Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c121957aa | ||
|
|
5d00124ca0 | ||
|
|
b164fbd9bb | ||
|
|
f1620a784b | ||
|
|
e546ba3515 | ||
|
|
d39e781203 | ||
|
|
b5dfaf8188 | ||
|
|
0bd3d70e12 | ||
|
|
bce3d1a046 | ||
|
|
826b036724 | ||
|
|
799bd01a79 | ||
|
|
814663a528 | ||
|
|
ee9d1a3ad1 | ||
|
|
11a4d08b45 | ||
|
|
1eb2165b28 | ||
|
|
3f22c3a531 | ||
|
|
2c618c7a6a | ||
|
|
7622537961 | ||
|
|
5365df91f6 | ||
|
|
194295d34c | ||
|
|
5e9a08aeda | ||
|
|
f870e4d8b7 | ||
|
|
8ba0e465b1 | ||
|
|
0e470688a1 | ||
|
|
1e7152846b | ||
|
|
e29f723486 | ||
|
|
59d10184e8 | ||
|
|
da03510a92 | ||
|
|
199c7797f5 | ||
|
|
9a32cab718 | ||
|
|
4853a49112 | ||
|
|
0d637340f5 | ||
|
|
401469e276 | ||
|
|
31e59f6339 | ||
|
|
8dde585d49 | ||
|
|
3c84007f17 | ||
|
|
d11b0cabaa | ||
|
|
4b5908d6cc | ||
|
|
5bb8f2add0 | ||
|
|
484327a032 | ||
|
|
9ea9f571bb | ||
|
|
9b4a58f82f | ||
|
|
e6adbbece4 | ||
|
|
1e79b15351 | ||
|
|
6f3c123b3a | ||
|
|
4a0f763307 | ||
|
|
a121d38fbb | ||
|
|
1e8b97888a | ||
|
|
9ca2fe487c | ||
|
|
229ce7e1e0 | ||
|
|
39d5fd1856 | ||
|
|
fed1c1c960 | ||
|
|
e7df5e539c | ||
|
|
dcc7f9eb72 | ||
|
|
baaf5c457f | ||
|
|
62bf5c35a8 | ||
|
|
d619afff29 | ||
|
|
3bf9acf4d4 | ||
|
|
a7ec55d741 | ||
|
|
0701af0f35 | ||
|
|
c0851c5339 | ||
|
|
785b6c2d41 | ||
|
|
cce3fd4f58 | ||
|
|
0dd6beebb2 | ||
|
|
8fb2a76698 | ||
|
|
3d6713caa9 | ||
|
|
8d8fdce519 | ||
|
|
c385a1466c | ||
|
|
8e1fdb33d2 | ||
|
|
29222be1fa | ||
|
|
08aca3b28d | ||
|
|
658139d607 | ||
|
|
1d4abf7977 | ||
|
|
62e8bc08d5 | ||
|
|
475a30caf4 | ||
|
|
c1593d7868 | ||
|
|
0dbcfe3770 | ||
|
|
e3ffb87cfc | ||
|
|
b4218ec6a7 | ||
|
|
48f8656ef8 | ||
|
|
cee8491b3d | ||
|
|
4bc3f3b4b3 | ||
|
|
adb1105621 | ||
|
|
c8fd79e4ba | ||
|
|
52bc9f71cc | ||
|
|
a1d3db0e16 | ||
|
|
d0e34126e1 | ||
|
|
c96ebea077 | ||
|
|
f00a72ad15 | ||
|
|
22171eb66f | ||
|
|
061fdeb72b | ||
|
|
8eba23574b | ||
|
|
7b650a74a6 | ||
|
|
805bd4cf60 | ||
|
|
56a32d493f | ||
|
|
b24a685066 | ||
|
|
fb06fbd0e1 | ||
|
|
719369057f | ||
|
|
cea9d98155 | ||
|
|
6cf15e0698 | ||
|
|
2971b9ad2e | ||
|
|
3e86fc76b6 | ||
|
|
829a18715a | ||
|
|
844c8e12a7 | ||
|
|
ef75f905f4 | ||
|
|
cd816cbe5c | ||
|
|
2b0fa3a314 | ||
|
|
ead990e61c | ||
|
|
eb6e343e4c | ||
|
|
caf5b88eef | ||
|
|
183bef070d | ||
|
|
643bf5fabb | ||
|
|
7d446d5f64 | ||
|
|
73ce689d5e | ||
|
|
5791f2e58c | ||
|
|
69cc2cbe29 | ||
|
|
7df2e127a4 | ||
|
|
d503cf7f7f | ||
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd | ||
|
|
c65b608f0b | ||
|
|
cff8368b7c | ||
|
|
2f367bbd6b | ||
|
|
c3d6324ec6 | ||
|
|
4800a2da80 | ||
|
|
70a0cd44a1 | ||
|
|
545c9c7d07 | ||
|
|
cd60f496fa | ||
|
|
f883065bb9 | ||
|
|
b71bd71d8d | ||
|
|
0524d24b13 |
+77
-36
@@ -11,31 +11,37 @@ POSTGRES_DB=postgres #
|
||||
|
||||
# Localization and Display
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
|
||||
# External Links
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
SUBSCRIPTION_MINI_APP_URL="" # URL of the subscription mini-app
|
||||
START_COMMAND_DESCRIPTION="" # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE=False # Disable the welcome message
|
||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||
|
||||
# Required channel subscription
|
||||
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||
REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=False # Enable/disable the required channel gate
|
||||
REQUIRED_CHANNEL_ID="" # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
TELEGRAM_WEBHOOK_PATH=/webhook/telegram # Relative path for Telegram updates
|
||||
TELEGRAM_WEBHOOK_SECRET= # Secret token for Telegram webhook validation header
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
STARS_PROVIDER_TOKEN= # Leave empty for Telegram Stars (XTR)
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
@@ -44,12 +50,20 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot #
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||
|
||||
# Nalogo (self-employed receipts)
|
||||
NALOGO_INN=your_inn # INN for nalog.ru
|
||||
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
|
||||
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
|
||||
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
|
||||
|
||||
# FreeKassa Payment Gateway Configuration
|
||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
@@ -57,40 +71,53 @@ CRYPTOPAY_NETWORK=mainnet #
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
|
||||
# Tribute Payment Gateway Configuration
|
||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||
# Platega Payment Gateway Configuration
|
||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
||||
PLATEGA_MERCHANT_ID="" # Your MerchantId from Platega
|
||||
PLATEGA_SECRET="" # API secret from Platega
|
||||
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
|
||||
PLATEGA_RETURN_URL="" # Optional: redirect after successful payment (defaults to bot link)
|
||||
PLATEGA_FAILED_URL="" # Optional: redirect after failed/cancelled payment (defaults to return URL)
|
||||
|
||||
# SeverPay Payment Gateway Configuration
|
||||
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
||||
SEVERPAY_MID="" # Your MID from SeverPay
|
||||
SEVERPAY_TOKEN="" # API token/secret for signing requests
|
||||
SEVERPAY_RETURN_URL="" # Optional: redirect URL after payment (defaults to bot link)
|
||||
SEVERPAY_LIFETIME_MINUTES="" # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
TRIBUTE_LINK_1_MONTH=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
TRIBUTE_LINK_3_MONTHS=
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
TRIBUTE_LINK_6_MONTHS=
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
|
||||
# Traffic Packages (enables traffic sale mode when set)
|
||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
||||
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
REFERRAL_ENABLED=True # Enable/disable the referral system
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
@@ -104,39 +131,53 @@ REFEREE_BONUS_DAYS_6_MONTHS=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET="" # secret used to verify panel webhook signatures
|
||||
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||
# Default External Squad for Users (Optional, single UUID)
|
||||
USER_EXTERNAL_SQUAD_UUID="" # Optional: UUID from Remnawave External Squads to auto-link new panel users
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Connection link handling (happ crypt4)
|
||||
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
|
||||
CRYPT4_REDIRECT_URL="" # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_STORE_MESSAGE_CONTENT=False # Store message/callback content in DB logs
|
||||
LOG_STORE_RAW_UPDATES=False # Store raw update payload snippets in DB logs
|
||||
LOG_EXPORT_INCLUDE_SENSITIVE=False # Include content/raw update columns in admin CSV export
|
||||
LOG_ADMIN_HIDE=False # Hide admin actions from "All message logs" UI and CSV export
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID="" # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
|
||||
# Discount promo reservation timeout
|
||||
DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES=10 # Minutes to keep discount promo reservation before it expires
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Codex PR Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
jobs:
|
||||
codex:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
final_message: ${{ steps.run_codex.outputs.final-message }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.pull_request.number }}/merge
|
||||
|
||||
- name: Pre-fetch base and head refs for the PR
|
||||
run: |
|
||||
git fetch --no-tags origin \
|
||||
${{ github.event.pull_request.base.ref }} \
|
||||
+refs/pull/${{ github.event.pull_request.number }}/head
|
||||
|
||||
- name: Run Codex review
|
||||
id: run_codex
|
||||
uses: openai/codex-action@v1
|
||||
with:
|
||||
openai-api-key: ${{ secrets.CODEX_API_KEY }}
|
||||
responses-api-endpoint: ${{ secrets.CODEX_RESPONSES_API_ENDPOINT }}
|
||||
prompt: |
|
||||
This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}.
|
||||
|
||||
Review only changes from this PR using:
|
||||
git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}
|
||||
|
||||
Focus on:
|
||||
- bugs and regressions
|
||||
- security issues
|
||||
- risky edge cases
|
||||
- missing tests where critical
|
||||
|
||||
Keep feedback concise and actionable.
|
||||
|
||||
Pull request title and body:
|
||||
----
|
||||
${{ github.event.pull_request.title }}
|
||||
${{ github.event.pull_request.body }}
|
||||
|
||||
post-feedback:
|
||||
runs-on: ubuntu-latest
|
||||
needs: codex
|
||||
if: needs.codex.outputs.final_message != ''
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Post Codex feedback to PR
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }}
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: process.env.CODEX_FINAL_MESSAGE,
|
||||
});
|
||||
@@ -9,8 +9,9 @@ on:
|
||||
- dev
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -26,18 +27,26 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
@@ -56,4 +65,4 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
|
||||
@@ -9,6 +9,10 @@ on:
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
|
||||
env:
|
||||
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -27,6 +31,12 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU (для эмуляции arm64 на x86)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -40,5 +50,7 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
${{ env.GHCR_IMAGE }}:latest
|
||||
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
|
||||
${{ env.DOCKERHUB_IMAGE }}:latest
|
||||
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
|
||||
|
||||
@@ -17,3 +17,5 @@ __pycache__/
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
.serena/
|
||||
AGENTS.md
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,11 +7,11 @@ COPY requirements.txt .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
|
||||
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
@@ -24,11 +24,12 @@
|
||||
|
||||
## 🚀 Технологии
|
||||
|
||||
- **Python 3.11**
|
||||
- **Python 3.12**
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **Alembic:** Миграции схемы базы данных.
|
||||
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
|
||||
@@ -45,7 +46,7 @@
|
||||
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
||||
git clone https://github.com/kavore/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
```
|
||||
|
||||
@@ -68,8 +69,10 @@
|
||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | Включить/выключить обязательную проверку подписки на канал (`true`/`false`). | `false` |
|
||||
| `REQUIRED_CHANNEL_ID` | ID канала для проверки подписки. Используется, только если `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`. | `-1001234567890` |
|
||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||
| `REFERRAL_ENABLED` | Включить/выключить реферальную систему полностью (`true`/`false`). | `true` |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -78,11 +81,18 @@
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
||||
| `TELEGRAM_WEBHOOK_PATH` | Относительный путь Telegram вебхука. По умолчанию `/webhook/telegram`. |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | (Рекомендуется) Секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. По умолчанию `0.0.0.0`. | `0.0.0.0` |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
|
||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
||||
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
||||
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
|
||||
| `NALOGO_PASSWORD` | Пароль для авторизации в nalog.ru (самозанятый). |
|
||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||
@@ -91,8 +101,33 @@
|
||||
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||
| `STARS_PROVIDER_TOKEN` | Токен провайдера Telegram invoice. Для Stars (XTR) оставить пустым. |
|
||||
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
|
||||
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
|
||||
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
|
||||
| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
|
||||
| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
|
||||
| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
|
||||
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
|
||||
| `SEVERPAY_MID` | MID магазина в SeverPay. |
|
||||
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
|
||||
| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. |
|
||||
| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). |
|
||||
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки логирования</b></summary>
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
| --- | --- | --- |
|
||||
| `LOGS_PAGE_SIZE` | Количество записей на странице в разделе админ-логов. | `10` |
|
||||
| `LOG_STORE_MESSAGE_CONTENT` | Сохранять ли содержимое сообщений/колбэков в БД логов (`true`/`false`). | `false` |
|
||||
| `LOG_STORE_RAW_UPDATES` | Сохранять ли превью сырого Telegram update в БД логов (`true`/`false`). | `false` |
|
||||
| `LOG_EXPORT_INCLUDE_SENSITIVE` | Добавлять ли в CSV экспорт чувствительные поля (`content`, `raw_update_preview`). | `false` |
|
||||
| `LOG_ADMIN_HIDE` | Скрывать админские события (`ADMIN_IDS`) в интерфейсе «Все логи сообщений» и в CSV экспорте (`true`/`false`). Логи продолжают записываться в БД. | `true` |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -102,7 +137,6 @@
|
||||
- `1_MONTH_ENABLED`: `true` или `false`
|
||||
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
||||
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
||||
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
|
||||
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
||||
</details>
|
||||
|
||||
@@ -115,11 +149,12 @@
|
||||
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||
| `USER_EXTERNAL_SQUAD_UUID` | Опционально. UUID внешнего отряда (External Squad) из [документации Remnawave](https://docs.rw/api), куда автоматически добавляются новые пользователи. |
|
||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||
|
||||
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||
</gidetails>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки пробного периода</b></summary>
|
||||
@@ -138,17 +173,18 @@
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
|
||||
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
|
||||
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
|
||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь берётся из `TELEGRAM_WEBHOOK_PATH` (по умолчанию `https://<ваш_домен>/webhook/telegram`).
|
||||
|
||||
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
|
||||
@@ -157,12 +193,169 @@
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||
> 💡 Если включена проверка подписки (`REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`), добавьте бота администратором в канал из `REQUIRED_CHANNEL_ID`. Пользователь увидит кнопку «Проверить подписку», и после успешного подтверждения доступ продолжится.
|
||||
|
||||
### Миграции БД (Alembic)
|
||||
|
||||
- При запуске `python main.py` миграции применяются автоматически до `head`.
|
||||
- Для ручного запуска используйте:
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Подробная инструкция для развертывания на сервере с панелью Remnawave
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop
|
||||
```
|
||||
|
||||
### 2. Настройка переменных окружения
|
||||
|
||||
```bash
|
||||
cp .env.example .env && nano .env
|
||||
```
|
||||
|
||||
**Обязательные поля для заполнения:**
|
||||
- `BOT_TOKEN` - токен телеграмм бота, например, `234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`
|
||||
- `ADMIN_IDS` - TG ID администраторов, например, `12345678,98765432` и т.д. (через запятую без пробелов)
|
||||
- `WEBHOOK_BASE_URL` - Обязательно. Базовый URL для вебхуков, например `https://webhook.domain.com`
|
||||
- `PANEL_API_URL` - URL API вашей панели Remnawave (например, `http://remnawave:3000/api` или `https://panel.domain.com/api`)
|
||||
- `PANEL_API_KEY` - API ключ для доступа к панели (генерируется из UI-интерфейса панели)
|
||||
- `PANEL_WEBHOOK_SECRET` - Секретный ключ для проверки вебхуков от панели (берётся из `.env` самой панели)
|
||||
- `USER_SQUAD_UUIDS` - ID отрядов для новых пользователей
|
||||
|
||||
### 3. Настройка Reverse Proxy (Nginx)
|
||||
|
||||
Перейдите в директорию конфигурации Nginx панели Remnawave:
|
||||
|
||||
```bash
|
||||
cd /opt/remnawave/nginx && nano nginx.conf
|
||||
```
|
||||
|
||||
Добавьте в `nginx.conf` следующую конфигурацию:
|
||||
|
||||
```nginx
|
||||
upstream remnawave-tg-shop {
|
||||
server remnawave-tg-shop:8080;
|
||||
}
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name webhook.domain.com; # Домен для отправки Webhook'ов
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
|
||||
ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key";
|
||||
ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_intercept_errors on;
|
||||
error_page 400 404 500 502 @redirect;
|
||||
|
||||
location / {
|
||||
proxy_pass http://remnawave-tg-shop$request_uri;
|
||||
}
|
||||
|
||||
location @redirect {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Выпуск SSL-сертификата для домена webhook
|
||||
|
||||
Убедитесь, что установлены необходимые компоненты, а также откройте 80 порт:
|
||||
|
||||
```bash
|
||||
sudo apt-get install cron socat
|
||||
curl https://get.acme.sh | sh -s email=EMAIL && source ~/.bashrc
|
||||
ufw allow 80/tcp && ufw reload
|
||||
```
|
||||
|
||||
Выпустите сертификат:
|
||||
|
||||
```bash
|
||||
acme.sh --set-default-ca --server letsencrypt
|
||||
acme.sh --issue --standalone -d 'webhook.domain.com' \
|
||||
--key-file /opt/remnawave/nginx/webhook_privkey.key \
|
||||
--fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem
|
||||
```
|
||||
|
||||
### 5. Добавление сертификатов в Docker Compose Nginx
|
||||
|
||||
Отредактируйте `docker-compose.yml` панели Nginx:
|
||||
|
||||
```bash
|
||||
cd /opt/remnawave/nginx && nano docker-compose.yml
|
||||
```
|
||||
|
||||
Добавьте две строки в секцию `volumes`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
remnawave-nginx:
|
||||
image: nginx:1.26
|
||||
container_name: remnawave-nginx
|
||||
hostname: remnawave-nginx
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro
|
||||
- ./privkey.key:/etc/nginx/ssl/privkey.key:ro
|
||||
- ./subdomain_fullchain.pem:/etc/nginx/ssl/subdomain_fullchain.pem:ro
|
||||
- ./subdomain_privkey.key:/etc/nginx/ssl/subdomain_privkey.key:ro
|
||||
- ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro # Добавьте эту строку
|
||||
- ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro # Добавьте эту строку
|
||||
restart: always
|
||||
ports:
|
||||
- '0.0.0.0:443:443'
|
||||
networks:
|
||||
- remnawave-network
|
||||
|
||||
networks:
|
||||
remnawave-network:
|
||||
name: remnawave-network
|
||||
driver: bridge
|
||||
external: true
|
||||
```
|
||||
|
||||
### 6. Запуск бота и перезапуск Nginx
|
||||
|
||||
Запустите бота:
|
||||
|
||||
```bash
|
||||
cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t
|
||||
```
|
||||
|
||||
Перезапустите Nginx:
|
||||
|
||||
```bash
|
||||
cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker compose logs -f -t
|
||||
```
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
|
||||
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from db.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _get_database_url() -> str:
|
||||
configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if configured_url:
|
||||
return configured_url
|
||||
|
||||
env_url = os.getenv("DATABASE_URL")
|
||||
if env_url:
|
||||
return env_url
|
||||
|
||||
user = os.getenv("POSTGRES_USER", "postgres")
|
||||
password = os.getenv("POSTGRES_PASSWORD", "postgres")
|
||||
host = os.getenv("POSTGRES_HOST", "localhost")
|
||||
port = os.getenv("POSTGRES_PORT", "5432")
|
||||
db_name = os.getenv("POSTGRES_DB", "postgres")
|
||||
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db_name}"
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
|
||||
context.configure(
|
||||
url=_get_database_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
configuration = config.get_section(config.config_ini_section) or {}
|
||||
configuration["sqlalchemy.url"] = _get_database_url()
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
connectable = config.attributes.get("connection", None)
|
||||
if connectable is None:
|
||||
asyncio.run(run_async_migrations())
|
||||
else:
|
||||
do_run_migrations(connectable)
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,280 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001_initial_schema
|
||||
Revises:
|
||||
Create Date: 2026-02-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0001_initial_schema"
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("username", sa.String(), nullable=True),
|
||||
sa.Column("first_name", sa.String(), nullable=True),
|
||||
sa.Column("last_name", sa.String(), nullable=True),
|
||||
sa.Column("language_code", sa.String(), nullable=True),
|
||||
sa.Column("registration_date", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("is_banned", sa.Boolean(), nullable=True),
|
||||
sa.Column("panel_user_uuid", sa.String(), nullable=True),
|
||||
sa.Column("referral_code", sa.String(length=16), nullable=True),
|
||||
sa.Column("referred_by_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("channel_subscription_verified", sa.Boolean(), nullable=True),
|
||||
sa.Column("channel_subscription_checked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("channel_subscription_verified_for", sa.BigInteger(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["referred_by_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
sa.UniqueConstraint("panel_user_uuid"),
|
||||
sa.UniqueConstraint("referral_code"),
|
||||
)
|
||||
op.create_index("ix_users_username", "users", ["username"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"promo_codes",
|
||||
sa.Column("promo_code_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("code", sa.String(), nullable=False),
|
||||
sa.Column("promo_type", sa.String(), nullable=False),
|
||||
sa.Column("bonus_days", sa.Integer(), nullable=True),
|
||||
sa.Column("discount_percentage", sa.Integer(), nullable=True),
|
||||
sa.Column("max_activations", sa.Integer(), nullable=False),
|
||||
sa.Column("current_activations", sa.Integer(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_by_admin_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("promo_code_id"),
|
||||
sa.UniqueConstraint("code"),
|
||||
)
|
||||
op.create_index("idx_promo_codes_promo_type", "promo_codes", ["promo_type"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"ad_campaigns",
|
||||
sa.Column("ad_campaign_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("source", sa.String(), nullable=False),
|
||||
sa.Column("start_param", sa.String(), nullable=False),
|
||||
sa.Column("cost", sa.Float(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.PrimaryKeyConstraint("ad_campaign_id"),
|
||||
sa.UniqueConstraint("start_param"),
|
||||
)
|
||||
op.create_index("ix_ad_campaigns_source", "ad_campaigns", ["source"], unique=False)
|
||||
op.create_index("ix_ad_campaigns_is_active", "ad_campaigns", ["is_active"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"subscriptions",
|
||||
sa.Column("subscription_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("panel_user_uuid", sa.String(), nullable=False),
|
||||
sa.Column("panel_subscription_uuid", sa.String(), nullable=True),
|
||||
sa.Column("start_date", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("end_date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("duration_months", sa.Integer(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("status_from_panel", sa.String(), nullable=True),
|
||||
sa.Column("traffic_limit_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("traffic_used_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("last_notification_sent", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("provider", sa.String(), nullable=True),
|
||||
sa.Column("skip_notifications", sa.Boolean(), nullable=True),
|
||||
sa.Column("auto_renew_enabled", sa.Boolean(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("subscription_id"),
|
||||
sa.UniqueConstraint("panel_subscription_uuid"),
|
||||
)
|
||||
op.create_index("ix_subscriptions_user_id", "subscriptions", ["user_id"], unique=False)
|
||||
op.create_index("ix_subscriptions_panel_user_uuid", "subscriptions", ["panel_user_uuid"], unique=False)
|
||||
op.create_index("ix_subscriptions_end_date", "subscriptions", ["end_date"], unique=False)
|
||||
op.create_index("ix_subscriptions_is_active", "subscriptions", ["is_active"], unique=False)
|
||||
op.create_index("ix_subscriptions_auto_renew_enabled", "subscriptions", ["auto_renew_enabled"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"payments",
|
||||
sa.Column("payment_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("yookassa_payment_id", sa.String(), nullable=True),
|
||||
sa.Column("provider_payment_id", sa.String(), nullable=True),
|
||||
sa.Column("provider", sa.String(), nullable=False),
|
||||
sa.Column("idempotence_key", sa.String(), nullable=True),
|
||||
sa.Column("amount", sa.Float(), nullable=False),
|
||||
sa.Column("original_amount", sa.Float(), nullable=True),
|
||||
sa.Column("discount_applied", sa.Float(), nullable=True),
|
||||
sa.Column("currency", sa.String(), nullable=False),
|
||||
sa.Column("status", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.String(), nullable=True),
|
||||
sa.Column("subscription_duration_months", sa.Integer(), nullable=True),
|
||||
sa.Column("promo_code_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["promo_code_id"], ["promo_codes.promo_code_id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("payment_id"),
|
||||
sa.UniqueConstraint("idempotence_key"),
|
||||
sa.UniqueConstraint("provider_payment_id"),
|
||||
sa.UniqueConstraint("yookassa_payment_id"),
|
||||
)
|
||||
op.create_index("ix_payments_user_id", "payments", ["user_id"], unique=False)
|
||||
op.create_index("ix_payments_provider", "payments", ["provider"], unique=False)
|
||||
op.create_index("ix_payments_status", "payments", ["status"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"user_billing",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("yookassa_payment_method_id", sa.String(), nullable=True),
|
||||
sa.Column("card_last4", sa.String(), nullable=True),
|
||||
sa.Column("card_network", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
sa.UniqueConstraint("yookassa_payment_method_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_payment_methods",
|
||||
sa.Column("method_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("provider", sa.String(), nullable=False),
|
||||
sa.Column("provider_payment_method_id", sa.String(), nullable=False),
|
||||
sa.Column("card_last4", sa.String(), nullable=True),
|
||||
sa.Column("card_network", sa.String(), nullable=True),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("method_id"),
|
||||
sa.UniqueConstraint("provider_payment_method_id"),
|
||||
sa.UniqueConstraint("user_id", "provider_payment_method_id", name="uq_user_provider_method"),
|
||||
)
|
||||
op.create_index("ix_user_payment_methods_user_id", "user_payment_methods", ["user_id"], unique=False)
|
||||
op.create_index("ix_user_payment_methods_provider", "user_payment_methods", ["provider"], unique=False)
|
||||
op.create_index("ix_user_payment_methods_is_default", "user_payment_methods", ["is_default"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"promo_code_activations",
|
||||
sa.Column("activation_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("promo_code_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("payment_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["payment_id"], ["payments.payment_id"]),
|
||||
sa.ForeignKeyConstraint(["promo_code_id"], ["promo_codes.promo_code_id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("activation_id"),
|
||||
sa.UniqueConstraint("promo_code_id", "user_id", name="uq_promo_user_activation"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"active_discounts",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("promo_code_id", sa.Integer(), nullable=False),
|
||||
sa.Column("discount_percentage", sa.Integer(), nullable=False),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["promo_code_id"], ["promo_codes.promo_code_id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"message_logs",
|
||||
sa.Column("log_id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("telegram_username", sa.String(), nullable=True),
|
||||
sa.Column("telegram_first_name", sa.String(), nullable=True),
|
||||
sa.Column("event_type", sa.String(), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("raw_update_preview", sa.Text(), nullable=True),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("is_admin_event", sa.Boolean(), nullable=True),
|
||||
sa.Column("target_user_id", sa.BigInteger(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["target_user_id"], ["users.user_id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("log_id"),
|
||||
)
|
||||
op.create_index("ix_message_logs_user_id", "message_logs", ["user_id"], unique=False)
|
||||
op.create_index("ix_message_logs_event_type", "message_logs", ["event_type"], unique=False)
|
||||
op.create_index("ix_message_logs_timestamp", "message_logs", ["timestamp"], unique=False)
|
||||
op.create_index("ix_message_logs_target_user_id", "message_logs", ["target_user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"panel_sync_status",
|
||||
sa.Column("id", sa.Integer(), autoincrement=False, nullable=False),
|
||||
sa.Column("last_sync_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status", sa.String(), nullable=True),
|
||||
sa.Column("details", sa.Text(), nullable=True),
|
||||
sa.Column("users_processed_from_panel", sa.Integer(), nullable=True),
|
||||
sa.Column("subscriptions_synced", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ad_attributions",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("ad_campaign_id", sa.Integer(), nullable=False),
|
||||
sa.Column("first_start_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True),
|
||||
sa.Column("trial_activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["ad_campaign_id"], ["ad_campaigns.ad_campaign_id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.user_id"]),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
op.create_index("ix_ad_attributions_ad_campaign_id", "ad_attributions", ["ad_campaign_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ad_attributions_ad_campaign_id", table_name="ad_attributions")
|
||||
op.drop_table("ad_attributions")
|
||||
|
||||
op.drop_table("panel_sync_status")
|
||||
|
||||
op.drop_index("ix_message_logs_target_user_id", table_name="message_logs")
|
||||
op.drop_index("ix_message_logs_timestamp", table_name="message_logs")
|
||||
op.drop_index("ix_message_logs_event_type", table_name="message_logs")
|
||||
op.drop_index("ix_message_logs_user_id", table_name="message_logs")
|
||||
op.drop_table("message_logs")
|
||||
|
||||
op.drop_table("active_discounts")
|
||||
|
||||
op.drop_table("promo_code_activations")
|
||||
|
||||
op.drop_index("ix_user_payment_methods_is_default", table_name="user_payment_methods")
|
||||
op.drop_index("ix_user_payment_methods_provider", table_name="user_payment_methods")
|
||||
op.drop_index("ix_user_payment_methods_user_id", table_name="user_payment_methods")
|
||||
op.drop_table("user_payment_methods")
|
||||
|
||||
op.drop_table("user_billing")
|
||||
|
||||
op.drop_index("ix_payments_status", table_name="payments")
|
||||
op.drop_index("ix_payments_provider", table_name="payments")
|
||||
op.drop_index("ix_payments_user_id", table_name="payments")
|
||||
op.drop_table("payments")
|
||||
|
||||
op.drop_index("ix_subscriptions_auto_renew_enabled", table_name="subscriptions")
|
||||
op.drop_index("ix_subscriptions_is_active", table_name="subscriptions")
|
||||
op.drop_index("ix_subscriptions_end_date", table_name="subscriptions")
|
||||
op.drop_index("ix_subscriptions_panel_user_uuid", table_name="subscriptions")
|
||||
op.drop_index("ix_subscriptions_user_id", table_name="subscriptions")
|
||||
op.drop_table("subscriptions")
|
||||
|
||||
op.drop_index("ix_ad_campaigns_is_active", table_name="ad_campaigns")
|
||||
op.drop_index("ix_ad_campaigns_source", table_name="ad_campaigns")
|
||||
op.drop_table("ad_campaigns")
|
||||
|
||||
op.drop_index("idx_promo_codes_promo_type", table_name="promo_codes")
|
||||
op.drop_table("promo_codes")
|
||||
|
||||
op.drop_index("ix_users_username", table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add active discount expiration
|
||||
|
||||
Revision ID: 0002_active_discount_expires_at
|
||||
Revises: 0001_initial_schema
|
||||
Create Date: 2026-02-08 00:00:01.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op, context
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0002_active_discount_expires_at"
|
||||
down_revision: Union[str, Sequence[str], None] = "0001_initial_schema"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
_INDEX_NAME = "idx_active_discounts_expires_at"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.add_column(
|
||||
"active_discounts",
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE active_discounts "
|
||||
"SET expires_at = COALESCE(activated_at, NOW()) + INTERVAL '10 minutes' "
|
||||
"WHERE expires_at IS NULL"
|
||||
)
|
||||
)
|
||||
op.alter_column("active_discounts", "expires_at", nullable=False)
|
||||
op.create_index(_INDEX_NAME, "active_discounts", ["expires_at"], unique=False)
|
||||
return
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not inspector.has_table("active_discounts"):
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("active_discounts")}
|
||||
if "expires_at" not in columns:
|
||||
op.add_column(
|
||||
"active_discounts",
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE active_discounts "
|
||||
"SET expires_at = COALESCE(activated_at, NOW()) + INTERVAL '10 minutes' "
|
||||
"WHERE expires_at IS NULL"
|
||||
)
|
||||
)
|
||||
op.alter_column("active_discounts", "expires_at", nullable=False)
|
||||
|
||||
indexes = {index["name"] for index in inspector.get_indexes("active_discounts")}
|
||||
if _INDEX_NAME not in indexes:
|
||||
op.create_index(_INDEX_NAME, "active_discounts", ["expires_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.drop_index(_INDEX_NAME, table_name="active_discounts")
|
||||
op.drop_column("active_discounts", "expires_at")
|
||||
return
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not inspector.has_table("active_discounts"):
|
||||
return
|
||||
|
||||
indexes = {index["name"] for index in inspector.get_indexes("active_discounts")}
|
||||
if _INDEX_NAME in indexes:
|
||||
op.drop_index(_INDEX_NAME, table_name="active_discounts")
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("active_discounts")}
|
||||
if "expires_at" in columns:
|
||||
op.drop_column("active_discounts", "expires_at")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""harden promo current activations
|
||||
|
||||
Revision ID: 0003_promo_current_activations_not_null
|
||||
Revises: 0002_active_discount_expires_at
|
||||
Create Date: 2026-02-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op, context
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0003_promo_curr_act_not_null"
|
||||
down_revision: Union[str, Sequence[str],
|
||||
None] = "0002_active_discount_expires_at"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE promo_codes SET current_activations = 0 "
|
||||
"WHERE current_activations IS NULL"
|
||||
)
|
||||
)
|
||||
op.alter_column(
|
||||
"promo_codes",
|
||||
"current_activations",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
)
|
||||
return
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table("promo_codes"):
|
||||
return
|
||||
|
||||
promo_columns = {column["name"]
|
||||
for column in inspector.get_columns("promo_codes")}
|
||||
if "current_activations" not in promo_columns:
|
||||
op.add_column(
|
||||
"promo_codes",
|
||||
sa.Column("current_activations", sa.Integer(),
|
||||
nullable=False, server_default=sa.text("0")),
|
||||
)
|
||||
return
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE promo_codes SET current_activations = 0 "
|
||||
"WHERE current_activations IS NULL"
|
||||
)
|
||||
)
|
||||
op.alter_column(
|
||||
"promo_codes",
|
||||
"current_activations",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
op.alter_column(
|
||||
"promo_codes",
|
||||
"current_activations",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
server_default=None,
|
||||
)
|
||||
return
|
||||
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table("promo_codes"):
|
||||
return
|
||||
|
||||
promo_columns = {column["name"]
|
||||
for column in inspector.get_columns("promo_codes")}
|
||||
if "current_activations" in promo_columns:
|
||||
op.alter_column(
|
||||
"promo_codes",
|
||||
"current_activations",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
server_default=None,
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
@@ -9,10 +10,12 @@ from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
|
||||
|
||||
def build_core_services(
|
||||
@@ -45,14 +48,23 @@ def build_core_services(
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
)
|
||||
tribute_service = TributeService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
platega_service = PlategaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
severpay_service = SeverPayService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
|
||||
yookassa_service = YooKassaService(
|
||||
@@ -62,15 +74,22 @@ def build_core_services(
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
settings_obj=settings,
|
||||
)
|
||||
lknpd_service = LknpdService(
|
||||
settings.LKNPD_INN,
|
||||
settings.LKNPD_PASSWORD,
|
||||
api_url=settings.LKNPD_API_URL,
|
||||
)
|
||||
|
||||
# Wire services that depend on each other
|
||||
try:
|
||||
# Allow subscription service to consume promo codes
|
||||
setattr(subscription_service, "promo_code_service", promo_code_service)
|
||||
# Attach YooKassa to subscription service for auto-renew charges
|
||||
setattr(subscription_service, "yookassa_service", yookassa_service)
|
||||
# Allow panel webhook to trigger renewals through subscription service
|
||||
setattr(panel_webhook_service, "subscription_service", subscription_service)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/app/factories/build_services.py: %s", exc)
|
||||
|
||||
return {
|
||||
"panel_service": panel_service,
|
||||
@@ -80,8 +99,9 @@ def build_core_services(
|
||||
"stars_service": stars_service,
|
||||
"cryptopay_service": cryptopay_service,
|
||||
"freekassa_service": freekassa_service,
|
||||
"tribute_service": tribute_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"yookassa_service": yookassa_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
"platega_service": platega_service,
|
||||
"severpay_service": severpay_service,
|
||||
}
|
||||
|
||||
|
||||
+29
-12
@@ -23,14 +23,16 @@ async def build_and_start_web_app(
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
# Access dispatcher workflow_data directly to avoid sequence protocol issues
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||
@@ -39,35 +41,51 @@ async def build_and_start_web_app(
|
||||
setup_application(app, dp, bot=bot)
|
||||
|
||||
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
|
||||
telegram_webhook_secret = (settings.TELEGRAM_WEBHOOK_SECRET or "").strip() or None
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = f"/{settings.BOT_TOKEN}"
|
||||
app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot))
|
||||
telegram_webhook_path = settings.telegram_webhook_path
|
||||
app.router.add_post(
|
||||
telegram_webhook_path,
|
||||
SimpleRequestHandler(
|
||||
dispatcher=dp,
|
||||
bot=bot,
|
||||
secret_token=telegram_webhook_secret,
|
||||
),
|
||||
)
|
||||
logging.info(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||
"Telegram webhook route configured at: [POST] %s (secret_token=%s)",
|
||||
telegram_webhook_path,
|
||||
"set" if telegram_webhook_secret else "not_set",
|
||||
)
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.tribute_service import tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
from bot.services.freekassa_service import freekassa_webhook_route
|
||||
|
||||
tribute_path = settings.tribute_webhook_path
|
||||
if tribute_path.startswith("/"):
|
||||
app.router.add_post(tribute_path, tribute_webhook_route)
|
||||
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
|
||||
from bot.services.platega_service import platega_webhook_route
|
||||
from bot.services.severpay_service import severpay_webhook_route
|
||||
|
||||
cp_path = settings.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||
logging.info("CryptoPay webhook route configured at: [POST] %s", cp_path)
|
||||
|
||||
fk_path = settings.freekassa_webhook_path
|
||||
if fk_path.startswith("/"):
|
||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||
|
||||
pg_path = settings.platega_webhook_path
|
||||
if pg_path.startswith("/"):
|
||||
app.router.add_post(pg_path, platega_webhook_route)
|
||||
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
|
||||
|
||||
sp_path = settings.severpay_webhook_path
|
||||
if sp_path.startswith("/"):
|
||||
app.router.add_post(sp_path, severpay_webhook_route)
|
||||
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
@@ -94,4 +112,3 @@ async def build_and_start_web_app(
|
||||
|
||||
# Run until cancelled
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/admin/ads.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:page:"))
|
||||
@@ -178,7 +178,7 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
@@ -224,7 +224,7 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings,
|
||||
|
||||
existed = await ad_dal.delete_campaign(session, camp_id)
|
||||
if not existed:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
await callback.answer(_("admin_ads_not_found"), show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
|
||||
@@ -262,8 +262,8 @@ async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, set
|
||||
await callback.message.edit_text(_("admin_ads_create_source_prompt"))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/admin/ads.py: %s", exc)
|
||||
|
||||
|
||||
@router.message(
|
||||
|
||||
@@ -122,7 +122,6 @@ async def process_broadcast_message_handler(
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_broadcast_invalid_html",
|
||||
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
@@ -172,8 +171,8 @@ async def change_broadcast_target_handler(
|
||||
current_lang, i18n, target=new_target
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/admin/broadcast.py: %s", exc)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -347,15 +346,6 @@ async def confirm_broadcast_callback_handler(
|
||||
total_failed = failed_count + dynamic_failed
|
||||
return _(
|
||||
"broadcast_queue_result",
|
||||
default=(
|
||||
"🚀 Рассылка поставлена в очередь!\n"
|
||||
"📤 В очередь добавлено: {sent_count}\n"
|
||||
"❌ Ошибок: {failed_count}\n\n"
|
||||
"📊 Статус очередей:\n"
|
||||
"👥 Очередь пользователей: {user_queue_size} сообщений\n"
|
||||
"📢 Очередь групп: {group_queue_size} сообщений\n\n"
|
||||
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
||||
),
|
||||
sent_count=sent_count,
|
||||
failed_count=total_failed,
|
||||
user_queue_size=stats["user_queue_size"],
|
||||
|
||||
@@ -98,8 +98,22 @@ async def admin_panel_actions_callback_handler(
|
||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_management":
|
||||
# This is deprecated, kept for compatibility
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_management_menu_handler(
|
||||
await admin_user_management_handlers.user_search_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_list" and len(action_parts) > 2:
|
||||
# Route to users list handler with page number
|
||||
from . import user_management as admin_user_management_handlers
|
||||
try:
|
||||
page = int(action_parts[2])
|
||||
await admin_user_management_handlers.users_list_handler(
|
||||
callback, i18n_data, settings, session, page)
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("Invalid page number", show_alert=True)
|
||||
elif action == "users_search_prompt":
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_search_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "view_banned":
|
||||
|
||||
|
||||
@@ -171,9 +171,17 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
|
||||
hide_admin_events = bool(settings.LOG_ADMIN_HIDE)
|
||||
logs_models = await message_log_dal.get_all_message_logs(
|
||||
session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE)
|
||||
total_logs_count = await message_log_dal.count_all_message_logs(session)
|
||||
session,
|
||||
settings.LOGS_PAGE_SIZE,
|
||||
page_idx * settings.LOGS_PAGE_SIZE,
|
||||
hide_admin_events=hide_admin_events,
|
||||
)
|
||||
total_logs_count = await message_log_dal.count_all_message_logs(
|
||||
session,
|
||||
hide_admin_events=hide_admin_events,
|
||||
)
|
||||
|
||||
await _display_formatted_logs(
|
||||
target_message=callback.message,
|
||||
@@ -338,50 +346,56 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await callback.answer(_(
|
||||
"admin_logs_csv_export_started",
|
||||
default="🔄 Начинаю экспорт логов в CSV..."
|
||||
"admin_logs_csv_export_started"
|
||||
))
|
||||
|
||||
try:
|
||||
# Get all logs (limit to 10000 for performance)
|
||||
logs_models = await message_log_dal.get_all_message_logs(
|
||||
session, limit=10000, offset=0)
|
||||
|
||||
session,
|
||||
limit=10000,
|
||||
offset=0,
|
||||
hide_admin_events=bool(settings.LOG_ADMIN_HIDE),
|
||||
)
|
||||
|
||||
if not logs_models:
|
||||
await callback.message.answer(_(
|
||||
"admin_logs_csv_no_data",
|
||||
default="❌ Нет данных для экспорта"
|
||||
"admin_logs_csv_no_data"
|
||||
))
|
||||
return
|
||||
|
||||
# Create CSV content
|
||||
csv_buffer = io.StringIO()
|
||||
csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
|
||||
|
||||
|
||||
# Write header
|
||||
headers = [
|
||||
_("admin_csv_header_log_id", default="Log ID"),
|
||||
_("admin_csv_header_timestamp", default="Timestamp"),
|
||||
_("admin_csv_header_user_id", default="User ID"),
|
||||
_("admin_csv_header_telegram_username", default="Telegram Username"),
|
||||
_("admin_csv_header_telegram_first_name", default="Telegram First Name"),
|
||||
_("admin_csv_header_event_type", default="Event Type"),
|
||||
_("admin_csv_header_content", default="Content"),
|
||||
_("admin_csv_header_is_admin_event", default="Is Admin Event"),
|
||||
_("admin_csv_header_target_user_id", default="Target User ID"),
|
||||
_("admin_csv_header_raw_update_preview", default="Raw Update Preview")
|
||||
_("admin_csv_header_log_id"),
|
||||
_("admin_csv_header_timestamp"),
|
||||
_("admin_csv_header_user_id"),
|
||||
_("admin_csv_header_telegram_username"),
|
||||
_("admin_csv_header_telegram_first_name"),
|
||||
_("admin_csv_header_event_type"),
|
||||
_("admin_csv_header_is_admin_event"),
|
||||
_("admin_csv_header_target_user_id"),
|
||||
]
|
||||
include_sensitive_fields = bool(settings.LOG_EXPORT_INCLUDE_SENSITIVE)
|
||||
if include_sensitive_fields:
|
||||
headers.extend([
|
||||
_("admin_csv_header_content"),
|
||||
_("admin_csv_header_raw_update_preview"),
|
||||
])
|
||||
csv_writer.writerow(headers)
|
||||
|
||||
|
||||
# Write data rows
|
||||
for log in logs_models:
|
||||
# Format timestamp
|
||||
timestamp_str = log.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if log.timestamp else ''
|
||||
|
||||
|
||||
# Clean content and raw_update_preview (remove newlines and quotes for CSV)
|
||||
content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip()
|
||||
raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip()
|
||||
|
||||
|
||||
row = [
|
||||
log.log_id or '',
|
||||
timestamp_str,
|
||||
@@ -389,41 +403,42 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
|
||||
log.telegram_username or '',
|
||||
log.telegram_first_name or '',
|
||||
log.event_type or '',
|
||||
content_clean,
|
||||
'Yes' if log.is_admin_event else 'No',
|
||||
log.target_user_id or '',
|
||||
raw_update_clean
|
||||
]
|
||||
if include_sensitive_fields:
|
||||
row.extend([
|
||||
content_clean,
|
||||
raw_update_clean,
|
||||
])
|
||||
csv_writer.writerow(row)
|
||||
|
||||
|
||||
# Create file
|
||||
csv_content = csv_buffer.getvalue()
|
||||
csv_buffer.close()
|
||||
|
||||
|
||||
# Generate filename with current timestamp
|
||||
now = datetime.now()
|
||||
filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
|
||||
# Send as document
|
||||
csv_file = types.BufferedInputFile(
|
||||
csv_content.encode('utf-8-sig'), # BOM for Excel compatibility
|
||||
filename=filename
|
||||
)
|
||||
|
||||
|
||||
await callback.message.answer_document(
|
||||
csv_file,
|
||||
caption=_(
|
||||
"admin_logs_csv_export_success",
|
||||
default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}",
|
||||
count=len(logs_models),
|
||||
date=now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
|
||||
await callback.message.answer(_(
|
||||
"admin_logs_csv_export_failed",
|
||||
default="❌ Ошибка при экспорте логов: {error}",
|
||||
error=str(e)
|
||||
))
|
||||
|
||||
@@ -34,12 +34,20 @@ async def get_payments_with_pagination(session: AsyncSession, page: int = 0,
|
||||
return payments, total_count
|
||||
|
||||
|
||||
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
||||
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str:
|
||||
"""Format single payment info as text."""
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
||||
|
||||
pending_statuses = [
|
||||
'pending',
|
||||
'pending_yookassa',
|
||||
'pending_freekassa',
|
||||
'pending_platega',
|
||||
'pending_severpay',
|
||||
'pending_cryptopay',
|
||||
]
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌"
|
||||
"⏳" if payment.status in pending_statuses else "❌"
|
||||
)
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
@@ -52,17 +60,27 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
||||
|
||||
provider_text = {
|
||||
'yookassa': 'YooKassa',
|
||||
'tribute': 'Tribute',
|
||||
'telegram_stars': 'Telegram Stars',
|
||||
'cryptopay': 'CryptoPay',
|
||||
'freekassa': 'FreeKassa',
|
||||
'severpay': 'SeverPay',
|
||||
'platega': 'Platega',
|
||||
}.get(payment.provider, payment.provider or 'Unknown')
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
if traffic_mode:
|
||||
traffic_val = payment.subscription_duration_months or 0
|
||||
traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
|
||||
period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display)
|
||||
else:
|
||||
period_line = _("admin_payment_months_label", months=payment.subscription_duration_months or 0)
|
||||
|
||||
return (
|
||||
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
|
||||
f"👤 {user_info}\n"
|
||||
f"💳 {provider_text}\n"
|
||||
f"📅 {payment_date}\n"
|
||||
f"{period_line}\n"
|
||||
f"📋 {payment.status}\n"
|
||||
f"📝 {payment.description or 'N/A'}"
|
||||
)
|
||||
@@ -84,7 +102,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
|
||||
if not payments and page == 0:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_payments_found", default="Платежи не найдены."),
|
||||
_("admin_no_payments_found"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -92,7 +110,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
return
|
||||
|
||||
# Format payments text
|
||||
text_parts = [_("admin_payments_header", default="💰 <b>Все платежи</b>")]
|
||||
text_parts = [_("admin_payments_header")]
|
||||
text_parts.append(_("admin_payments_pagination_info",
|
||||
shown=len(payments),
|
||||
total=total_count,
|
||||
@@ -100,7 +118,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
total_pages=total_pages) + "\n")
|
||||
|
||||
for i, payment in enumerate(payments, 1):
|
||||
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang)}")
|
||||
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}")
|
||||
text_parts.append("") # Empty line between payments
|
||||
|
||||
# Build keyboard with pagination and export
|
||||
@@ -122,11 +140,11 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
# Export and refresh buttons
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_export_payments_csv", default="📊 Экспорт CSV"),
|
||||
text=_("admin_export_payments_csv"),
|
||||
callback_data="payments_export_csv"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_refresh_payments", default="🔄 Обновить"),
|
||||
text=_("admin_refresh_payments"),
|
||||
callback_data=f"payments_page:{page}"
|
||||
)
|
||||
)
|
||||
@@ -173,7 +191,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
|
||||
if not all_payments:
|
||||
await callback.answer(
|
||||
_("admin_no_payments_to_export", default="Нет платежей для экспорта."),
|
||||
_("admin_no_payments_to_export"),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
@@ -184,22 +202,30 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
|
||||
# Write header
|
||||
writer.writerow([
|
||||
_("admin_csv_payment_id", default="ID"),
|
||||
_("admin_csv_user_id", default="User ID"),
|
||||
_("admin_csv_username", default="Username"),
|
||||
_("admin_csv_first_name", default="First Name"),
|
||||
_("admin_csv_amount", default="Amount"),
|
||||
_("admin_csv_currency", default="Currency"),
|
||||
_("admin_csv_provider", default="Provider"),
|
||||
_("admin_csv_status", default="Status"),
|
||||
_("admin_csv_description", default="Description"),
|
||||
_("admin_csv_months", default="Months"),
|
||||
_("admin_csv_created_at", default="Created At"),
|
||||
_("admin_csv_provider_payment_id", default="Provider Payment ID")
|
||||
_("admin_csv_payment_id"),
|
||||
_("admin_csv_user_id"),
|
||||
_("admin_csv_username"),
|
||||
_("admin_csv_first_name"),
|
||||
_("admin_csv_amount"),
|
||||
_("admin_csv_currency"),
|
||||
_("admin_csv_provider"),
|
||||
_("admin_csv_status"),
|
||||
_("admin_csv_description"),
|
||||
_("admin_csv_units"),
|
||||
_("admin_csv_created_at"),
|
||||
_("admin_csv_provider_payment_id")
|
||||
])
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
|
||||
# Write payment data
|
||||
for payment in all_payments:
|
||||
units_val = payment.subscription_duration_months or ""
|
||||
if traffic_mode and units_val not in ("", None):
|
||||
try:
|
||||
units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
||||
except Exception:
|
||||
units_val = payment.subscription_duration_months or ""
|
||||
writer.writerow([
|
||||
payment.payment_id,
|
||||
payment.user_id,
|
||||
@@ -210,7 +236,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
payment.provider or "",
|
||||
payment.status,
|
||||
payment.description or "",
|
||||
payment.subscription_duration_months or "",
|
||||
units_val,
|
||||
payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
|
||||
payment.provider_payment_id or ""
|
||||
])
|
||||
@@ -229,13 +255,12 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
|
||||
await callback.message.reply_document(
|
||||
document=file,
|
||||
caption=_("admin_payments_export_success",
|
||||
default="📊 Payments export completed!\nTotal records: {count}",
|
||||
caption=_("admin_payments_export_success",
|
||||
count=len(all_payments))
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
_("admin_export_sent", default="File sent!"),
|
||||
_("admin_export_sent"),
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
import csv
|
||||
import io
|
||||
@@ -34,8 +34,7 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
|
||||
# Step 1: Ask for quantity
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step1_quantity",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 1 из 4:</b> Количество\n\nВведите количество промокодов для создания (1-100):"
|
||||
"admin_bulk_promo_step1_quantity"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -57,7 +56,7 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
def generate_unique_promo_code(length: int = 8) -> str:
|
||||
"""Generate a unique random promo code"""
|
||||
characters = string.ascii_uppercase + string.digits
|
||||
return ''.join(random.choice(characters) for _ in range(length))
|
||||
return ''.join(secrets.choice(characters) for _ in range(length))
|
||||
|
||||
|
||||
# Step 1: Process quantity
|
||||
@@ -77,8 +76,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
||||
quantity = int(message.text.strip())
|
||||
if not (1 <= quantity <= 100):
|
||||
await message.answer(_(
|
||||
"admin_bulk_promo_invalid_quantity",
|
||||
default="❌ Количество промокодов должно быть от 1 до 100"
|
||||
"admin_bulk_promo_invalid_quantity"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -87,7 +85,6 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
||||
# Step 2: Ask for bonus days
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step2_bonus_days",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКоличество: <b>{quantity}</b>\n\nВведите количество бонусных дней для каждого промокода (1-365):",
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
@@ -100,8 +97,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo quantity: {e}")
|
||||
@@ -125,8 +121,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
||||
bonus_days = int(message.text.strip())
|
||||
if not (1 <= bonus_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_bonus_days",
|
||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
||||
"admin_promo_invalid_bonus_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -136,7 +131,6 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step3_max_activations",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций для каждого промокода (1-10000):",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=bonus_days
|
||||
)
|
||||
@@ -150,8 +144,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo bonus days: {e}")
|
||||
@@ -175,8 +168,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
max_activations = int(message.text.strip())
|
||||
if not (1 <= max_activations <= 10000):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_max_activations",
|
||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
||||
"admin_promo_invalid_max_activations"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -186,7 +178,6 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_step4_validity",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокодов:",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
@@ -196,19 +187,19 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
||||
text=_("admin_promo_unlimited_validity"),
|
||||
callback_data="bulk_promo_unlimited_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
||||
text=_("admin_promo_set_validity_days"),
|
||||
callback_data="bulk_promo_set_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
||||
text=_("admin_back_to_panel"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
@@ -222,8 +213,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo max activations: {e}")
|
||||
@@ -257,7 +247,6 @@ async def process_bulk_promo_set_validity(callback: types.CallbackQuery,
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_enter_validity_days",
|
||||
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокодов (1-365):",
|
||||
quantity=data.get("quantity"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=data.get("max_activations")
|
||||
@@ -296,8 +285,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
|
||||
validity_days = int(message.text.strip())
|
||||
if not (1 <= validity_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_validity_days",
|
||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
||||
"admin_promo_invalid_validity_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -306,8 +294,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing bulk promo validity days: {e}")
|
||||
@@ -333,7 +320,6 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
# Show progress message
|
||||
progress_text = _(
|
||||
"admin_bulk_promo_creating",
|
||||
default="🔄 Создание {quantity} промокодов...",
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
@@ -395,12 +381,10 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
# Success message
|
||||
success_lines = [
|
||||
_(
|
||||
"admin_bulk_promo_created_title",
|
||||
default="✅ <b>Массовое создание завершено!</b>\n"
|
||||
"admin_bulk_promo_created_title"
|
||||
),
|
||||
_(
|
||||
"admin_bulk_promo_created_stats",
|
||||
default="📊 Создано: <b>{created}</b> из <b>{total}</b>",
|
||||
created=len(created_codes),
|
||||
total=quantity
|
||||
)
|
||||
@@ -409,14 +393,11 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
if data.get("validity_days"):
|
||||
validity_text = f"{data['validity_days']} дней"
|
||||
else:
|
||||
validity_text = _("admin_promo_unlimited", default="Без ограничений")
|
||||
validity_text = _("admin_promo_unlimited")
|
||||
|
||||
success_lines.append(
|
||||
_(
|
||||
"admin_bulk_promo_settings",
|
||||
default="🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
||||
"⏰ Срок действия: <b>{validity}</b>",
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
validity=validity_text
|
||||
@@ -521,7 +502,7 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating bulk promo codes: {e}")
|
||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
||||
error_text = _("error_occurred_try_again")
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
await callback_or_message.message.answer(error_text)
|
||||
@@ -564,5 +545,5 @@ async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
|
||||
await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_bulk_promo_creation_cancelled"))
|
||||
await state.clear()
|
||||
|
||||
@@ -28,26 +28,82 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
# Step 1: Ask for promo code
|
||||
# Step 0: Ask for promo type (bonus_days or discount)
|
||||
prompt_text = _(
|
||||
"admin_promo_step1_code",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):"
|
||||
"admin_promo_step0_type"
|
||||
)
|
||||
|
||||
# Create keyboard for type selection
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_type_bonus_days"),
|
||||
callback_data="promo_type_select:bonus_days"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_type_discount"),
|
||||
callback_data="promo_type_select:discount"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for promo prompt: {e}. Sending new.")
|
||||
f"Could not edit message for promo type prompt: {e}. Sending new.")
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML")
|
||||
await callback.answer()
|
||||
await state.set_state(AdminStates.waiting_for_promo_code)
|
||||
await state.set_state(AdminStates.waiting_for_promo_type_selection)
|
||||
|
||||
|
||||
# Step 0: Process type selection
|
||||
@router.callback_query(F.data.startswith("promo_type_select:"), StateFilter(AdminStates.waiting_for_promo_type_selection))
|
||||
async def process_promo_type_selection(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing type selection.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
promo_type = callback.data.split(":")[-1] # "bonus_days" or "discount"
|
||||
await state.update_data(promo_type=promo_type)
|
||||
|
||||
# Step 1: Ask for promo code
|
||||
prompt_text = _(
|
||||
"admin_promo_step1_code"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
await state.set_state(AdminStates.waiting_for_promo_code)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo type selection: {e}")
|
||||
await callback.message.answer(_("error_occurred_try_again"))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# Step 1: Process promo code
|
||||
@@ -68,8 +124,7 @@ async def process_promo_code_handler(message: types.Message,
|
||||
code_str = message.text.strip().upper()
|
||||
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_code_format",
|
||||
default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)"
|
||||
"admin_promo_invalid_code_format"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -77,26 +132,36 @@ async def process_promo_code_handler(message: types.Message,
|
||||
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
|
||||
if existing_promo:
|
||||
await message.answer(_(
|
||||
"admin_promo_code_already_exists",
|
||||
default="❌ Промокод с таким кодом уже существует"
|
||||
"admin_promo_code_already_exists"
|
||||
))
|
||||
return
|
||||
|
||||
await state.update_data(promo_code=code_str)
|
||||
|
||||
# Step 2: Ask for bonus days
|
||||
prompt_text = _(
|
||||
"admin_promo_step2_bonus_days",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
|
||||
code=code_str
|
||||
)
|
||||
|
||||
|
||||
# Get promo type from state
|
||||
data = await state.get_data()
|
||||
promo_type = data.get("promo_type", "bonus_days")
|
||||
|
||||
# Step 2: Ask for bonus days OR discount percentage based on type
|
||||
if promo_type == "discount":
|
||||
prompt_text = _(
|
||||
"admin_promo_step2_discount_percentage",
|
||||
code=code_str
|
||||
)
|
||||
next_state = AdminStates.waiting_for_promo_discount_percentage
|
||||
else:
|
||||
prompt_text = _(
|
||||
"admin_promo_step2_bonus_days",
|
||||
code=code_str
|
||||
)
|
||||
next_state = AdminStates.waiting_for_promo_bonus_days
|
||||
|
||||
await message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_promo_bonus_days)
|
||||
await state.set_state(next_state)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo code: {e}")
|
||||
@@ -120,39 +185,83 @@ async def process_promo_bonus_days_handler(message: types.Message,
|
||||
bonus_days = int(message.text.strip())
|
||||
if not (1 <= bonus_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_bonus_days",
|
||||
default="❌ Количество бонусных дней должно быть от 1 до 365"
|
||||
"admin_promo_invalid_bonus_days"
|
||||
))
|
||||
return
|
||||
|
||||
|
||||
await state.update_data(bonus_days=bonus_days)
|
||||
|
||||
|
||||
# Step 3: Ask for max activations
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step3_max_activations",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=bonus_days
|
||||
)
|
||||
|
||||
|
||||
await message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_promo_max_activations)
|
||||
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo bonus days: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
# Step 2: Process discount percentage
|
||||
@router.message(AdminStates.waiting_for_promo_discount_percentage, F.text)
|
||||
async def process_promo_discount_percentage_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
discount_percentage = int(message.text.strip())
|
||||
if not (1 <= discount_percentage <= 100):
|
||||
await message.answer(
|
||||
"❌ Discount percentage must be between 1 and 100."
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(discount_percentage=discount_percentage)
|
||||
|
||||
# Step 3: Ask for max activations
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step3_max_activations_discount",
|
||||
code=data.get("promo_code"),
|
||||
discount_percentage=discount_percentage
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_promo_max_activations)
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing discount percentage: {e}")
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
|
||||
|
||||
# Step 3: Process max activations
|
||||
@router.message(AdminStates.waiting_for_promo_max_activations, F.text)
|
||||
async def process_promo_max_activations_handler(message: types.Message,
|
||||
@@ -170,40 +279,48 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
max_activations = int(message.text.strip())
|
||||
if not (1 <= max_activations <= 10000):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_max_activations",
|
||||
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
|
||||
"admin_promo_invalid_max_activations"
|
||||
))
|
||||
return
|
||||
|
||||
await state.update_data(max_activations=max_activations)
|
||||
|
||||
|
||||
# Step 4: Ask for validity
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_step4_validity",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
)
|
||||
promo_type = data.get("promo_type", "bonus_days")
|
||||
|
||||
if promo_type == "discount":
|
||||
prompt_text = _(
|
||||
"admin_promo_step4_validity_discount",
|
||||
code=data.get("promo_code"),
|
||||
discount_percentage=data.get("discount_percentage"),
|
||||
max_activations=max_activations
|
||||
)
|
||||
else:
|
||||
prompt_text = _(
|
||||
"admin_promo_step4_validity",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=max_activations
|
||||
)
|
||||
|
||||
# Create keyboard for validity options
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
|
||||
text=_("admin_promo_unlimited_validity"),
|
||||
callback_data="promo_unlimited_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
|
||||
text=_("admin_promo_set_validity_days"),
|
||||
callback_data="promo_set_validity"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
||||
text=_("admin_back_to_panel"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
@@ -217,8 +334,7 @@ async def process_promo_max_activations_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo max activations: {e}")
|
||||
@@ -250,13 +366,15 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
data = await state.get_data()
|
||||
prompt_text = _(
|
||||
"admin_promo_enter_validity_days",
|
||||
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокода (1-365):",
|
||||
code=data.get("promo_code"),
|
||||
bonus_days=data.get("bonus_days"),
|
||||
max_activations=data.get("max_activations")
|
||||
)
|
||||
promo_type = data.get("promo_type", "bonus_days")
|
||||
|
||||
# Display the correct text based on promo type
|
||||
if promo_type == "discount":
|
||||
value_info = f"{data.get('discount_percentage')}%"
|
||||
else:
|
||||
value_info = f"{data.get('bonus_days')} дней"
|
||||
|
||||
prompt_text = f"⏰ Введите количество дней действия промокода (1-365):\n\nКод: <b>{data.get('promo_code')}</b>\n{'Скидка' if promo_type == 'discount' else 'Бонус'}: <b>{value_info}</b>\nМакс. активаций: <b>{data.get('max_activations')}</b>"
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
@@ -291,8 +409,7 @@ async def process_promo_validity_days_handler(message: types.Message,
|
||||
validity_days = int(message.text.strip())
|
||||
if not (1 <= validity_days <= 365):
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_validity_days",
|
||||
default="❌ Срок действия должен быть от 1 до 365 дней"
|
||||
"admin_promo_invalid_validity_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -301,8 +418,7 @@ async def process_promo_validity_days_handler(message: types.Message,
|
||||
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_promo_invalid_number",
|
||||
default="❌ Введите корректное число"
|
||||
"admin_promo_invalid_number"
|
||||
))
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing promo validity days: {e}")
|
||||
@@ -323,45 +439,60 @@ async def create_promo_code_final(callback_or_message,
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
|
||||
promo_type = data.get("promo_type", "bonus_days")
|
||||
|
||||
# Prepare promo code data
|
||||
promo_data = {
|
||||
"code": data["promo_code"],
|
||||
"bonus_days": data["bonus_days"],
|
||||
"promo_type": promo_type,
|
||||
"max_activations": data["max_activations"],
|
||||
"current_activations": 0,
|
||||
"is_active": True,
|
||||
"created_by_admin_id": callback_or_message.from_user.id,
|
||||
"created_at": datetime.now(timezone.utc)
|
||||
}
|
||||
|
||||
|
||||
# Set type-specific fields
|
||||
if promo_type == "discount":
|
||||
promo_data["discount_percentage"] = data["discount_percentage"]
|
||||
promo_data["bonus_days"] = None
|
||||
else:
|
||||
promo_data["bonus_days"] = data["bonus_days"]
|
||||
promo_data["discount_percentage"] = None
|
||||
|
||||
# Set validity
|
||||
if data.get("validity_days"):
|
||||
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
|
||||
else:
|
||||
promo_data["valid_until"] = None
|
||||
|
||||
|
||||
# Create promo code
|
||||
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# Log successful creation
|
||||
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
|
||||
|
||||
logging.info(f"Promo code '{data['promo_code']}' ({promo_type}) created with ID {created_promo.promo_code_id}")
|
||||
|
||||
# Success message
|
||||
valid_until_str = _("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
success_text = _(
|
||||
"admin_promo_created_success",
|
||||
default="✅ <b>Промокод успешно создан!</b>\n\n"
|
||||
"🎟 Код: <code>{code}</code>\n"
|
||||
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
||||
"⏰ Срок действия: <b>{valid_until_str}</b>",
|
||||
code=data["promo_code"],
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
valid_until_str=valid_until_str
|
||||
)
|
||||
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
|
||||
# Format success message based on type
|
||||
if promo_type == "discount":
|
||||
success_text = _(
|
||||
"admin_promo_created_success_discount",
|
||||
code=data["promo_code"],
|
||||
discount_percentage=data['discount_percentage'],
|
||||
max_activations=data["max_activations"],
|
||||
valid_until_str=valid_until_str
|
||||
)
|
||||
else:
|
||||
success_text = _(
|
||||
"admin_promo_created_success",
|
||||
code=data["promo_code"],
|
||||
bonus_days=data['bonus_days'],
|
||||
max_activations=data["max_activations"],
|
||||
valid_until_str=valid_until_str
|
||||
)
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
try:
|
||||
@@ -388,7 +519,7 @@ async def create_promo_code_final(callback_or_message,
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating promo code: {e}")
|
||||
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
|
||||
error_text = _("error_occurred_try_again")
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
await callback_or_message.message.answer(error_text)
|
||||
@@ -403,8 +534,10 @@ async def create_promo_code_final(callback_or_message,
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main",
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_promo_type_selection,
|
||||
AdminStates.waiting_for_promo_code,
|
||||
AdminStates.waiting_for_promo_bonus_days,
|
||||
AdminStates.waiting_for_promo_discount_percentage,
|
||||
AdminStates.waiting_for_promo_max_activations,
|
||||
AdminStates.waiting_for_promo_validity_days,
|
||||
),
|
||||
@@ -432,5 +565,5 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
|
||||
await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_promo_creation_cancelled"))
|
||||
await state.clear()
|
||||
|
||||
@@ -47,9 +47,19 @@ async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSessio
|
||||
|
||||
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
|
||||
|
||||
# Determine promo type and value to display
|
||||
promo_type = getattr(promo, "promo_type", "bonus_days")
|
||||
if promo_type == "discount":
|
||||
type_name = _("admin_promo_type_discount")
|
||||
value_line = _("admin_promo_card_discount_percentage", percentage=promo.discount_percentage)
|
||||
else:
|
||||
type_name = _("admin_promo_type_bonus_days")
|
||||
value_line = _("admin_promo_card_bonus_days", days=promo.bonus_days)
|
||||
|
||||
text = "\n".join([
|
||||
_("admin_promo_card_title", code=promo.code),
|
||||
_("admin_promo_card_bonus_days", days=promo.bonus_days),
|
||||
_("admin_promo_card_type", type=type_name),
|
||||
value_line,
|
||||
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
|
||||
_("admin_promo_card_validity", validity=validity),
|
||||
_("admin_promo_card_status", status=status),
|
||||
@@ -76,12 +86,22 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dic
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
|
||||
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join(
|
||||
[_("admin_active_promos_list_header"), ""] + [
|
||||
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}"
|
||||
for p in promo_models
|
||||
]
|
||||
)
|
||||
if not promo_models:
|
||||
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
|
||||
else:
|
||||
promo_lines = [_("admin_active_promos_list_header"), ""]
|
||||
for p in promo_models:
|
||||
status_emoji = get_promo_status_emoji_and_text(p, i18n, current_lang)[0]
|
||||
promo_type = getattr(p, "promo_type", "bonus_days")
|
||||
if promo_type == "discount":
|
||||
value_display = f"💰 {p.discount_percentage}%"
|
||||
else:
|
||||
value_display = f"🎁 {p.bonus_days}д"
|
||||
validity_display = p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')
|
||||
promo_lines.append(
|
||||
f"{status_emoji} <code>{p.code}</code> | {value_display} | 📊 {p.current_activations}/{p.max_activations} | ⏰ {validity_display}"
|
||||
)
|
||||
text = "\n".join(promo_lines)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
@@ -300,7 +320,9 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
|
||||
# CSV headers (forced to English)
|
||||
writer.writerow([
|
||||
i18n.gettext(export_lang, "admin_promo_csv_code"),
|
||||
"Type",
|
||||
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
|
||||
"Discount %",
|
||||
i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
|
||||
i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
|
||||
i18n.gettext(export_lang, "admin_promo_csv_status"),
|
||||
@@ -309,15 +331,22 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
|
||||
i18n.gettext(export_lang, "admin_promo_csv_created_at"),
|
||||
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
|
||||
])
|
||||
|
||||
|
||||
for promo in all_promos:
|
||||
# Определяем статус
|
||||
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang)
|
||||
|
||||
|
||||
# Determine promo type and values
|
||||
promo_type = getattr(promo, "promo_type", "bonus_days")
|
||||
bonus_days_val = promo.bonus_days if promo_type == "bonus_days" else "N/A"
|
||||
discount_val = promo.discount_percentage if promo_type == "discount" else "N/A"
|
||||
|
||||
# Формируем данные для CSV
|
||||
row = [
|
||||
promo.code,
|
||||
promo.bonus_days,
|
||||
promo_type,
|
||||
bonus_days_val,
|
||||
discount_val,
|
||||
promo.max_activations,
|
||||
promo.current_activations,
|
||||
status_text,
|
||||
@@ -374,13 +403,26 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
|
||||
|
||||
# Get promo to check type
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
return
|
||||
|
||||
promo_type = getattr(promo, "promo_type", "bonus_days")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
|
||||
# Show appropriate edit option based on type
|
||||
if promo_type == "discount":
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_discount_percentage"), callback_data=f"promo_edit_field:discount_percentage:{promo_id}"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
|
||||
|
||||
|
||||
await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
|
||||
await callback.answer()
|
||||
|
||||
@@ -394,14 +436,19 @@ async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMCont
|
||||
|
||||
action, field, promo_id_str = callback.data.split(":")
|
||||
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
|
||||
|
||||
|
||||
prompts = {
|
||||
"bonus_days": "admin_promo_prompt_bonus_days",
|
||||
"discount_percentage": "admin_promo_prompt_discount_percentage",
|
||||
"max_activations": "admin_promo_prompt_max_activations",
|
||||
"valid_until": "admin_promo_prompt_validity_days"
|
||||
}
|
||||
|
||||
prompt_key = prompts.get(field, "error_occurred_try_again")
|
||||
prompt_text = _(prompt_key)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_promo_edit_details)
|
||||
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again")))
|
||||
await callback.message.edit_text(prompt_text)
|
||||
await callback.answer()
|
||||
|
||||
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
|
||||
@@ -418,9 +465,15 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
|
||||
try:
|
||||
value = message.text
|
||||
update_data = {}
|
||||
|
||||
|
||||
if field == "bonus_days":
|
||||
update_data["bonus_days"] = int(value)
|
||||
elif field == "discount_percentage":
|
||||
discount_pct = int(value)
|
||||
if not (1 <= discount_pct <= 100):
|
||||
await message.answer("❌ Discount percentage must be between 1 and 100.")
|
||||
return
|
||||
update_data["discount_percentage"] = discount_pct
|
||||
elif field == "max_activations":
|
||||
update_data["max_activations"] = int(value)
|
||||
elif field == "valid_until":
|
||||
@@ -433,7 +486,7 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
|
||||
if await promo_code_dal.update_promo_code(session, promo_id, update_data):
|
||||
await session.commit()
|
||||
await message.answer(_("admin_promo_edit_success"))
|
||||
|
||||
|
||||
# Reset state and show updated details
|
||||
await state.clear()
|
||||
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
|
||||
|
||||
@@ -34,30 +34,30 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
|
||||
f"\n<b>👥 {_('admin_enhanced_users_stats_header')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📊 {_('admin_user_stats_total_label', default='Всего')}: <b>{user_stats['total_users']}</b>"
|
||||
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
||||
)
|
||||
# Removed: Active today moved to panel stats
|
||||
stats_text_parts.append(
|
||||
f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: <b>{user_stats['trial_users']}</b>"
|
||||
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: <b>{user_stats['inactive_users']}</b>"
|
||||
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: <b>{user_stats['banned_users']}</b>"
|
||||
f"🚫 {_('admin_user_stats_banned_label')}: <b>{user_stats['banned_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: <b>{user_stats['referral_users']}</b>"
|
||||
f"🎁 {_('admin_user_stats_referral_label')}: <b>{user_stats['referral_users']}</b>"
|
||||
)
|
||||
|
||||
# Panel Statistics - moved above financial
|
||||
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header', default='Статистика панели')}</b>")
|
||||
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header')}</b>")
|
||||
|
||||
try:
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
@@ -80,12 +80,12 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
total_users = users.get('totalUsers', 0)
|
||||
online_now = online_stats.get('onlineNow', 0)
|
||||
|
||||
stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: <b>{online_now}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: <b>{active_users}</b>")
|
||||
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: <b>{disabled_users}</b>")
|
||||
stats_text_parts.append(f"⏰ {_('admin_panel_expired_label', default='Истекшие')}: <b>{expired_users}</b>")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: <b>{limited_users}</b>")
|
||||
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: <b>{total_users}</b>")
|
||||
stats_text_parts.append(f"🟢 {_('admin_panel_online_label')}: <b>{online_now}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_active_label')}: <b>{active_users}</b>")
|
||||
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label')}: <b>{disabled_users}</b>")
|
||||
stats_text_parts.append(f"⏰ {_('admin_panel_expired_label')}: <b>{expired_users}</b>")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label')}: <b>{limited_users}</b>")
|
||||
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label')}: <b>{total_users}</b>")
|
||||
|
||||
# System resources
|
||||
memory = system_stats.get('memory', {})
|
||||
@@ -93,9 +93,9 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
memory_total = memory.get('total', 1)
|
||||
memory_used = memory.get('used', 0)
|
||||
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
|
||||
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: <b>{memory_usage:.1f}%</b>")
|
||||
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label')}: <b>{memory_usage:.1f}%</b>")
|
||||
else:
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error')}")
|
||||
|
||||
# Bandwidth stats
|
||||
if bandwidth_stats:
|
||||
@@ -107,13 +107,13 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
|
||||
if week_traffic:
|
||||
week_total = week_traffic.get('current', '0 B')
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: <b>{week_total}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label')}: <b>{week_total}</b>")
|
||||
|
||||
if month_traffic:
|
||||
month_total = month_traffic.get('current', '0 B')
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: <b>{month_total}</b>")
|
||||
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label')}: <b>{month_total}</b>")
|
||||
else:
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error')}")
|
||||
|
||||
# Nodes stats
|
||||
if nodes_stats and 'lastSevenDays' in nodes_stats:
|
||||
@@ -124,35 +124,35 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
unique_nodes.add(node_data.get('nodeName', ''))
|
||||
total_nodes_count = len(unique_nodes)
|
||||
# Assume all nodes are active since we don't have status info
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
|
||||
else:
|
||||
# Use nodes total from system stats as fallback
|
||||
nodes_info = system_stats.get('nodes', {}) if system_stats else {}
|
||||
total_online = nodes_info.get('totalOnline', 0)
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_online}</b>")
|
||||
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_online}</b>")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
|
||||
stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}")
|
||||
stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error')}")
|
||||
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details')}: {str(e)}")
|
||||
|
||||
# Financial statistics
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
|
||||
f"\n<b>💰 {_('admin_financial_stats_header')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_today_label', default='За сегодня')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})"
|
||||
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_week_label', default='За неделю')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 {_('admin_financial_month_label', default='За месяц')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🏆 {_('admin_financial_all_time_label', default='За все время')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
||||
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
|
||||
last_payments_models: List[
|
||||
@@ -162,8 +162,21 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
stats_text_parts.append(
|
||||
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
||||
for payment in last_payments_models:
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌")
|
||||
pending_statuses = [
|
||||
"pending",
|
||||
"pending_yookassa",
|
||||
"pending_freekassa",
|
||||
"pending_platega",
|
||||
"pending_severpay",
|
||||
"pending_cryptopay",
|
||||
]
|
||||
status_emoji = (
|
||||
"✅"
|
||||
if payment.status == "succeeded"
|
||||
else "⏳"
|
||||
if payment.status in pending_statuses
|
||||
else "❌"
|
||||
)
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
if payment.user and payment.user.username:
|
||||
|
||||
@@ -3,6 +3,7 @@ from aiogram import Router, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from typing import Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import update, or_
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -10,14 +11,19 @@ from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
||||
from db.models import Subscription
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_sync_router")
|
||||
|
||||
|
||||
async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
settings: Settings, i18n_instance: JsonI18n) -> dict:
|
||||
async def perform_sync(
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
i18n_instance: JsonI18n,
|
||||
) -> dict:
|
||||
"""
|
||||
Perform panel synchronization and return results
|
||||
Returns dict with status, details, and sync statistics
|
||||
@@ -27,7 +33,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
users_updated = 0
|
||||
subscriptions_synced_count = 0
|
||||
sync_errors = []
|
||||
|
||||
|
||||
# Additional counters for detailed logging
|
||||
users_without_telegram_id = 0
|
||||
users_not_found_in_db = 0
|
||||
@@ -52,7 +58,12 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
session, "success", status_msg, 0, 0
|
||||
)
|
||||
await session.commit()
|
||||
return {"status": "success", "details": status_msg, "users_synced": 0, "subs_synced": 0}
|
||||
return {
|
||||
"status": "success",
|
||||
"details": status_msg,
|
||||
"users_synced": 0,
|
||||
"subs_synced": 0,
|
||||
}
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
@@ -61,12 +72,16 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
try:
|
||||
panel_records_checked += 1
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
|
||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get(
|
||||
"shortUuid"
|
||||
)
|
||||
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
||||
|
||||
if not panel_uuid:
|
||||
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
||||
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
|
||||
logging.warning(
|
||||
f"Skipping panel user without UUID: {panel_user_dict}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Track users without telegram ID
|
||||
@@ -75,22 +90,35 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
|
||||
# Try to find existing user in local DB
|
||||
existing_user = None
|
||||
|
||||
|
||||
# First, try to find by telegram ID if available
|
||||
if telegram_id_from_panel:
|
||||
existing_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||
existing_user = await user_dal.get_user_by_id(
|
||||
session, telegram_id_from_panel
|
||||
)
|
||||
if existing_user:
|
||||
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
|
||||
|
||||
logging.debug(
|
||||
f"Found user by telegramId {telegram_id_from_panel}"
|
||||
)
|
||||
|
||||
# If not found by telegram ID, try to find by panel UUID
|
||||
if not existing_user:
|
||||
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||
existing_user = await user_dal.get_user_by_panel_uuid(
|
||||
session, panel_uuid
|
||||
)
|
||||
if existing_user:
|
||||
logging.info(f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}")
|
||||
logging.info(
|
||||
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}"
|
||||
)
|
||||
# Update telegram ID if it was missing in panel data but we have local user
|
||||
if telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel:
|
||||
logging.warning(f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}")
|
||||
|
||||
if (
|
||||
telegram_id_from_panel
|
||||
and existing_user.user_id != telegram_id_from_panel
|
||||
):
|
||||
logging.warning(
|
||||
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}"
|
||||
)
|
||||
|
||||
if not existing_user:
|
||||
users_not_found_in_db += 1
|
||||
if telegram_id_from_panel:
|
||||
@@ -100,26 +128,36 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
"user_id": telegram_id_from_panel,
|
||||
"username": None, # Username will be updated when user interacts with bot
|
||||
"first_name": None, # Panel doesn't provide this info
|
||||
"last_name": None, # Panel doesn't provide this info
|
||||
"last_name": None, # Panel doesn't provide this info
|
||||
"language_code": "ru", # Default language
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"is_banned": False,
|
||||
"referred_by_id": None
|
||||
"referred_by_id": None,
|
||||
}
|
||||
|
||||
new_user, was_created = await user_dal.create_user(session, user_data)
|
||||
|
||||
new_user, was_created = await user_dal.create_user(
|
||||
session, user_data
|
||||
)
|
||||
if was_created:
|
||||
users_created += 1
|
||||
logging.info(f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}")
|
||||
|
||||
logging.info(
|
||||
f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}"
|
||||
)
|
||||
|
||||
existing_user = new_user
|
||||
|
||||
|
||||
except Exception as e_create:
|
||||
sync_errors.append(f"Error creating user {telegram_id_from_panel}: {str(e_create)}")
|
||||
logging.error(f"Error creating user {telegram_id_from_panel}: {e_create}")
|
||||
sync_errors.append(
|
||||
f"Error creating user {telegram_id_from_panel}: {str(e_create)}"
|
||||
)
|
||||
logging.error(
|
||||
f"Error creating user {telegram_id_from_panel}: {e_create}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping")
|
||||
logging.debug(
|
||||
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
# User found in local DB
|
||||
@@ -134,20 +172,29 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
existing_user.panel_user_uuid = panel_uuid
|
||||
user_was_updated = True
|
||||
users_uuid_updated += 1
|
||||
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
|
||||
logging.info(
|
||||
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
|
||||
)
|
||||
|
||||
# Ensure panel description contains Telegram fields
|
||||
try:
|
||||
if panel_uuid and existing_user:
|
||||
description_text = "\n".join([
|
||||
existing_user.username or "",
|
||||
existing_user.first_name or "",
|
||||
existing_user.last_name or "",
|
||||
])
|
||||
description_text = "\n".join(
|
||||
[
|
||||
existing_user.username or "",
|
||||
existing_user.first_name or "",
|
||||
existing_user.last_name or "",
|
||||
]
|
||||
)
|
||||
# Update description only when it differs from the current one on panel
|
||||
current_panel_description = (panel_user_dict.get("description") or "").strip()
|
||||
current_panel_description = (
|
||||
panel_user_dict.get("description") or ""
|
||||
).strip()
|
||||
desired_description = description_text.strip()
|
||||
if desired_description and desired_description != current_panel_description:
|
||||
if (
|
||||
desired_description
|
||||
and desired_description != current_panel_description
|
||||
):
|
||||
await panel_service.update_user_details_on_panel(
|
||||
panel_uuid, {"description": description_text}
|
||||
)
|
||||
@@ -159,13 +206,13 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
# Sync subscription data
|
||||
panel_expire_at_iso = panel_user_dict.get("expireAt")
|
||||
panel_status = panel_user_dict.get("status", "UNKNOWN")
|
||||
|
||||
|
||||
if panel_expire_at_iso:
|
||||
try:
|
||||
panel_expire_at = datetime.fromisoformat(
|
||||
panel_expire_at_iso.replace("Z", "+00:00")
|
||||
)
|
||||
|
||||
|
||||
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
|
||||
subscription_uuid_from_panel = (
|
||||
panel_user_dict.get("subscriptionUuid")
|
||||
@@ -173,6 +220,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
)
|
||||
|
||||
if subscription_uuid_from_panel:
|
||||
# Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные
|
||||
if panel_status == "ACTIVE":
|
||||
await session.execute(
|
||||
update(Subscription)
|
||||
.where(
|
||||
Subscription.panel_user_uuid == panel_uuid,
|
||||
Subscription.is_active.is_(True),
|
||||
or_(
|
||||
Subscription.panel_subscription_uuid
|
||||
!= subscription_uuid_from_panel,
|
||||
Subscription.panel_subscription_uuid.is_(
|
||||
None
|
||||
),
|
||||
),
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
status_from_panel="INACTIVE",
|
||||
)
|
||||
)
|
||||
|
||||
# Try to find subscription by its panel_subscription_uuid first (idempotent)
|
||||
existing_sub_by_uuid = (
|
||||
await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
||||
@@ -197,7 +265,8 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} "
|
||||
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
)
|
||||
else:
|
||||
# Create a new subscription only when we have a concrete subscription UUID
|
||||
@@ -212,6 +281,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
created_sub = await subscription_dal.upsert_subscription(
|
||||
session, sub_payload
|
||||
@@ -220,12 +290,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_created += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Created subscription {created_sub.subscription_id} for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
||||
f"Created subscription {created_sub.subscription_id} "
|
||||
f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
||||
)
|
||||
else:
|
||||
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, actual_user_id, panel_uuid
|
||||
active_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, actual_user_id, panel_uuid
|
||||
)
|
||||
)
|
||||
if active_sub:
|
||||
await subscription_dal.update_subscription(
|
||||
@@ -241,23 +314,30 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.info(
|
||||
f"Updated active subscription {active_sub.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
f"Updated active subscription {active_sub.subscription_id} "
|
||||
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||
)
|
||||
else:
|
||||
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
|
||||
logging.debug(
|
||||
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
|
||||
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
|
||||
sync_errors.append(
|
||||
f"Error syncing subscription for user {actual_user_id}: {str(e)}"
|
||||
)
|
||||
logging.error(
|
||||
f"Error syncing subscription for user {actual_user_id}: {e}"
|
||||
)
|
||||
|
||||
if user_was_updated:
|
||||
users_updated += 1
|
||||
|
||||
|
||||
except Exception as e_user:
|
||||
sync_errors.append(f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}")
|
||||
sync_errors.append(
|
||||
f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}"
|
||||
)
|
||||
logging.error(f"Error syncing user: {e_user}")
|
||||
|
||||
# Update sync status
|
||||
@@ -266,14 +346,26 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
default_lang = settings.DEFAULT_LANGUAGE
|
||||
additional_stats = ""
|
||||
if users_without_telegram_id > 0:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_no_telegram_id", count=users_without_telegram_id)
|
||||
additional_stats += i18n_instance.gettext(
|
||||
default_lang,
|
||||
"admin_sync_no_telegram_id",
|
||||
count=users_without_telegram_id,
|
||||
)
|
||||
if users_not_found_in_db > 0:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_not_found_in_db", count=users_not_found_in_db)
|
||||
additional_stats += i18n_instance.gettext(
|
||||
default_lang,
|
||||
"admin_sync_not_found_in_db",
|
||||
count=users_not_found_in_db,
|
||||
)
|
||||
if sync_errors:
|
||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_errors", count=len(sync_errors))
|
||||
additional_stats += i18n_instance.gettext(
|
||||
default_lang, "admin_sync_errors", count=len(sync_errors)
|
||||
)
|
||||
|
||||
# Build full details using localization
|
||||
details = i18n_instance.gettext(default_lang, "admin_sync_details",
|
||||
details = i18n_instance.gettext(
|
||||
default_lang,
|
||||
"admin_sync_details",
|
||||
panel_records_checked=panel_records_checked,
|
||||
users_found_in_db=users_found_in_db,
|
||||
users_created=users_created,
|
||||
@@ -281,11 +373,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
subscriptions_synced_count=subscriptions_synced_count,
|
||||
subscriptions_created=subscriptions_created,
|
||||
subscriptions_updated=subscriptions_updated,
|
||||
additional_stats=additional_stats
|
||||
additional_stats=additional_stats,
|
||||
)
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, status, details, panel_records_checked, subscriptions_synced_count
|
||||
session,
|
||||
status,
|
||||
details,
|
||||
panel_records_checked,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -310,19 +406,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
"users_synced": users_found_in_db,
|
||||
"users_created": users_created,
|
||||
"subs_synced": subscriptions_synced_count,
|
||||
"errors": sync_errors
|
||||
"errors": sync_errors,
|
||||
}
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
|
||||
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
|
||||
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
|
||||
session,
|
||||
"failed",
|
||||
error_detail,
|
||||
panel_records_checked,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
|
||||
return {"status": "failed", "details": error_detail, "errors": [str(e_sync_global)]}
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
"details": error_detail,
|
||||
"errors": [str(e_sync_global)],
|
||||
}
|
||||
|
||||
|
||||
@router.message(Command("sync"))
|
||||
@@ -365,34 +469,40 @@ async def sync_command_handler(
|
||||
# Use the extracted perform_sync function
|
||||
try:
|
||||
sync_result = await perform_sync(panel_service, session, settings, i18n)
|
||||
|
||||
|
||||
status = sync_result.get("status")
|
||||
details = sync_result.get("details", "No details available")
|
||||
errors = sync_result.get("errors", [])
|
||||
|
||||
|
||||
# Simple confirmation message to admin
|
||||
if status == "failed":
|
||||
await bot.send_message(target_chat_id, _("sync_failed_simple"))
|
||||
elif status == "completed_with_errors":
|
||||
await bot.send_message(target_chat_id, _("sync_errors_simple", errors_count=len(errors)))
|
||||
await bot.send_message(
|
||||
target_chat_id,
|
||||
_("sync_errors_simple", errors_count=len(errors)),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(target_chat_id, _("sync_success_simple"))
|
||||
|
||||
|
||||
# Send notification to log channel with proper thread handling
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_panel_sync(
|
||||
status, details,
|
||||
status,
|
||||
details,
|
||||
sync_result.get("users_processed", 0),
|
||||
sync_result.get("subs_synced", 0)
|
||||
sync_result.get("subs_synced", 0),
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync notification: {e_notification}")
|
||||
|
||||
|
||||
except Exception as e_sync_global:
|
||||
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
|
||||
logging.error(
|
||||
f"Global error during /sync command: {e_sync_global}", exc_info=True
|
||||
)
|
||||
await bot.send_message(target_chat_id, _("sync_critical_error"))
|
||||
|
||||
|
||||
# Send notification to log channel about failure
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
@@ -400,7 +510,9 @@ async def sync_command_handler(
|
||||
"failed", str(e_sync_global), 0, 0
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync failure notification: {e_notification}")
|
||||
logging.error(
|
||||
f"Failed to send sync failure notification: {e_notification}"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("syncstatus"))
|
||||
@@ -419,7 +531,9 @@ async def sync_status_command_handler(
|
||||
if status_record_model:
|
||||
last_time_val = status_record_model.last_sync_time
|
||||
last_time_str = (
|
||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
|
||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
if last_time_val
|
||||
else "N/A"
|
||||
)
|
||||
|
||||
details_val = status_record_model.details
|
||||
@@ -436,4 +550,4 @@ async def sync_status_command_handler(
|
||||
else:
|
||||
response_text = _("admin_sync_status_never_run")
|
||||
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
|
||||
@@ -4,7 +4,7 @@ from aiogram import Router, F, types, Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.utils.markdown import hcode, hbold
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, Callable, Awaitable
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -24,25 +24,70 @@ from bot.utils.text_sanitizer import (
|
||||
sanitize_username,
|
||||
username_for_display,
|
||||
)
|
||||
from bot.utils.telegram_markup import (
|
||||
is_profile_link_error,
|
||||
remove_profile_link_buttons,
|
||||
)
|
||||
|
||||
router = Router(name="admin_user_management_router")
|
||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
|
||||
|
||||
async def user_management_menu_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
"""Display user management menu"""
|
||||
async def users_list_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession, page: int = 0):
|
||||
"""Display paginated list of all users"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error preparing user management.", show_alert=True)
|
||||
await callback.answer("Error preparing user list.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
# Get paginated users
|
||||
from bot.keyboards.inline.admin_keyboards import get_users_list_keyboard
|
||||
from db.dal import user_dal
|
||||
|
||||
users = await user_dal.get_all_users_paginated(session, page=page, page_size=15)
|
||||
total_users = await user_dal.count_all_users(session)
|
||||
total_pages = max(1, (total_users + 14) // 15)
|
||||
|
||||
# Format message
|
||||
header_text = _(
|
||||
"admin_users_list_header",
|
||||
current=page + 1,
|
||||
total=total_pages,
|
||||
total_users=total_users
|
||||
)
|
||||
|
||||
keyboard = get_users_list_keyboard(users, page, total_users, i18n, current_lang, page_size=15)
|
||||
|
||||
await callback.message.edit_text(
|
||||
header_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error displaying user list: {e}")
|
||||
await callback.answer("Ошибка отображения списка пользователей", show_alert=True)
|
||||
|
||||
|
||||
async def user_search_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
"""Display search prompt for user management"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error preparing search.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
prompt_text = _(
|
||||
"admin_user_management_prompt",
|
||||
default="👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:"
|
||||
"admin_user_management_prompt"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -61,44 +106,62 @@ async def user_management_menu_handler(callback: types.CallbackQuery,
|
||||
await state.set_state(AdminStates.waiting_for_user_search)
|
||||
|
||||
|
||||
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyboardBuilder:
|
||||
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str,
|
||||
referrer_id: Optional[int] = None) -> InlineKeyboardBuilder:
|
||||
"""Generate keyboard for user management actions"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Row 1: Trial and Subscription actions
|
||||
builder.button(
|
||||
text=_(key="admin_user_reset_trial_button", default="🔄 Сбросить триал"),
|
||||
text=_(key="admin_user_reset_trial_button"),
|
||||
callback_data=f"user_action:reset_trial:{user_id}"
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="admin_user_add_subscription_button", default="➕ Добавить дни"),
|
||||
text=_(key="admin_user_add_subscription_button"),
|
||||
callback_data=f"user_action:add_subscription:{user_id}"
|
||||
)
|
||||
|
||||
# Row 2: Block/Unblock and Message
|
||||
builder.button(
|
||||
text=_(key="admin_user_toggle_ban_button", default="🚫 Заблокировать/Разблокировать"),
|
||||
text=_(key="admin_user_toggle_ban_button"),
|
||||
callback_data=f"user_action:toggle_ban:{user_id}"
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="admin_user_send_message_button", default="✉️ Отправить сообщение"),
|
||||
text=_(key="admin_user_send_message_button"),
|
||||
callback_data=f"user_action:send_message:{user_id}"
|
||||
)
|
||||
|
||||
# Row 3: View actions
|
||||
builder.button(
|
||||
text=_(key="admin_user_view_logs_button", default="📜 Действия пользователя"),
|
||||
text=_(key="admin_user_view_logs_button"),
|
||||
callback_data=f"user_action:view_logs:{user_id}"
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="admin_user_refresh_button", default="🔄 Обновить"),
|
||||
text=_(key="admin_user_refresh_button"),
|
||||
callback_data=f"user_action:refresh:{user_id}"
|
||||
)
|
||||
|
||||
# Row 4: Back button
|
||||
|
||||
# Row 4: Quick links
|
||||
builder.button(
|
||||
text=_(key="admin_user_search_new_button", default="🔍 Найти другого"),
|
||||
text=_(key="user_card_open_profile_button"),
|
||||
url=f"tg://user?id={user_id}"
|
||||
)
|
||||
if referrer_id:
|
||||
builder.button(
|
||||
text=_(key="user_card_open_referrer_profile_button"),
|
||||
url=f"tg://user?id={referrer_id}"
|
||||
)
|
||||
|
||||
# Row 5: Destructive action
|
||||
builder.button(
|
||||
text=_(key="admin_user_delete_button"),
|
||||
callback_data=f"user_action:delete_user:{user_id}"
|
||||
)
|
||||
|
||||
# Row 6: Navigation
|
||||
builder.button(
|
||||
text=_(key="admin_user_search_new_button"),
|
||||
callback_data="admin_action:users_management"
|
||||
)
|
||||
builder.button(
|
||||
@@ -106,10 +169,39 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
|
||||
builder.adjust(2, 2, 2, 2)
|
||||
quick_links_width = 2 if referrer_id else 1
|
||||
builder.adjust(2, 2, 2, quick_links_width, 1, 2)
|
||||
return builder
|
||||
|
||||
|
||||
async def _send_with_profile_link_fallback(
|
||||
sender: Callable[..., Awaitable[Any]],
|
||||
*,
|
||||
text: str,
|
||||
markup: Optional[types.InlineKeyboardMarkup],
|
||||
user_id: int,
|
||||
parse_mode: Optional[str] = "HTML") -> None:
|
||||
"""Send text with markup and fallback if Telegram rejects tg://user buttons."""
|
||||
send_kwargs: Dict[str, Any] = {"text": text, "reply_markup": markup}
|
||||
if parse_mode is not None:
|
||||
send_kwargs["parse_mode"] = parse_mode
|
||||
|
||||
try:
|
||||
await sender(**send_kwargs)
|
||||
except TelegramBadRequest as exc:
|
||||
if not is_profile_link_error(exc):
|
||||
raise
|
||||
|
||||
logging.warning(
|
||||
"Telegram rejected profile buttons for user %s: %s. Retrying without tg:// links.",
|
||||
user_id,
|
||||
getattr(exc, "message", "") or str(exc),
|
||||
)
|
||||
fallback_markup = remove_profile_link_buttons(markup)
|
||||
send_kwargs["reply_markup"] = fallback_markup
|
||||
await sender(**send_kwargs)
|
||||
|
||||
|
||||
async def format_user_card(user: User, session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
i18n_instance, lang: str,
|
||||
@@ -119,10 +211,10 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
|
||||
# Basic user info
|
||||
card_parts = []
|
||||
card_parts.append(f"👤 <b>{_('admin_user_card_title', default='Карточка пользователя')}</b>\n")
|
||||
card_parts.append(f"👤 <b>{_('admin_user_card_title')}</b>\n")
|
||||
|
||||
# User details
|
||||
na_value = _("admin_user_na_value", default="N/A")
|
||||
na_value = _("admin_user_na_value")
|
||||
safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None
|
||||
user_name = safe_first_name or na_value
|
||||
if user.username:
|
||||
@@ -135,23 +227,23 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
username_display = na_value
|
||||
registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value
|
||||
|
||||
card_parts.append(f"{_('admin_user_id_label', default='🆔 <b>ID:</b>')} {hcode(str(user.user_id))}")
|
||||
card_parts.append(f"{_('admin_user_name_label', default='👤 <b>Имя:</b>')} {hcode(user_name)}")
|
||||
card_parts.append(f"{_('admin_user_username_label', default='📱 <b>Username:</b>')} {hcode(username_display)}")
|
||||
card_parts.append(f"{_('admin_user_language_label', default='🌍 <b>Язык:</b>')} {hcode(user.language_code or na_value)}")
|
||||
card_parts.append(f"{_('admin_user_registration_label', default='📅 <b>Регистрация:</b>')} {hcode(registration_date)}")
|
||||
card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}")
|
||||
card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}")
|
||||
card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}")
|
||||
card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}")
|
||||
card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}")
|
||||
|
||||
# Ban status
|
||||
ban_status = _("admin_user_status_banned", default="🚫 Заблокирован") if user.is_banned else _("admin_user_status_active", default="✅ Активен")
|
||||
card_parts.append(f"{_('admin_user_status_label', default='🛡 <b>Статус:</b>')} {ban_status}")
|
||||
ban_status = _("admin_user_status_banned") if user.is_banned else _("admin_user_status_active")
|
||||
card_parts.append(f"{_('admin_user_status_label')} {ban_status}")
|
||||
|
||||
# Referral info
|
||||
if user.referred_by_id:
|
||||
card_parts.append(f"{_('admin_user_referral_label', default='🎁 <b>Привлечен по реферальной программе от:</b>')} {hcode(str(user.referred_by_id))}")
|
||||
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
|
||||
|
||||
# Panel info
|
||||
if user.panel_user_uuid:
|
||||
card_parts.append(f"{_('admin_user_panel_uuid_label', default='🔗 <b>Panel UUID:</b>')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}")
|
||||
card_parts.append(f"{_('admin_user_panel_uuid_label')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}")
|
||||
|
||||
card_parts.append("") # Empty line
|
||||
|
||||
@@ -159,38 +251,52 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
try:
|
||||
subscription_details = await subscription_service.get_active_subscription_details(session, user.user_id)
|
||||
if subscription_details:
|
||||
card_parts.append(f"💳 <b>{_('admin_user_subscription_info', default='Информация о подписке:')}</b>")
|
||||
card_parts.append(f"💳 <b>{_('admin_user_subscription_info')}</b>")
|
||||
|
||||
end_date = subscription_details.get('end_date')
|
||||
if end_date:
|
||||
end_date_str = end_date.strftime('%Y-%m-%d %H:%M') if isinstance(end_date, datetime) else str(end_date)
|
||||
card_parts.append(f"{_('admin_user_subscription_active_until', default='⏰ <b>Действует до:</b>')} {hcode(end_date_str)}")
|
||||
card_parts.append(f"{_('admin_user_subscription_active_until')} {hcode(end_date_str)}")
|
||||
|
||||
status = subscription_details.get('status_from_panel', 'UNKNOWN')
|
||||
card_parts.append(f"{_('admin_user_panel_status_label', default='📊 <b>Статус на панели:</b>')} {hcode(status)}")
|
||||
card_parts.append(f"{_('admin_user_panel_status_label')} {hcode(status)}")
|
||||
|
||||
traffic_limit = subscription_details.get('traffic_limit_bytes')
|
||||
traffic_used = subscription_details.get('traffic_used_bytes')
|
||||
if traffic_limit and traffic_used is not None:
|
||||
traffic_limit_gb = traffic_limit / (1024**3)
|
||||
traffic_used_gb = traffic_used / (1024**3)
|
||||
card_parts.append(f"{_('admin_user_traffic_label', default='📊 <b>Трафик:</b>')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
|
||||
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
|
||||
else:
|
||||
card_parts.append(f"{_('admin_user_subscription_label', default='💼 <b>Подписка:</b>')} {hcode(_('admin_user_subscription_none', default='Нет активной подписки'))}")
|
||||
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting subscription details for user {user.user_id}: {e}")
|
||||
card_parts.append(f"{_('admin_user_subscription_label', default='💼 <b>Подписка:</b>')} {hcode(_('admin_user_subscription_error', default='Ошибка загрузки'))}")
|
||||
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_error'))}")
|
||||
|
||||
# Statistics
|
||||
try:
|
||||
# Count user logs
|
||||
logs_count = await message_log_dal.count_user_message_logs(session, user.user_id)
|
||||
card_parts.append(f"{_('admin_user_actions_count_label', default='📜 <b>Всего действий:</b>')} {hcode(str(logs_count))}")
|
||||
card_parts.append(f"{_('admin_user_actions_count_label')} {hcode(str(logs_count))}")
|
||||
|
||||
# Check if user had any subscriptions
|
||||
had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id)
|
||||
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал")
|
||||
card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}")
|
||||
trial_status = _("admin_user_trial_used") if had_subscriptions else _("admin_user_trial_not_used")
|
||||
card_parts.append(f"{_('admin_user_trial_label')} {hcode(trial_status)}")
|
||||
|
||||
# Financial analytics (admin-only)
|
||||
try:
|
||||
from db.dal import payment_dal
|
||||
|
||||
# Total amount paid by this user
|
||||
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
|
||||
card_parts.append(f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}")
|
||||
|
||||
# Total revenue from referrals
|
||||
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
|
||||
card_parts.append(f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}")
|
||||
except Exception as e_fin:
|
||||
logging.error(f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}")
|
||||
|
||||
# Referral stats
|
||||
if referral_service is not None:
|
||||
@@ -198,8 +304,8 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
stats = await referral_service.get_referral_stats(session, user.user_id)
|
||||
invited_count = stats.get('invited_count', 0)
|
||||
purchased_count = stats.get('purchased_count', 0)
|
||||
card_parts.append(f"{_('admin_user_invited_friends_label', default='👥 <b>Приглашено друзей:</b>')} {hcode(str(invited_count))}")
|
||||
card_parts.append(f"{_('admin_user_ref_purchased_label', default='💳 <b>Купили подписку:</b>')} {hcode(str(purchased_count))}")
|
||||
card_parts.append(f"{_('admin_user_invited_friends_label')} {hcode(str(invited_count))}")
|
||||
card_parts.append(f"{_('admin_user_ref_purchased_label')} {hcode(str(purchased_count))}")
|
||||
except Exception as e_rs:
|
||||
logging.error(f"Failed to build referral stats for admin card {user.user_id}: {e_rs}")
|
||||
|
||||
@@ -239,7 +345,6 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
||||
if not user_model:
|
||||
await message.answer(_(
|
||||
"admin_user_not_found",
|
||||
default="❌ Пользователь не найден: {input}",
|
||||
input=hcode(input_text)
|
||||
))
|
||||
return
|
||||
@@ -252,18 +357,24 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
||||
try:
|
||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang)
|
||||
keyboard = get_user_card_keyboard(
|
||||
user_model.user_id,
|
||||
i18n,
|
||||
current_lang,
|
||||
user_model.referred_by_id
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
user_card_text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
await _send_with_profile_link_fallback(
|
||||
message.answer,
|
||||
text=user_card_text,
|
||||
markup=keyboard.as_markup(),
|
||||
user_id=user_model.user_id,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error displaying user card for {user_model.user_id}: {e}")
|
||||
await message.answer(_(
|
||||
"admin_user_card_error",
|
||||
default="❌ Ошибка отображения карточки пользователя"
|
||||
"admin_user_card_error"
|
||||
))
|
||||
|
||||
|
||||
@@ -293,8 +404,7 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user:
|
||||
await callback.answer(_(
|
||||
"admin_user_not_found_action",
|
||||
default="Пользователь не найден"
|
||||
"admin_user_not_found_action"
|
||||
), show_alert=True)
|
||||
return
|
||||
|
||||
@@ -310,6 +420,10 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
|
||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
||||
elif action == "refresh":
|
||||
await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang)
|
||||
elif action == "delete_user":
|
||||
await handle_delete_user_prompt(
|
||||
callback, state, user, settings, i18n, current_lang, session
|
||||
)
|
||||
else:
|
||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||
|
||||
@@ -326,8 +440,7 @@ async def handle_reset_trial(callback: types.CallbackQuery, user: User,
|
||||
await session.commit()
|
||||
|
||||
await callback.answer(_(
|
||||
"admin_user_trial_reset_success",
|
||||
default="✅ Триал сброшен! Пользователь может активировать триал заново."
|
||||
"admin_user_trial_reset_success"
|
||||
), show_alert=True)
|
||||
|
||||
# Refresh user card
|
||||
@@ -337,8 +450,7 @@ async def handle_reset_trial(callback: types.CallbackQuery, user: User,
|
||||
logging.error(f"Error resetting trial for user {user.user_id}: {e}")
|
||||
await session.rollback()
|
||||
await callback.answer(_(
|
||||
"admin_user_trial_reset_error",
|
||||
default="❌ Ошибка сброса триала"
|
||||
"admin_user_trial_reset_error"
|
||||
), show_alert=True)
|
||||
|
||||
|
||||
@@ -352,7 +464,6 @@ async def handle_add_subscription_prompt(callback: types.CallbackQuery, state: F
|
||||
|
||||
prompt_text = _(
|
||||
"admin_user_add_subscription_prompt",
|
||||
default="➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:",
|
||||
user_id=user.user_id
|
||||
)
|
||||
|
||||
@@ -383,10 +494,9 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User,
|
||||
|
||||
await session.commit()
|
||||
|
||||
status_text = _("admin_user_ban_action_banned", default="заблокирован") if new_ban_status else _("admin_user_ban_action_unbanned", default="разблокирован")
|
||||
status_text = _("admin_user_ban_action_banned") if new_ban_status else _("admin_user_ban_action_unbanned")
|
||||
await callback.answer(_(
|
||||
"admin_user_ban_toggle_success",
|
||||
default="✅ Пользователь {status}",
|
||||
status=status_text
|
||||
), show_alert=True)
|
||||
|
||||
@@ -403,8 +513,7 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User,
|
||||
logging.error(f"Error toggling ban for user {user.user_id}: {e}")
|
||||
await session.rollback()
|
||||
await callback.answer(_(
|
||||
"admin_user_ban_toggle_error",
|
||||
default="❌ Ошибка изменения статуса блокировки"
|
||||
"admin_user_ban_toggle_error"
|
||||
), show_alert=True)
|
||||
|
||||
|
||||
@@ -418,7 +527,6 @@ async def handle_send_message_prompt(callback: types.CallbackQuery, state: FSMCo
|
||||
|
||||
prompt_text = _(
|
||||
"admin_user_send_message_prompt",
|
||||
default="✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:",
|
||||
user_id=user.user_id
|
||||
)
|
||||
|
||||
@@ -442,13 +550,12 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User,
|
||||
|
||||
if not logs:
|
||||
await callback.answer(_(
|
||||
"admin_user_no_logs",
|
||||
default="📜 У пользователя нет действий"
|
||||
"admin_user_no_logs"
|
||||
), show_alert=True)
|
||||
return
|
||||
|
||||
logs_text_parts = [
|
||||
f"{_('admin_user_recent_actions_title', default='📜 Последние действия пользователя {user_id}:', user_id=user.user_id)}\n"
|
||||
f"{_('admin_user_recent_actions_title', user_id=user.user_id)}\n"
|
||||
]
|
||||
|
||||
for log in logs:
|
||||
@@ -466,11 +573,11 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User,
|
||||
# Create inline keyboard for full logs
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=_(key="admin_user_view_all_logs_button", default="📋 Все действия"),
|
||||
text=_(key="admin_user_view_all_logs_button"),
|
||||
callback_data=f"admin_logs:view_user:{user.user_id}:0"
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="admin_user_back_to_card_button", default="🔙 К карточке"),
|
||||
text=_(key="admin_user_back_to_card_button"),
|
||||
callback_data=f"user_action:refresh:{user.user_id}"
|
||||
)
|
||||
builder.adjust(1)
|
||||
@@ -493,8 +600,7 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User,
|
||||
except Exception as e:
|
||||
logging.error(f"Error viewing logs for user {user.user_id}: {e}")
|
||||
await callback.answer(_(
|
||||
"admin_user_logs_error",
|
||||
default="❌ Ошибка загрузки действий пользователя"
|
||||
"admin_user_logs_error"
|
||||
), show_alert=True)
|
||||
|
||||
|
||||
@@ -513,18 +619,28 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
||||
_settings = _Settings()
|
||||
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance)
|
||||
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang)
|
||||
keyboard = get_user_card_keyboard(
|
||||
fresh_user.user_id,
|
||||
i18n_instance,
|
||||
lang,
|
||||
fresh_user.referred_by_id
|
||||
)
|
||||
markup = keyboard.as_markup()
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
user_card_text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
await _send_with_profile_link_fallback(
|
||||
callback.message.edit_text,
|
||||
text=user_card_text,
|
||||
markup=markup,
|
||||
user_id=fresh_user.user_id,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
user_card_text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
await _send_with_profile_link_fallback(
|
||||
callback.message.answer,
|
||||
text=user_card_text,
|
||||
markup=markup,
|
||||
user_id=fresh_user.user_id,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -535,8 +651,199 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
||||
await callback.answer("Error refreshing user card", show_alert=True)
|
||||
|
||||
|
||||
# Destructive deletion flow
|
||||
async def handle_delete_user_prompt(callback: types.CallbackQuery, state: FSMContext,
|
||||
user: User, settings: Settings, i18n_instance,
|
||||
lang: str, session: AsyncSession):
|
||||
"""Trigger confirmation workflow for destructive deletion."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
admin = callback.from_user
|
||||
admin_id = admin.id if admin else None
|
||||
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||
logging.warning(
|
||||
f"Unauthorized delete attempt by user {admin_id} targeting {user.user_id}."
|
||||
)
|
||||
await callback.answer(
|
||||
_(
|
||||
"admin_user_delete_not_allowed",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
target_user_id=user.user_id,
|
||||
delete_initiator_id=admin_id,
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_user_delete_confirmation)
|
||||
|
||||
prompt_text = _(
|
||||
"admin_user_delete_confirmation_prompt",
|
||||
user_id=hcode(str(user.user_id)),
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(prompt_text, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send delete confirmation prompt for user {user.user_id}: {e}"
|
||||
)
|
||||
await callback.message.reply(prompt_text, parse_mode="HTML")
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _log_admin_user_deletion(
|
||||
session: AsyncSession,
|
||||
admin_id: int,
|
||||
admin_user: Optional[types.User],
|
||||
target_user_id: int,
|
||||
) -> None:
|
||||
"""Store audit log for successful deletion."""
|
||||
try:
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": admin_id,
|
||||
"telegram_username": admin_user.username if admin_user else None,
|
||||
"telegram_first_name": admin_user.first_name if admin_user else None,
|
||||
"event_type": "admin:user_deleted",
|
||||
"content": f"Admin {admin_id} deleted user {target_user_id}",
|
||||
"raw_update_preview": None,
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_user_id,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to log deletion audit for admin {admin_id} -> user {target_user_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
# Message handlers for state-based inputs
|
||||
|
||||
@router.message(AdminStates.waiting_for_user_delete_confirmation, F.text)
|
||||
async def process_delete_user_confirmation_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
"""Confirm and execute destructive user deletion."""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await message.reply("Language service error.")
|
||||
await state.clear()
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
admin = message.from_user
|
||||
admin_id = admin.id if admin else None
|
||||
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||
logging.warning(
|
||||
f"Unauthorized delete confirmation attempt by user {admin_id}."
|
||||
)
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_not_allowed",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target_user_id = data.get("target_user_id")
|
||||
if not target_user_id:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_state_missing",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
confirmation_input = message.text.strip() if message.text else ""
|
||||
if confirmation_input.lower() in {"/cancel", "cancel", "отмена"}:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_cancelled",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if confirmation_input != str(target_user_id):
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_mismatch",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
user_model = await user_dal.get_user_by_id(session, target_user_id)
|
||||
if not user_model:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_already_removed",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
if user_model.panel_user_uuid:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(
|
||||
user_model.panel_user_uuid
|
||||
)
|
||||
if not panel_deleted:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_panel_error",
|
||||
)
|
||||
)
|
||||
await session.rollback()
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
deleted = await user_dal.delete_user_and_relations(
|
||||
session, target_user_id
|
||||
)
|
||||
if not deleted:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_already_removed",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await _log_admin_user_deletion(session, admin_id, admin, target_user_id)
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_success",
|
||||
user_id=hcode(str(target_user_id)),
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error deleting user {target_user_id}: {e}", exc_info=True)
|
||||
await session.rollback()
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_error",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text)
|
||||
async def process_subscription_days_handler(message: types.Message, state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
@@ -563,8 +870,7 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
||||
raise ValueError("Invalid days count")
|
||||
except ValueError:
|
||||
await message.answer(_(
|
||||
"admin_user_invalid_days",
|
||||
default="❌ Неверное количество дней. Введите число от 1 до 3650."
|
||||
"admin_user_invalid_days"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -578,7 +884,6 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
||||
await session.commit()
|
||||
await message.answer(_(
|
||||
"admin_user_subscription_added_success",
|
||||
default="✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
|
||||
days=days_to_add,
|
||||
user_id=target_user_id
|
||||
))
|
||||
@@ -588,26 +893,31 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
||||
if user:
|
||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang)
|
||||
keyboard = get_user_card_keyboard(
|
||||
user.user_id,
|
||||
i18n,
|
||||
current_lang,
|
||||
user.referred_by_id
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
user_card_text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
await _send_with_profile_link_fallback(
|
||||
message.answer,
|
||||
text=user_card_text,
|
||||
markup=keyboard.as_markup(),
|
||||
user_id=user.user_id,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
await message.answer(_(
|
||||
"admin_user_subscription_added_error",
|
||||
default="❌ Ошибка добавления дней подписки"
|
||||
"admin_user_subscription_added_error"
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error adding subscription days for user {target_user_id}: {e}")
|
||||
await session.rollback()
|
||||
await message.answer(_(
|
||||
"admin_user_subscription_added_error",
|
||||
default="❌ Ошибка добавления дней подписки"
|
||||
"admin_user_subscription_added_error"
|
||||
))
|
||||
|
||||
await state.clear()
|
||||
@@ -636,8 +946,7 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
||||
text = (message.text or message.caption or "").strip()
|
||||
if len(text) > 4000:
|
||||
await message.answer(_(
|
||||
"admin_user_message_too_long",
|
||||
default="❌ Сообщение слишком длинное (максимум 4000 символов)"
|
||||
"admin_user_message_too_long"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -651,16 +960,14 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
||||
|
||||
# Prepare admin signature and get content
|
||||
admin_signature = _(
|
||||
"admin_direct_message_signature",
|
||||
default="\n\n---\n💬 Сообщение от администратора"
|
||||
"admin_direct_message_signature"
|
||||
)
|
||||
|
||||
content = get_message_content(message)
|
||||
|
||||
if not content.text and not content.file_id:
|
||||
await message.answer(_(
|
||||
"admin_direct_empty_message",
|
||||
default="❌ Пустое сообщение. Отправьте текст или медиа."
|
||||
"admin_direct_empty_message"
|
||||
))
|
||||
return
|
||||
|
||||
@@ -679,7 +986,6 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
||||
except TelegramBadRequest as e:
|
||||
await message.answer(_(
|
||||
"admin_broadcast_invalid_html",
|
||||
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
|
||||
error=str(e),
|
||||
))
|
||||
return
|
||||
@@ -687,7 +993,6 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
||||
# Confirm to admin
|
||||
await message.answer(_(
|
||||
"admin_user_message_sent_success",
|
||||
default="✅ Сообщение отправлено пользователю {user_id}",
|
||||
user_id=target_user_id
|
||||
))
|
||||
|
||||
@@ -697,19 +1002,25 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
||||
subscription_service = SubscriptionService(settings, panel_service)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang)
|
||||
keyboard = get_user_card_keyboard(
|
||||
target_user.user_id,
|
||||
i18n,
|
||||
current_lang,
|
||||
target_user.referred_by_id
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
user_card_text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
await _send_with_profile_link_fallback(
|
||||
message.answer,
|
||||
text=user_card_text,
|
||||
markup=keyboard.as_markup(),
|
||||
user_id=target_user.user_id,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error sending direct message to user {target_user_id}: {e}")
|
||||
await message.answer(_(
|
||||
"admin_user_message_sent_error",
|
||||
default="❌ Ошибка отправки сообщения"
|
||||
"admin_user_message_sent_error"
|
||||
))
|
||||
|
||||
await state.clear()
|
||||
@@ -727,8 +1038,7 @@ async def ban_user_prompt_handler(callback: types.CallbackQuery,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
prompt_text = _(
|
||||
"admin_ban_user_prompt",
|
||||
default="🚫 Блокировка пользователя\n\nВведите ID пользователя или @username для блокировки:"
|
||||
"admin_ban_user_prompt"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -759,8 +1069,7 @@ async def unban_user_prompt_handler(callback: types.CallbackQuery,
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
prompt_text = _(
|
||||
"admin_unban_user_prompt",
|
||||
default="✅ Разблокировка пользователя\n\nВведите ID пользователя или @username для разблокировки:"
|
||||
"admin_unban_user_prompt"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -796,8 +1105,7 @@ async def view_banned_users_handler(callback: types.CallbackQuery,
|
||||
|
||||
if not banned_users:
|
||||
message_text = _(
|
||||
"admin_banned_users_empty",
|
||||
default="📋 Заблокированные пользователи\n\nСписок пуст"
|
||||
"admin_banned_users_empty"
|
||||
)
|
||||
else:
|
||||
user_list = []
|
||||
@@ -809,7 +1117,6 @@ async def view_banned_users_handler(callback: types.CallbackQuery,
|
||||
|
||||
message_text = _(
|
||||
"admin_banned_users_list",
|
||||
default="📋 Заблокированные пользователи ({count}):\n\n{users}",
|
||||
count=len(banned_users),
|
||||
users="\n".join(user_list)
|
||||
)
|
||||
@@ -854,7 +1161,6 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
||||
if not user_model:
|
||||
await message.answer(_(
|
||||
"admin_user_not_found",
|
||||
default="❌ Пользователь не найден: {input}",
|
||||
input=hcode(input_text)
|
||||
))
|
||||
return
|
||||
@@ -863,8 +1169,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
||||
# Check if user is already banned
|
||||
if user_model.is_banned:
|
||||
await message.answer(_(
|
||||
"admin_user_already_banned",
|
||||
default="⚠️ Пользователь уже заблокирован"
|
||||
"admin_user_already_banned"
|
||||
))
|
||||
await state.clear()
|
||||
return
|
||||
@@ -880,7 +1185,6 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
||||
|
||||
await message.answer(_(
|
||||
"admin_user_ban_success",
|
||||
default="✅ Пользователь {input} заблокирован",
|
||||
input=hcode(input_text)
|
||||
))
|
||||
|
||||
@@ -888,8 +1192,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
||||
logging.error(f"Error banning user {user_model.user_id}: {e}")
|
||||
await session.rollback()
|
||||
await message.answer(_(
|
||||
"admin_user_ban_error",
|
||||
default="❌ Ошибка блокировки пользователя"
|
||||
"admin_user_ban_error"
|
||||
))
|
||||
|
||||
await state.clear()
|
||||
@@ -897,9 +1200,9 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
||||
|
||||
@router.message(AdminStates.waiting_for_user_id_to_unban, F.text)
|
||||
async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
settings: Settings, i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
"""Process user unban input"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -925,7 +1228,6 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||
if not user_model:
|
||||
await message.answer(_(
|
||||
"admin_user_not_found",
|
||||
default="❌ Пользователь не найден: {input}",
|
||||
input=hcode(input_text)
|
||||
))
|
||||
return
|
||||
@@ -934,8 +1236,7 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||
# Check if user is not banned
|
||||
if not user_model.is_banned:
|
||||
await message.answer(_(
|
||||
"admin_user_not_banned",
|
||||
default="⚠️ Пользователь не заблокирован"
|
||||
"admin_user_not_banned"
|
||||
))
|
||||
await state.clear()
|
||||
return
|
||||
@@ -951,7 +1252,6 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||
|
||||
await message.answer(_(
|
||||
"admin_user_unban_success",
|
||||
default="✅ Пользователь {input} разблокирован",
|
||||
input=hcode(input_text)
|
||||
))
|
||||
|
||||
@@ -959,8 +1259,71 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||
logging.error(f"Error unbanning user {user_model.user_id}: {e}")
|
||||
await session.rollback()
|
||||
await message.answer(_(
|
||||
"admin_user_unban_error",
|
||||
default="❌ Ошибка разблокировки пользователя"
|
||||
"admin_user_unban_error"
|
||||
))
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_user_card_from_list:"))
|
||||
async def user_card_from_list_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings, bot: Bot,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
"""Display user card when clicked from user list"""
|
||||
try:
|
||||
parts = callback.data.split(":")
|
||||
user_id = int(parts[1])
|
||||
page = int(parts[2])
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("Invalid user data", show_alert=True)
|
||||
return
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await callback.answer("Language service error", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
# Get user from database
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user:
|
||||
await callback.answer("User not found", show_alert=True)
|
||||
return
|
||||
|
||||
# Create keyboard with back to list button
|
||||
keyboard = get_user_card_keyboard(
|
||||
user_id,
|
||||
i18n,
|
||||
current_lang,
|
||||
user.referred_by_id
|
||||
)
|
||||
keyboard.button(
|
||||
text=_("admin_user_back_to_list_button"),
|
||||
callback_data=f"admin_action:users_list:{page}"
|
||||
)
|
||||
quick_links_width = 2 if user.referred_by_id else 1
|
||||
keyboard.adjust(2, 2, 2, quick_links_width, 1, 2, 1)
|
||||
|
||||
# Format user card
|
||||
try:
|
||||
from bot.services.referral_service import ReferralService
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||
markup = keyboard.as_markup()
|
||||
|
||||
await _send_with_profile_link_fallback(
|
||||
callback.message.edit_text,
|
||||
text=user_card_text,
|
||||
markup=markup,
|
||||
user_id=user.user_id,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error displaying user card: {e}")
|
||||
await callback.answer("Error displaying user card", show_alert=True)
|
||||
|
||||
+32
-51
@@ -38,7 +38,13 @@ async def inline_query_handler(inline_query: InlineQuery,
|
||||
# For all users: referral functionality
|
||||
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
|
||||
referral_result = await create_referral_result(
|
||||
inline_query, bot, referral_service, i18n, current_lang, settings
|
||||
inline_query,
|
||||
bot,
|
||||
referral_service,
|
||||
i18n,
|
||||
current_lang,
|
||||
settings,
|
||||
session,
|
||||
)
|
||||
if referral_result:
|
||||
results.append(referral_result)
|
||||
@@ -67,9 +73,15 @@ async def inline_query_handler(inline_query: InlineQuery,
|
||||
await inline_query.answer(results=[], cache_time=10)
|
||||
|
||||
|
||||
async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
||||
referral_service: ReferralService,
|
||||
i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
|
||||
async def create_referral_result(
|
||||
inline_query: InlineQuery,
|
||||
bot: Bot,
|
||||
referral_service: ReferralService,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
) -> Optional[InlineQueryResultArticle]:
|
||||
"""Create referral link result for inline query"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
@@ -80,25 +92,27 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
||||
return None
|
||||
|
||||
user_id = inline_query.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(bot_username, user_id)
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, user_id
|
||||
)
|
||||
|
||||
if not referral_link:
|
||||
logging.warning("Could not produce referral link for inline user %s", user_id)
|
||||
return None
|
||||
|
||||
# Create message content (use same text as friend message)
|
||||
message_text = _(
|
||||
"referral_friend_message",
|
||||
default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n"
|
||||
"🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
referral_link=referral_link
|
||||
)
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="referral_link",
|
||||
title=_(
|
||||
"inline_referral_title",
|
||||
default="🎁 Пригласить друга"
|
||||
"inline_referral_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_referral_description",
|
||||
default="Поделиться реферальной ссылкой для получения бонусов"
|
||||
"inline_referral_description"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=message_text,
|
||||
@@ -149,13 +163,6 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
|
||||
|
||||
stats_text = _(
|
||||
"inline_user_stats_message",
|
||||
default="📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n"
|
||||
"📊 Всего: <b>{total}</b>\n"
|
||||
"💳 С платной подпиской: <b>{paid}</b>\n"
|
||||
"🆓 На пробном периоде: <b>{trial}</b>\n"
|
||||
"😴 Неактивных: <b>{inactive}</b>\n"
|
||||
"🚫 Заблокированных: <b>{banned}</b>\n"
|
||||
"🎁 Привлечено по реферальной программе: <b>{referral}</b>",
|
||||
total=user_stats['total_users'],
|
||||
active_today=user_stats['active_today'],
|
||||
paid=user_stats['paid_subscriptions'],
|
||||
@@ -168,12 +175,10 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_user_stats",
|
||||
title=_(
|
||||
"inline_admin_user_stats_title",
|
||||
default="📊 Статистика пользователей"
|
||||
"inline_admin_user_stats_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_user_stats_description",
|
||||
default="Всего: {total}, Платных: {active}",
|
||||
total=user_stats['total_users'],
|
||||
active=user_stats['paid_subscriptions']
|
||||
),
|
||||
@@ -199,12 +204,6 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
|
||||
|
||||
stats_text = _(
|
||||
"inline_financial_stats_message",
|
||||
default="💰 <b>Финансовая статистика</b>\n\n"
|
||||
"📅 За сегодня: <b>{today:.2f} RUB</b>\n"
|
||||
" ({today_count} платежей)\n"
|
||||
"📅 За неделю: <b>{week:.2f} RUB</b>\n"
|
||||
"📅 За месяц: <b>{month:.2f} RUB</b>\n"
|
||||
"🏆 За все время: <b>{all_time:.2f} RUB</b>",
|
||||
today=financial_stats['today_revenue'],
|
||||
today_count=financial_stats['today_payments_count'],
|
||||
week=financial_stats['week_revenue'],
|
||||
@@ -215,12 +214,10 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_financial_stats",
|
||||
title=_(
|
||||
"inline_admin_financial_stats_title",
|
||||
default="💰 Финансовая статистика"
|
||||
"inline_admin_financial_stats_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_financial_description",
|
||||
default="Сегодня: {today} RUB",
|
||||
today=f"{financial_stats['today_revenue']:.2f}"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
@@ -293,17 +290,6 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
|
||||
stats_text = _(
|
||||
"inline_system_stats_message",
|
||||
default="🖥 <b>Статистика панели</b>\n\n"
|
||||
"🟢 Онлайн: <b>{online}</b>\n"
|
||||
"📊 Активных: <b>{active}</b>\n"
|
||||
"🔴 Отключенных: <b>{disabled}</b>\n"
|
||||
"⏰ Истекшие: <b>{expired}</b>\n"
|
||||
"⚠️ Ограниченные: <b>{limited}</b>\n"
|
||||
"👥 Всего пользователей: <b>{total}</b>\n"
|
||||
"💾 Использование RAM: <b>{memory:.1f}%</b>\n"
|
||||
"📊 Трафик за неделю: <b>{week_traffic}</b>\n"
|
||||
"📊 Трафик за месяц: <b>{month_traffic}</b>\n"
|
||||
"🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||
online=online_now,
|
||||
active=active_users,
|
||||
disabled=disabled_users,
|
||||
@@ -317,17 +303,15 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
total_nodes=total_nodes
|
||||
)
|
||||
else:
|
||||
stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
||||
stats_text = _("inline_panel_stats_error")
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_system_stats",
|
||||
title=_(
|
||||
"inline_admin_system_stats_title",
|
||||
default="🖥 Системная статистика"
|
||||
"inline_admin_system_stats_title"
|
||||
),
|
||||
description=_(
|
||||
"inline_system_description",
|
||||
default="🟢 Онлайн: {online}, 📊 Активных: {active}",
|
||||
online=online_now,
|
||||
active=active_users
|
||||
),
|
||||
@@ -341,15 +325,14 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating system stats result: {e}")
|
||||
# Fallback error message
|
||||
error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
|
||||
error_text = _("inline_panel_stats_error")
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_system_stats",
|
||||
title=_(
|
||||
"inline_admin_system_stats_title",
|
||||
default="🖥 Системная статистика"
|
||||
"inline_admin_system_stats_title"
|
||||
),
|
||||
description=_("inline_system_error", default="Ошибка получения данных"),
|
||||
description=_("inline_system_error"),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=error_text,
|
||||
parse_mode="HTML"
|
||||
@@ -357,5 +340,3 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
|
||||
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+534
-200
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
@@ -18,13 +17,13 @@ from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
|
||||
@@ -36,75 +35,148 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
i18n: JsonI18n, settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService):
|
||||
referral_service: ReferralService,
|
||||
yookassa_service: Optional[YooKassaService] = None,
|
||||
lknpd_service: Optional[LknpdService] = None) -> bool:
|
||||
metadata = payment_info_from_webhook.get("metadata", {})
|
||||
user_id_str = metadata.get("user_id")
|
||||
subscription_months_str = metadata.get("subscription_months")
|
||||
traffic_gb_str = metadata.get("traffic_gb")
|
||||
sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription")
|
||||
promo_code_id_str = metadata.get("promo_code_id")
|
||||
payment_db_id_str = metadata.get("payment_db_id")
|
||||
auto_renew_subscription_id_str = metadata.get(
|
||||
"auto_renew_for_subscription_id")
|
||||
|
||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
||||
# we will create/ensure a payment record idempotently using provider payment id.
|
||||
if (not user_id_str or not subscription_months_str
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)):
|
||||
if (
|
||||
not user_id_str
|
||||
or (not subscription_months_str and not traffic_gb_str)
|
||||
or not payment_db_id_str
|
||||
):
|
||||
logging.error(
|
||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
||||
)
|
||||
return
|
||||
return False
|
||||
|
||||
db_user = None
|
||||
payment_before_update = None
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
subscription_months = int(subscription_months_str)
|
||||
payment_db_id = int(
|
||||
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
||||
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id)
|
||||
subscription_months = float(subscription_months_str or 0)
|
||||
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
|
||||
if not payment_db_id_str.isdigit():
|
||||
logging.error(
|
||||
"Invalid payment_db_id metadata for payment %s: %s",
|
||||
payment_info_from_webhook.get("id"),
|
||||
payment_db_id_str,
|
||||
)
|
||||
return False
|
||||
payment_db_id = int(payment_db_id_str)
|
||||
is_auto_renew = bool(auto_renew_subscription_id_str and sale_mode != "traffic")
|
||||
promo_code_id = int(
|
||||
promo_code_id_str
|
||||
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
||||
|
||||
amount_data = payment_info_from_webhook.get("amount", {})
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
||||
try:
|
||||
# Create/ensure provider payment by YooKassa payment id for idempotency
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
from db.dal import payment_dal as _payment_dal
|
||||
ensured_payment = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=subscription_months,
|
||||
description=payment_info_from_webhook.get(
|
||||
"description") or f"Auto-renewal for {subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
)
|
||||
payment_db_id = ensured_payment.payment_id
|
||||
# Also persist yookassa_payment_id field if not set yet
|
||||
try:
|
||||
await _payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
payment_info_from_webhook.get("status", "succeeded"),
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
except Exception:
|
||||
# Non-fatal; continue processing
|
||||
logging.exception(
|
||||
"Failed to backfill yookassa_payment_id for ensured auto-renew payment"
|
||||
)
|
||||
except Exception as e_ensure:
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment_record:
|
||||
logging.error(
|
||||
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
|
||||
)
|
||||
return False
|
||||
if payment_record.user_id != user_id:
|
||||
logging.error(
|
||||
"Payment ownership mismatch for payment %s: metadata user_id=%s, db user_id=%s",
|
||||
payment_db_id,
|
||||
user_id,
|
||||
payment_record.user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Provider-backed verification (defense-in-depth): verify actual YooKassa payment state
|
||||
if yk_payment_id_from_hook and yookassa_service and yookassa_service.configured:
|
||||
provider_payment_info = await yookassa_service.get_payment_info(yk_payment_id_from_hook)
|
||||
if not provider_payment_info:
|
||||
logging.error(
|
||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
|
||||
exc_info=True,
|
||||
"YooKassa webhook verification failed: payment %s not found via provider API",
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
return
|
||||
return False
|
||||
|
||||
provider_status = str(provider_payment_info.get("status") or "")
|
||||
provider_paid = bool(provider_payment_info.get("paid"))
|
||||
if provider_status != "succeeded" or not provider_paid:
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: payment %s status/paid mismatch (status=%s, paid=%s)",
|
||||
yk_payment_id_from_hook,
|
||||
provider_status,
|
||||
provider_paid,
|
||||
)
|
||||
return False
|
||||
|
||||
provider_metadata_raw = provider_payment_info.get("metadata") or {}
|
||||
provider_metadata = provider_metadata_raw if isinstance(provider_metadata_raw, dict) else {}
|
||||
if str(provider_metadata.get("user_id") or "") != str(user_id):
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: user_id mismatch for payment %s (provider=%s, expected=%s)",
|
||||
yk_payment_id_from_hook,
|
||||
provider_metadata.get("user_id"),
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
provider_payment_db_id = str(provider_metadata.get("payment_db_id") or "").strip()
|
||||
if provider_payment_db_id != str(payment_db_id):
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: payment_db_id mismatch for payment %s (provider=%s, expected=%s)",
|
||||
yk_payment_id_from_hook,
|
||||
provider_metadata.get("payment_db_id"),
|
||||
payment_db_id,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
provider_amount = float(provider_payment_info.get("amount_value") or 0.0)
|
||||
if round(provider_amount, 2) != round(payment_value, 2):
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: amount mismatch for payment %s (payload %.2f vs provider %.2f)",
|
||||
yk_payment_id_from_hook,
|
||||
payment_value,
|
||||
provider_amount,
|
||||
)
|
||||
return False
|
||||
if payment_record and round(float(payment_record.amount), 2) != round(provider_amount, 2):
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: DB amount mismatch for payment %s (db %.2f vs provider %.2f)",
|
||||
payment_record.payment_id,
|
||||
float(payment_record.amount),
|
||||
provider_amount,
|
||||
)
|
||||
return False
|
||||
provider_currency = str(provider_payment_info.get("amount_currency") or "").upper()
|
||||
if payment_record and provider_currency and str(payment_record.currency or "").upper() != provider_currency:
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: currency mismatch for payment %s (db=%s, provider=%s)",
|
||||
payment_record.payment_id,
|
||||
payment_record.currency,
|
||||
provider_currency,
|
||||
)
|
||||
return False
|
||||
except Exception as e_amount_verify:
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed for payment %s: cannot validate amount (%s)",
|
||||
yk_payment_id_from_hook,
|
||||
e_amount_verify,
|
||||
)
|
||||
return False
|
||||
|
||||
if payment_record and payment_record.status == "succeeded":
|
||||
logging.info(
|
||||
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
|
||||
)
|
||||
return True
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -116,7 +188,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
session, payment_db_id, "failed_user_not_found",
|
||||
payment_info_from_webhook.get("id"))
|
||||
|
||||
return
|
||||
return False
|
||||
|
||||
except (TypeError, ValueError) as e:
|
||||
logging.error(
|
||||
@@ -132,14 +204,74 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
logging.error(
|
||||
f"Failed to update payment status after metadata error: {e_upd}"
|
||||
)
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
provider_payment_id = str(yk_payment_id_from_hook or "").strip()
|
||||
if not provider_payment_id:
|
||||
raise ValueError(
|
||||
f"Missing provider payment id in successful YooKassa webhook for payment {payment_db_id}"
|
||||
)
|
||||
|
||||
if payment_db_id is not None:
|
||||
payment_before_update = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
)
|
||||
if payment_before_update and payment_before_update.status == "succeeded":
|
||||
logging.info(
|
||||
"YooKassa webhook ignored: payment %s already succeeded (db_id=%s)",
|
||||
yk_payment_id_from_hook,
|
||||
payment_db_id,
|
||||
)
|
||||
return True
|
||||
|
||||
claimed_for_processing = await payment_dal.mark_provider_payment_processing_once(
|
||||
session,
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
expected_status_prefix="pending",
|
||||
)
|
||||
if not claimed_for_processing:
|
||||
payment_after_claim = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
)
|
||||
if payment_after_claim and payment_after_claim.status == "succeeded":
|
||||
logging.info(
|
||||
"YooKassa webhook ignored: payment %s already succeeded after claim attempt",
|
||||
payment_db_id,
|
||||
)
|
||||
return True
|
||||
|
||||
# Another transaction is processing this payment now.
|
||||
if payment_after_claim and payment_after_claim.status == "processing":
|
||||
logging.info(
|
||||
"YooKassa webhook: payment %s is already being processed by another worker",
|
||||
payment_db_id,
|
||||
)
|
||||
return False
|
||||
|
||||
logging.warning(
|
||||
"YooKassa webhook: payment %s cannot be claimed for processing (status=%s)",
|
||||
payment_db_id,
|
||||
payment_after_claim.status if payment_after_claim else None,
|
||||
)
|
||||
return False
|
||||
|
||||
should_send_lknpd_receipt = bool(
|
||||
lknpd_service
|
||||
and lknpd_service.configured
|
||||
and payment_info_from_webhook.get("paid") is True
|
||||
and payment_info_from_webhook.get("status") == "succeeded"
|
||||
and payment_before_update
|
||||
and payment_before_update.status != "succeeded"
|
||||
)
|
||||
# Try to capture and save payment method for future charges if available
|
||||
try:
|
||||
payment_method = payment_info_from_webhook.get("payment_method")
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
||||
if settings.yookassa_autopayments_active and isinstance(payment_method, dict) and payment_method.get("saved", False):
|
||||
pm_id = payment_method.get("id")
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
@@ -159,7 +291,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
# Wallets, SBP, etc. — use provided title/type; no last4
|
||||
# Wallets, SBP, etc. - use provided title/type; no last4
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
|
||||
@@ -170,48 +302,87 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm_id,
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
||||
except Exception:
|
||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=payment_db_id,
|
||||
new_status=payment_info_from_webhook.get("status", "succeeded"),
|
||||
yk_payment_id=yk_payment_id_from_hook)
|
||||
if not updated_payment_record:
|
||||
logging.error(
|
||||
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
|
||||
)
|
||||
raise Exception(
|
||||
f"DB Error: Could not update payment record {payment_db_id}")
|
||||
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
subscription_months,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa")
|
||||
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
|
||||
try:
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
except Exception:
|
||||
previous_status = payment_before_update.status if payment_before_update else "pending_yookassa"
|
||||
await payment_dal.rollback_provider_payment_processing(
|
||||
session,
|
||||
payment_db_id,
|
||||
rollback_status=previous_status,
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
logging.exception(
|
||||
"Failed to activate subscription for payment %s; rolled back payment status for retry",
|
||||
payment_db_id,
|
||||
)
|
||||
return False
|
||||
|
||||
if not activation_details or not activation_details.get('end_date'):
|
||||
logging.error(
|
||||
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
|
||||
)
|
||||
raise Exception(
|
||||
f"Subscription Error: Failed to activate for user {user_id}")
|
||||
previous_status = payment_before_update.status if payment_before_update else "pending_yookassa"
|
||||
await payment_dal.rollback_provider_payment_processing(
|
||||
session,
|
||||
payment_db_id,
|
||||
rollback_status=previous_status,
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
)
|
||||
if not marked:
|
||||
logging.warning(
|
||||
"YooKassa webhook: payment %s could not be atomically marked succeeded after activation",
|
||||
payment_db_id,
|
||||
)
|
||||
return False
|
||||
|
||||
base_subscription_end_date = activation_details['end_date']
|
||||
final_end_date_for_user = base_subscription_end_date
|
||||
applied_promo_bonus_days = activation_details.get(
|
||||
"applied_promo_bonus_days", 0)
|
||||
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
subscription_months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus_info = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation or int(subscription_months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||
if referral_bonus_info and referral_bonus_info.get(
|
||||
"referee_new_end_date"):
|
||||
@@ -224,19 +395,56 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
|
||||
traffic_label = (
|
||||
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
|
||||
)
|
||||
if should_send_lknpd_receipt:
|
||||
receipt_item_name = payment_info_from_webhook.get("description")
|
||||
if not receipt_item_name:
|
||||
if sale_mode == "traffic":
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
|
||||
else:
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
|
||||
try:
|
||||
await lknpd_service.create_income_receipt(
|
||||
item_name=receipt_item_name,
|
||||
amount=payment_value,
|
||||
quantity=1.0,
|
||||
operation_time=datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send LKNPD receipt for payment %s",
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
config_link_display, connect_button_url = await prepare_config_links(
|
||||
settings, activation_details.get("subscription_url") if activation_details else None
|
||||
)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
# For auto-renew charges, avoid re-sending config link; send concise message
|
||||
if is_auto_renew and final_end_date_for_user:
|
||||
if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"yookassa_auto_renewal",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
)
|
||||
details_markup = None
|
||||
else:
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
elif sale_mode == "traffic":
|
||||
details_message = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "-",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
else:
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
@@ -251,27 +459,27 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif final_end_date_for_user:
|
||||
details_message = _(
|
||||
"payment_successful_full",
|
||||
months=subscription_months,
|
||||
months=int(subscription_months),
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
@@ -280,7 +488,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang, i18n, settings, config_link, preserve_message=True
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
@@ -302,14 +515,17 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=subscription_months,
|
||||
currency="RUB",
|
||||
months=int(subscription_months) if sale_mode != "traffic" else 0,
|
||||
payment_provider="yookassa", # This is specifically for YooKassa webhook
|
||||
username=user.username if user else None
|
||||
username=user.username if user else None,
|
||||
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send payment notification: {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e_process:
|
||||
logging.error(
|
||||
f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
|
||||
@@ -376,9 +592,11 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
i18n_instance: JsonI18n = request.app['i18n']
|
||||
settings: Settings = request.app['settings']
|
||||
panel_service: PanelApiService = request.app['panel_service']
|
||||
yookassa_service: Optional[YooKassaService] = request.app.get('yookassa_service')
|
||||
subscription_service: SubscriptionService = request.app[
|
||||
'subscription_service']
|
||||
referral_service: ReferralService = request.app['referral_service']
|
||||
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
|
||||
async_session_factory: sessionmaker = request.app[
|
||||
'async_session_factory']
|
||||
except KeyError as e_app_ctx:
|
||||
@@ -406,7 +624,7 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
logging.error(
|
||||
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process."
|
||||
)
|
||||
return web.Response(status=200, text="ok_error_no_metadata")
|
||||
return web.Response(status=200, text="yookassa_missing_metadata")
|
||||
|
||||
# Safely extract payment_method details (SDK objects may not have to_dict)
|
||||
pm_obj = getattr(payment_data_from_notification, 'payment_method', None)
|
||||
@@ -461,115 +679,231 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
"payment_method": pm_dict,
|
||||
}
|
||||
|
||||
async with payment_processing_lock:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
||||
if payment_dict_for_processing.get(
|
||||
"paid") and payment_dict_for_processing.get(
|
||||
"status") == "succeeded":
|
||||
await process_successful_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings, panel_service,
|
||||
subscription_service, referral_service)
|
||||
await session.commit()
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
|
||||
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
|
||||
f"paid='{payment_dict_for_processing.get('paid')}'"
|
||||
)
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
||||
await process_cancelled_payment(
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
# Defense-in-depth: for state mutations beyond succeeded, verify against provider API.
|
||||
if notification_object.event in {
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED,
|
||||
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE,
|
||||
}:
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.critical(
|
||||
"YooKassa webhook rejected: verification service is not configured for event %s (payment_id=%s)",
|
||||
notification_object.event,
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_verification_required")
|
||||
|
||||
provider_payment_info = await yookassa_service.get_payment_info(
|
||||
payment_dict_for_processing.get("id")
|
||||
)
|
||||
if not provider_payment_info:
|
||||
logging.error(
|
||||
"YooKassa webhook verification failed: payment %s not found via provider API",
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_verification_failed")
|
||||
|
||||
provider_status = str(provider_payment_info.get("status") or "")
|
||||
provider_paid = bool(provider_payment_info.get("paid"))
|
||||
provider_metadata_raw = provider_payment_info.get("metadata") or {}
|
||||
provider_metadata = provider_metadata_raw if isinstance(provider_metadata_raw, dict) else {}
|
||||
|
||||
payment_dict_for_processing["status"] = provider_status or payment_dict_for_processing.get("status")
|
||||
payment_dict_for_processing["paid"] = provider_paid
|
||||
payment_dict_for_processing["metadata"] = dict(provider_metadata)
|
||||
|
||||
provider_amount_value = provider_payment_info.get("amount_value")
|
||||
provider_amount_currency = provider_payment_info.get("amount_currency")
|
||||
if provider_amount_value is not None and provider_amount_currency:
|
||||
payment_dict_for_processing["amount"] = {
|
||||
"value": str(provider_amount_value),
|
||||
"currency": str(provider_amount_currency),
|
||||
}
|
||||
|
||||
provider_pm = provider_payment_info.get("payment_method")
|
||||
if isinstance(provider_pm, dict) and provider_pm.get("id"):
|
||||
# Use provider payment_method as authoritative.
|
||||
payment_dict_for_processing["payment_method"] = provider_pm
|
||||
|
||||
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.critical(
|
||||
"YooKassa webhook rejected: verification service is not configured for succeeded event (payment_id=%s)",
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_verification_required")
|
||||
|
||||
if payment_dict_for_processing.get(
|
||||
"paid") and payment_dict_for_processing.get(
|
||||
"status") == "succeeded":
|
||||
processed = await process_successful_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings)
|
||||
i18n_instance, settings, panel_service,
|
||||
subscription_service, referral_service,
|
||||
yookassa_service,
|
||||
lknpd_service)
|
||||
if not processed:
|
||||
metadata_for_result = payment_dict_for_processing.get("metadata") or {}
|
||||
payment_db_id_raw = metadata_for_result.get("payment_db_id")
|
||||
payment_db_id_for_check = None
|
||||
if isinstance(payment_db_id_raw, int):
|
||||
payment_db_id_for_check = payment_db_id_raw
|
||||
elif isinstance(payment_db_id_raw, str) and payment_db_id_raw.isdigit():
|
||||
payment_db_id_for_check = int(payment_db_id_raw)
|
||||
|
||||
terminal_failure_recorded = False
|
||||
if payment_db_id_for_check is not None:
|
||||
payment_after_processing = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id_for_check,
|
||||
)
|
||||
terminal_failure_recorded = bool(
|
||||
payment_after_processing
|
||||
and isinstance(payment_after_processing.status, str)
|
||||
and payment_after_processing.status.startswith("failed")
|
||||
)
|
||||
|
||||
if terminal_failure_recorded:
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to commit failure status for YooKassa payment %s",
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_processing_failed_retry")
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
await session.rollback()
|
||||
logging.warning(
|
||||
"YooKassa payment %s processing returned non-terminal failure; responding 503 for retry",
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_processing_failed_retry")
|
||||
await session.commit()
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1":
|
||||
try:
|
||||
user_id_str = metadata.get("user_id")
|
||||
if user_id_str and user_id_str.isdigit():
|
||||
user_id = int(user_id_str)
|
||||
payment_method = payment_dict_for_processing.get("payment_method")
|
||||
if isinstance(payment_method, dict) and payment_method.get("id"):
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
card = payment_method.get("card") or {}
|
||||
account_number = payment_method.get("account_number") or payment_method.get("account")
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
# Normalize wallet display name to avoid leaking full account from title
|
||||
display_network = "YooMoney"
|
||||
if isinstance(account_number, str) and len(account_number) >= 4:
|
||||
display_last4 = account_number[-4:]
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
|
||||
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
|
||||
f"paid='{payment_dict_for_processing.get('paid')}'"
|
||||
)
|
||||
await session.rollback()
|
||||
return web.Response(status=503, text="yookassa_invalid_succeeded_payload")
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
||||
if payment_dict_for_processing.get("status") not in {"canceled", "cancelled"}:
|
||||
logging.error(
|
||||
"YooKassa webhook rejected: canceled event status mismatch for payment %s (status=%s)",
|
||||
payment_dict_for_processing.get("id"),
|
||||
payment_dict_for_processing.get("status"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_invalid_canceled_payload")
|
||||
await process_cancelled_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings)
|
||||
await session.commit()
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
|
||||
if payment_dict_for_processing.get("status") != "waiting_for_capture":
|
||||
logging.error(
|
||||
"YooKassa webhook rejected: waiting_for_capture event status mismatch for payment %s (status=%s)",
|
||||
payment_dict_for_processing.get("id"),
|
||||
payment_dict_for_processing.get("status"),
|
||||
)
|
||||
return web.Response(status=503, text="yookassa_invalid_waiting_payload")
|
||||
try:
|
||||
user_id_str = metadata.get("user_id")
|
||||
if user_id_str and user_id_str.isdigit():
|
||||
user_id = int(user_id_str)
|
||||
payment_method = payment_dict_for_processing.get("payment_method")
|
||||
if isinstance(payment_method, dict) and payment_method.get("id"):
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
|
||||
# Support both webhook shape (nested card) and provider shape (card_last4)
|
||||
last4_val = None
|
||||
card = payment_method.get("card") or {}
|
||||
if isinstance(card, dict) and card.get("last4"):
|
||||
last4_val = card.get("last4")
|
||||
if not last4_val:
|
||||
last4_val = payment_method.get("card_last4")
|
||||
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = title or "Card"
|
||||
display_last4 = last4_val
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
display_network = "YooMoney"
|
||||
display_last4 = last4_val
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = last4_val
|
||||
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=payment_method.get("id"),
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
await session.commit()
|
||||
# Save multi-card entry and mark default if first
|
||||
try:
|
||||
from db.dal import user_billing_dal as ub
|
||||
await ub.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=payment_method.get("id"),
|
||||
provider_payment_method_id=payment_method.get("id"),
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
await session.commit()
|
||||
# Save multi-card entry and mark default if first
|
||||
try:
|
||||
from db.dal import user_billing_dal as ub
|
||||
await ub.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=payment_method.get("id"),
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# Notify user about successful binding with Back button
|
||||
try:
|
||||
# Use user's DB language for bind success notification
|
||||
i18n_lang = settings.DEFAULT_LANGUAGE
|
||||
from db.dal import user_dal
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if db_user and db_user.language_code:
|
||||
i18n_lang = db_user.language_code
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=_("payment_method_bound_success"),
|
||||
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Attempt to cancel the authorization to avoid charge hold
|
||||
try:
|
||||
yk: YooKassaService = request.app.get('yookassa_service')
|
||||
if yk:
|
||||
await yk.cancel_payment(payment_dict_for_processing.get("id"))
|
||||
except Exception:
|
||||
logging.exception("Failed to cancel bind-only payment auth")
|
||||
except Exception:
|
||||
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
|
||||
except Exception as e_webhook_db_processing:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Error processing YooKassa webhook event '{notification_object.event}' "
|
||||
f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}",
|
||||
exc_info=True)
|
||||
return web.Response(
|
||||
status=200, text="ok_internal_processing_error_logged")
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# Notify user about successful binding with Back button
|
||||
try:
|
||||
# Use user's DB language for bind success notification
|
||||
i18n_lang = settings.DEFAULT_LANGUAGE
|
||||
from db.dal import user_dal
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if db_user and db_user.language_code:
|
||||
i18n_lang = db_user.language_code
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=_("payment_method_bound_success"),
|
||||
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug(
|
||||
"Failed to notify user %s about payment method binding: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
# Attempt to cancel the authorization to avoid charge hold
|
||||
try:
|
||||
yk: YooKassaService = request.app.get('yookassa_service')
|
||||
if yk:
|
||||
await yk.cancel_payment(payment_dict_for_processing.get("id"))
|
||||
except Exception:
|
||||
logging.exception("Failed to cancel bind-only payment auth")
|
||||
except Exception:
|
||||
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
|
||||
except Exception as e_webhook_db_processing:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Error processing YooKassa webhook event '{notification_object.event}' "
|
||||
f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}",
|
||||
exc_info=True)
|
||||
return web.Response(
|
||||
status=503, text="yookassa_processing_error_retry")
|
||||
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
@@ -580,5 +914,5 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
logging.error(
|
||||
f"YooKassa Webhook general processing error: {e_general_webhook}",
|
||||
exc_info=True)
|
||||
return web.Response(status=200,
|
||||
text="ok_general_internal_error_logged")
|
||||
return web.Response(status=503,
|
||||
text="yookassa_general_error_retry")
|
||||
|
||||
@@ -123,38 +123,90 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
code=hcode(code_input.upper()))
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
else:
|
||||
|
||||
# Try as BONUS code first (existing behavior)
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user.id, code_input, current_lang)
|
||||
|
||||
if success:
|
||||
# Bonus code success
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' successfully applied for user {user.id}."
|
||||
f"Bonus promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
active = await subscription_service.get_active_subscription_details(session, user.id)
|
||||
config_link = active.get("config_link") if active else None
|
||||
config_link = config_link or _("config_link_not_available")
|
||||
config_link_display = active.get("config_link") if active else None
|
||||
connect_button_url = active.get("connect_button_url") if active else None
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
response_to_user_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
||||
)
|
||||
response_to_user_text = result
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang, i18n
|
||||
# Bonus code failed, try as DISCOUNT code
|
||||
success_discount, result_discount = await promo_code_service.apply_discount_promo_code(
|
||||
session, user.id, code_input, current_lang
|
||||
)
|
||||
|
||||
if success_discount:
|
||||
# Discount code success
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Discount promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
discount_pct = result_discount # Returns percentage
|
||||
|
||||
# Send notification about discount promo activation
|
||||
if settings.LOG_PROMO_ACTIVATIONS:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_discount_promo_activation(
|
||||
user_id=user.id,
|
||||
promo_code=code_input.upper(),
|
||||
discount_percentage=discount_pct,
|
||||
username=user.username
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send discount promo activation notification: {e}")
|
||||
|
||||
response_to_user_text = _(
|
||||
"discount_promo_code_applied_success",
|
||||
code=hcode(code_input.upper()),
|
||||
discount=discount_pct
|
||||
)
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
else:
|
||||
# Both failed
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. "
|
||||
f"Bonus reason: {result}. Discount reason: {result_discount}"
|
||||
)
|
||||
bonus_not_found_text = _(
|
||||
"promo_code_not_found", code=code_input.upper()
|
||||
)
|
||||
discount_not_found_text = _(
|
||||
"promo_code_not_found_or_not_discount", code=code_input.upper()
|
||||
)
|
||||
if result != bonus_not_found_text and result_discount == discount_not_found_text:
|
||||
response_to_user_text = result
|
||||
else:
|
||||
response_to_user_text = result_discount # Prefer the discount attempt error
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang, i18n
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
response_to_user_text,
|
||||
reply_markup=reply_markup,
|
||||
|
||||
@@ -43,6 +43,15 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if not settings.REFERRAL_ENABLED:
|
||||
await target_message_obj.answer(
|
||||
_("referral_no_bonuses_configured"),
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
@@ -60,28 +69,40 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
return
|
||||
|
||||
inviter_user_id = event.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(
|
||||
bot_username, inviter_user_id)
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, inviter_user_id)
|
||||
|
||||
if not referral_link:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s (probably missing DB record).",
|
||||
inviter_user_id,
|
||||
)
|
||||
await target_message_obj.answer(_("error_generating_referral_link"))
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
return
|
||||
|
||||
bonus_info_parts = []
|
||||
if settings.subscription_options:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
bonus_details_str = _("referral_not_available_for_traffic")
|
||||
else:
|
||||
if settings.subscription_options:
|
||||
for months_period_key, _price in sorted(
|
||||
settings.subscription_options.items()):
|
||||
|
||||
for months_period_key, _price in sorted(
|
||||
settings.subscription_options.items()):
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_("referral_bonus_per_period",
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None else _("no_bonus_placeholder")))
|
||||
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_("referral_bonus_per_period",
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None else _("no_bonus_placeholder")))
|
||||
|
||||
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
|
||||
"referral_no_bonuses_configured")
|
||||
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
|
||||
"referral_no_bonuses_configured")
|
||||
|
||||
# Get referral statistics
|
||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||
@@ -124,6 +145,9 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if action == "share_message":
|
||||
if not settings.REFERRAL_ENABLED:
|
||||
await callback.answer(_("referral_no_bonuses_configured"), show_alert=True)
|
||||
return
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
@@ -132,7 +156,16 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
||||
return
|
||||
|
||||
inviter_user_id = callback.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id)
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, inviter_user_id)
|
||||
|
||||
if not referral_link:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s via inline button.",
|
||||
inviter_user_id,
|
||||
)
|
||||
await callback.answer(_("error_generating_referral_link"), show_alert=True)
|
||||
return
|
||||
|
||||
friend_message = _("referral_friend_message", referral_link=referral_link)
|
||||
|
||||
|
||||
+150
-48
@@ -48,13 +48,13 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
try:
|
||||
await target_event.answer(err_msg_fallback, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
elif isinstance(target_event, types.Message):
|
||||
try:
|
||||
await target_event.answer(err_msg_fallback)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
return
|
||||
|
||||
|
||||
@@ -102,8 +102,8 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
try:
|
||||
await target_event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
except Exception as e_send_edit:
|
||||
logging.warning(
|
||||
f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}."
|
||||
@@ -119,8 +119,8 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
try:
|
||||
await target_event.answer(
|
||||
_("error_occurred_try_again") if is_edit else None)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
|
||||
|
||||
async def ensure_required_channel_subscription(
|
||||
@@ -134,8 +134,15 @@ async def ensure_required_channel_subscription(
|
||||
Verify that the user is a member of the required channel (if configured).
|
||||
Returns True when access can proceed, False when user must subscribe first.
|
||||
"""
|
||||
if not settings.REQUIRED_CHANNEL_SUBSCRIBE_TO_USE:
|
||||
return True
|
||||
|
||||
required_channel_id = settings.REQUIRED_CHANNEL_ID
|
||||
if not required_channel_id:
|
||||
logging.warning(
|
||||
"REQUIRED_CHANNEL_SUBSCRIBE_TO_USE is enabled but REQUIRED_CHANNEL_ID is not set. "
|
||||
"Channel gate is skipped."
|
||||
)
|
||||
return True
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
@@ -213,13 +220,13 @@ async def ensure_required_channel_subscription(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(error_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
if message_obj:
|
||||
try:
|
||||
await message_obj.answer(error_text)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
else:
|
||||
await event.answer(error_text)
|
||||
return False
|
||||
@@ -234,13 +241,13 @@ async def ensure_required_channel_subscription(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(error_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
if message_obj:
|
||||
try:
|
||||
await message_obj.answer(error_text)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
else:
|
||||
await event.answer(error_text)
|
||||
return False
|
||||
@@ -289,12 +296,12 @@ async def ensure_required_channel_subscription(
|
||||
if keyboard is None and message_obj:
|
||||
try:
|
||||
await message_obj.answer(prompt_text)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
try:
|
||||
await event.answer(prompt_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
else:
|
||||
await event.answer(prompt_text, reply_markup=keyboard)
|
||||
|
||||
@@ -302,7 +309,7 @@ async def ensure_required_channel_subscription(
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||
async def start_command_handler(message: types.Message,
|
||||
@@ -327,10 +334,29 @@ async def start_command_handler(message: types.Message,
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
ad_start_param: Optional[str] = None
|
||||
|
||||
if ref_match:
|
||||
potential_referrer_id = int(ref_match.group(1))
|
||||
if await user_dal.get_user_by_id(session, potential_referrer_id):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
if ref_match and settings.REFERRAL_ENABLED:
|
||||
raw_ref_value = ref_match.group(1)
|
||||
if raw_ref_value.isdigit():
|
||||
if settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||
session, potential_referrer_id):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
else:
|
||||
normalized_code = raw_ref_value.strip()
|
||||
if normalized_code and normalized_code[0].lower() == "u":
|
||||
normalized_code = normalized_code[1:]
|
||||
ref_user = None
|
||||
if normalized_code:
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session, normalized_code)
|
||||
if ref_user and ref_user.user_id != user_id:
|
||||
referred_by_user_id = ref_user.user_id
|
||||
elif ref_match and not settings.REFERRAL_ENABLED:
|
||||
logging.info(
|
||||
"User %s started with referral parameter while referral system is disabled.",
|
||||
user_id,
|
||||
)
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
@@ -357,6 +383,17 @@ async def start_command_handler(message: types.Message,
|
||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||
|
||||
if created:
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as commit_error:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to commit new user {user_id}: {commit_error}",
|
||||
exc_info=True,
|
||||
)
|
||||
await message.answer(_("error_occurred_processing_request"))
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
||||
)
|
||||
@@ -425,8 +462,8 @@ async def start_command_handler(message: types.Message,
|
||||
logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}")
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
|
||||
if not await ensure_required_channel_subscription(message, settings, i18n,
|
||||
current_lang, session,
|
||||
@@ -440,43 +477,88 @@ async def start_command_handler(message: types.Message,
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
if promo_code_to_apply:
|
||||
try:
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
|
||||
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
success_bonus, bonus_result = await promo_code_service.apply_promo_code(
|
||||
session, user_id, promo_code_to_apply, current_lang
|
||||
)
|
||||
|
||||
if success:
|
||||
if success_bonus:
|
||||
await session.commit()
|
||||
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
|
||||
|
||||
# Get updated subscription details
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
config_link = active.get("config_link") if active else None
|
||||
config_link = config_link or _("config_link_not_available")
|
||||
config_link_display = active.get("config_link") if active else None
|
||||
connect_button_url = active.get("connect_button_url") if active else None
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
new_end_date = bonus_result if isinstance(bonus_result, datetime) else None
|
||||
|
||||
promo_success_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
await message.answer(
|
||||
promo_success_text,
|
||||
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
|
||||
reply_markup=get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Don't show main menu if promo was successfully applied
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
|
||||
# Continue to show main menu if promo failed
|
||||
|
||||
success_discount, discount_result = await promo_code_service.apply_discount_promo_code(
|
||||
session, user_id, promo_code_to_apply, current_lang
|
||||
)
|
||||
|
||||
if success_discount:
|
||||
await session.commit()
|
||||
discount_pct = discount_result if isinstance(discount_result, int) else 0
|
||||
logging.info(
|
||||
f"Auto-applied discount promo code '{promo_code_to_apply}' for user {user_id}: {discount_pct}%"
|
||||
)
|
||||
|
||||
if settings.LOG_PROMO_ACTIVATIONS:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(message.bot, settings, i18n)
|
||||
await notification_service.notify_discount_promo_activation(
|
||||
user_id=user_id,
|
||||
promo_code=promo_code_to_apply.upper(),
|
||||
discount_percentage=discount_pct,
|
||||
username=user.username,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logging.error(f"Failed to send discount promo activation notification: {notify_error}")
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
|
||||
await message.answer(
|
||||
_(
|
||||
"discount_promo_code_applied_success",
|
||||
code=hd.quote(promo_code_to_apply.upper()),
|
||||
discount=discount_pct,
|
||||
),
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
await session.rollback()
|
||||
logging.warning(
|
||||
f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}. "
|
||||
f"Bonus reason: {bonus_result}. Discount reason: {discount_result}"
|
||||
)
|
||||
# Continue to show main menu if promo failed
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
|
||||
@@ -520,7 +602,15 @@ async def verify_channel_subscription_callback(
|
||||
welcome_text = _(key="welcome",
|
||||
user_name=hd.quote(callback.from_user.full_name))
|
||||
if callback.message:
|
||||
await callback.message.answer(welcome_text)
|
||||
try:
|
||||
await callback.message.edit_text(welcome_text)
|
||||
except Exception as welcome_edit_error:
|
||||
logging.debug(
|
||||
"Failed to edit subscription prompt to welcome for user %s: %s",
|
||||
callback.from_user.id,
|
||||
welcome_edit_error,
|
||||
)
|
||||
await callback.message.answer(welcome_text)
|
||||
else:
|
||||
fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
|
||||
if fallback_bot:
|
||||
@@ -530,15 +620,22 @@ async def verify_channel_subscription_callback(
|
||||
try:
|
||||
await callback.answer(_(key="channel_subscription_verified_success"),
|
||||
show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/start.py: %s", exc)
|
||||
|
||||
await send_main_menu(callback,
|
||||
menu_target_event: Union[types.Message, types.CallbackQuery] = callback
|
||||
should_edit_menu_message = bool(callback.message)
|
||||
|
||||
if not settings.DISABLE_WELCOME_MESSAGE and callback.message:
|
||||
menu_target_event = callback.message
|
||||
should_edit_menu_message = False
|
||||
|
||||
await send_main_menu(menu_target_event,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=bool(callback.message))
|
||||
is_edit=should_edit_menu_message)
|
||||
|
||||
|
||||
@router.message(Command("language"))
|
||||
@@ -631,6 +728,10 @@ async def main_action_callback_handler(
|
||||
promo_code_service: PromoCodeService, session: AsyncSession):
|
||||
action = callback.data.split(":")[1]
|
||||
user_id = callback.from_user.id
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
from . import subscription as user_subscription_handlers
|
||||
from . import referral as user_referral_handlers
|
||||
@@ -643,7 +744,7 @@ async def main_action_callback_handler(
|
||||
|
||||
if action == "subscribe":
|
||||
await user_subscription_handlers.display_subscription_options(
|
||||
callback, i18n_data, settings, session)
|
||||
callback, i18n_data, settings, session, promo_code_service=promo_code_service)
|
||||
elif action == "my_subscription":
|
||||
await user_subscription_handlers.my_subscription_command_handler(
|
||||
callback, i18n_data, settings, panel_service, subscription_service,
|
||||
@@ -653,6 +754,10 @@ async def main_action_callback_handler(
|
||||
callback, i18n_data, settings, panel_service, subscription_service,
|
||||
session, bot)
|
||||
elif action == "referral":
|
||||
if not settings.REFERRAL_ENABLED:
|
||||
await callback.answer(_("referral_no_bonuses_configured"),
|
||||
show_alert=True)
|
||||
return
|
||||
await user_referral_handlers.referral_command_handler(
|
||||
callback, settings, i18n_data, referral_service, bot, session)
|
||||
elif action == "apply_promo":
|
||||
@@ -679,7 +784,4 @@ async def main_action_callback_handler(
|
||||
session,
|
||||
is_edit=False)
|
||||
else:
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(
|
||||
i18n_data.get("current_language"), key, **kw) if i18n else key
|
||||
await callback.answer(_("main_menu_unknown_action"), show_alert=True)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
@@ -16,13 +18,35 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import subscription_dal
|
||||
from db.dal import subscription_dal, user_billing_dal
|
||||
from db.models import Subscription
|
||||
|
||||
router = Router(name="user_subscription_core_router")
|
||||
|
||||
|
||||
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
|
||||
"""Trim HWID for button text to keep within Telegram limits."""
|
||||
if not hwid:
|
||||
return "-"
|
||||
hwid_str = str(hwid)
|
||||
if len(hwid_str) <= max_length:
|
||||
return hwid_str
|
||||
return f"{hwid_str[:8]}...{hwid_str[-6:]}"
|
||||
|
||||
|
||||
def _hwid_callback_token(hwid: Optional[str]) -> str:
|
||||
"""Stable short token for callback_data; avoids 64b limit with raw HWID."""
|
||||
hwid_str = str(hwid or "")
|
||||
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
async def display_subscription_options(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
@@ -33,28 +57,69 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(err_msg, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(err_msg)
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
|
||||
currency_symbol_val = "RUB"
|
||||
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||
|
||||
reply_markup = (
|
||||
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
|
||||
if settings.subscription_options
|
||||
else get_back_to_main_menu_markup(current_lang, i18n)
|
||||
)
|
||||
options_are_stars = False
|
||||
if traffic_mode:
|
||||
if traffic_packages:
|
||||
options = traffic_packages
|
||||
elif stars_traffic_packages:
|
||||
options = stars_traffic_packages
|
||||
currency_symbol_val = "⭐"
|
||||
options_are_stars = True
|
||||
else:
|
||||
options = {}
|
||||
else:
|
||||
options = settings.subscription_options
|
||||
|
||||
display_options = options
|
||||
if options and promo_code_service:
|
||||
try:
|
||||
active_discount_info = await promo_code_service.get_user_active_discount(
|
||||
session, event.from_user.id
|
||||
)
|
||||
except Exception:
|
||||
active_discount_info = None
|
||||
if active_discount_info:
|
||||
discount_pct, _promo_code = active_discount_info
|
||||
discounted_options = {}
|
||||
for period, price in options.items():
|
||||
if price is None:
|
||||
discounted_options[period] = price
|
||||
else:
|
||||
discounted_price, _ = promo_code_service.calculate_discounted_price(
|
||||
price, discount_pct
|
||||
)
|
||||
if options_are_stars:
|
||||
discounted_price = math.ceil(discounted_price)
|
||||
discounted_options[period] = discounted_price
|
||||
display_options = discounted_options
|
||||
|
||||
if display_options:
|
||||
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
|
||||
reply_markup = get_subscription_options_keyboard(
|
||||
display_options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
|
||||
)
|
||||
else:
|
||||
text_content = get_text("no_subscription_options_available")
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
@@ -64,15 +129,23 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
||||
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
else:
|
||||
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:subscribe")
|
||||
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
await display_subscription_options(callback, i18n_data, settings, session)
|
||||
async def reshow_subscription_options_callback(
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
await display_subscription_options(
|
||||
callback, i18n_data, settings, session, promo_code_service=promo_code_service
|
||||
)
|
||||
|
||||
|
||||
async def my_subscription_command_handler(
|
||||
@@ -104,7 +177,7 @@ async def my_subscription_command_handler(
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
|
||||
text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
@@ -113,8 +186,8 @@ async def my_subscription_command_handler(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=kb)
|
||||
except Exception:
|
||||
@@ -125,28 +198,53 @@ async def my_subscription_command_handler(
|
||||
|
||||
end_date = active.get("end_date")
|
||||
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
|
||||
tribute_hint = ""
|
||||
if active.get("status_from_panel", "").lower() == "active":
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||
if local_sub:
|
||||
if local_sub.provider == "tribute":
|
||||
link = None
|
||||
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
|
||||
tribute_hint = "\n\n" + (
|
||||
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
|
||||
)
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False))
|
||||
config_link_display = active.get("config_link")
|
||||
connect_button_url = active.get("connect_button_url")
|
||||
config_link_value = config_link_display or get_text("config_link_not_available")
|
||||
def _fmt_gb(val: Optional[float]) -> str:
|
||||
if val is None:
|
||||
return get_text("traffic_na")
|
||||
try:
|
||||
if isinstance(val, (int, float)):
|
||||
val_gb = float(val) / (2**30)
|
||||
return f"{val_gb:.2f} GB"
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return str(val)
|
||||
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||
),
|
||||
)
|
||||
if traffic_mode:
|
||||
limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
|
||||
used_display = _fmt_gb(active.get("traffic_used_bytes"))
|
||||
remaining_display = get_text("traffic_na")
|
||||
try:
|
||||
limit_val = active.get("traffic_limit_bytes") or 0
|
||||
used_val = active.get("traffic_used_bytes") or 0
|
||||
remaining_val = max(0, float(limit_val) - float(used_val))
|
||||
remaining_display = _fmt_gb(remaining_val)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
text = get_text(
|
||||
"my_traffic_details",
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else get_text("traffic_no_expiry"),
|
||||
traffic_limit=limit_display,
|
||||
traffic_used=used_display,
|
||||
traffic_left=remaining_display,
|
||||
config_link=config_link_value,
|
||||
)
|
||||
else:
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=config_link_value,
|
||||
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||
),
|
||||
)
|
||||
|
||||
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
kb = base_markup.inline_keyboard
|
||||
@@ -164,7 +262,7 @@ async def my_subscription_command_handler(
|
||||
)
|
||||
])
|
||||
else:
|
||||
cfg_link_val = (active or {}).get("config_link")
|
||||
cfg_link_val = connect_button_url or config_link_display
|
||||
if cfg_link_val:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -224,8 +322,8 @@ async def my_subscription_command_handler(
|
||||
)
|
||||
])
|
||||
|
||||
# 2) Auto-renew toggle (if supported and not tribute)
|
||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
# 2) Auto-renew toggle (YooKassa only)
|
||||
if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active:
|
||||
toggle_text = (
|
||||
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
|
||||
)
|
||||
@@ -237,34 +335,34 @@ async def my_subscription_command_handler(
|
||||
])
|
||||
|
||||
# 3) Payment methods management (when autopayments enabled)
|
||||
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not traffic_mode and settings.yookassa_autopayments_active:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
|
||||
])
|
||||
|
||||
if prepend_rows:
|
||||
kb = prepend_rows + kb
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except Exception:
|
||||
await bot.send_message(
|
||||
chat_id=target.chat.id,
|
||||
text=text + tribute_hint,
|
||||
text=text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
else:
|
||||
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:my_devices")
|
||||
@@ -291,8 +389,8 @@ async def my_devices_command_handler(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
else:
|
||||
await target.answer(get_text("my_devices_feature_disabled"))
|
||||
return
|
||||
@@ -304,8 +402,8 @@ async def my_devices_command_handler(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(message, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
else:
|
||||
await target.answer(message)
|
||||
return
|
||||
@@ -315,12 +413,18 @@ async def my_devices_command_handler(
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("no_devices_found"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
else:
|
||||
await target.answer(get_text("no_devices_found"))
|
||||
return
|
||||
|
||||
devices_list_raw = []
|
||||
if isinstance(devices, dict):
|
||||
devices_list_raw = devices.get("devices") or []
|
||||
elif isinstance(devices, list):
|
||||
devices_list_raw = devices
|
||||
|
||||
max_devices_value = active.get("max_devices")
|
||||
max_devices_display = get_text("devices_unlimited_label")
|
||||
if max_devices_value not in (None, 0):
|
||||
@@ -331,19 +435,22 @@ async def my_devices_command_handler(
|
||||
except (TypeError, ValueError):
|
||||
max_devices_display = str(max_devices_value)
|
||||
|
||||
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
|
||||
if not devices_list_raw:
|
||||
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
|
||||
else:
|
||||
devices_list = []
|
||||
current_devices = len(devices.get('devices') or [])
|
||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||
current_devices = len(devices_list_raw)
|
||||
for index, device in enumerate(devices_list_raw, start=1):
|
||||
device_model = device.get('deviceModel') or None
|
||||
platform = device.get('platform') or None
|
||||
user_agent = device.get('userAgent') or None
|
||||
os_version = device.get('osVersion') or None
|
||||
created_at = device.get('createdAt')
|
||||
hwid = device.get('hwid')
|
||||
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
|
||||
try:
|
||||
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") if created_at else "-"
|
||||
except Exception:
|
||||
created_at_str = str(created_at)
|
||||
|
||||
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
|
||||
devices_list.append(device_details)
|
||||
@@ -354,19 +461,22 @@ async def my_devices_command_handler(
|
||||
kb = base_markup.inline_keyboard
|
||||
|
||||
devices_kb = []
|
||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||
for index, device in enumerate(devices_list_raw, start=1):
|
||||
hwid = device.get('hwid')
|
||||
device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index)
|
||||
if not hwid:
|
||||
continue
|
||||
device_button_text = get_text("disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index)
|
||||
hwid_token = _hwid_callback_token(hwid)
|
||||
|
||||
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")])
|
||||
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid_token}")])
|
||||
kb = devices_kb + kb
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=markup)
|
||||
except Exception:
|
||||
@@ -392,24 +502,46 @@ async def disconnect_device_handler(
|
||||
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, hwid = callback.data.split(":", 1)
|
||||
_, hwid_token = callback.data.split(":", 1)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
|
||||
if not active:
|
||||
if not active or not active.get("user_id"):
|
||||
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||
return
|
||||
|
||||
devices = await panel_service.get_user_devices(active.get("user_id"))
|
||||
if not devices:
|
||||
await callback.answer(get_text("no_devices_found"), show_alert=True)
|
||||
return
|
||||
|
||||
devices_list_raw = []
|
||||
if isinstance(devices, dict):
|
||||
devices_list_raw = devices.get("devices") or []
|
||||
elif isinstance(devices, list):
|
||||
devices_list_raw = devices
|
||||
|
||||
hwid = None
|
||||
for device in devices_list_raw:
|
||||
hwid_candidate = device.get("hwid")
|
||||
if hwid_candidate and _hwid_callback_token(hwid_candidate) == hwid_token:
|
||||
hwid = hwid_candidate
|
||||
break
|
||||
|
||||
if not hwid:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
|
||||
if not success:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
@@ -417,8 +549,8 @@ async def disconnect_device_handler(
|
||||
await session.commit()
|
||||
try:
|
||||
await callback.answer(get_text("device_disconnected"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@@ -444,17 +576,25 @@ async def toggle_autorenew_handler(
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
sub = await session.get(Subscription, sub_id)
|
||||
if not sub or sub.user_id != callback.from_user.id:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
if sub.provider != "yookassa":
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if enable:
|
||||
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||
if not has_saved_card:
|
||||
try:
|
||||
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
# Show confirmation popup and inline buttons
|
||||
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
||||
@@ -464,12 +604,12 @@ async def toggle_autorenew_handler(
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(confirm_text, reply_markup=kb)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
|
||||
@@ -494,24 +634,36 @@ async def confirm_autorenew_handler(
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
sub = await session.get(Subscription, sub_id)
|
||||
if not sub or sub.user_id != callback.from_user.id:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
if sub.provider != "yookassa":
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
if enable:
|
||||
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||
if not has_saved_card:
|
||||
try:
|
||||
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
try:
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
|
||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||
await session.commit()
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_updated"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@@ -529,27 +681,27 @@ async def autorenew_cancel_from_webhook_button(
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
# Disable auto-renew on the active subscription (non-tribute)
|
||||
# Disable auto-renew on the active subscription
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
|
||||
if not sub:
|
||||
try:
|
||||
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
if sub.provider == "tribute":
|
||||
if sub.provider != "yookassa":
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
return
|
||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
|
||||
await session.commit()
|
||||
try:
|
||||
await callback.answer(get_text("subscription_autorenew_updated"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/core.py: %s", exc)
|
||||
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Helper функция для применения скидок к платежам
|
||||
Используется всеми платежными обработчиками
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from db.dal import active_discount_dal
|
||||
|
||||
|
||||
async def apply_discount_to_payment(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
original_price: float,
|
||||
promo_code_service=None
|
||||
) -> Tuple[float, Optional[float], Optional[int]]:
|
||||
"""
|
||||
Apply active discount to payment if exists.
|
||||
|
||||
Returns:
|
||||
(final_price, discount_amount, promo_code_id)
|
||||
"""
|
||||
if not promo_code_service:
|
||||
return original_price, None, None
|
||||
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if not active_discount:
|
||||
return original_price, None, None
|
||||
|
||||
# Calculate discounted price
|
||||
final_price, discount_amount = promo_code_service.calculate_discounted_price(
|
||||
original_price, active_discount.discount_percentage
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Applying {active_discount.discount_percentage}% discount to payment for user {user_id}: "
|
||||
f"{original_price} -> {final_price}"
|
||||
)
|
||||
|
||||
return final_price, discount_amount, active_discount.promo_code_id
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,12 +23,12 @@ router = Router(name="user_subscription_payment_methods_router")
|
||||
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
@@ -51,9 +52,9 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
network_name = network or get_text("payment_network_card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
|
||||
for m in methods:
|
||||
@@ -67,20 +68,20 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
|
||||
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:bind")
|
||||
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
@@ -101,20 +102,20 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
|
||||
await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete_confirm"))
|
||||
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
@@ -122,20 +123,20 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
|
||||
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete"))
|
||||
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
@@ -156,8 +157,8 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
try:
|
||||
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
|
||||
deleted = deleted or legacy_deleted
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
await session.commit()
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
@@ -177,9 +178,9 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
@@ -189,27 +190,27 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
|
||||
await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
try:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:view"))
|
||||
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
@@ -239,9 +240,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(sel.card_network, sel.card_last4)
|
||||
@@ -262,14 +263,14 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
lp = result.scalar_one_or_none()
|
||||
if lp and lp.created_at:
|
||||
last_tx = lp.created_at.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
|
||||
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
|
||||
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—"
|
||||
@@ -289,8 +290,8 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
last_payment = result.scalar_one_or_none()
|
||||
if last_payment and last_payment.created_at:
|
||||
last_tx = last_payment.created_at.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
@@ -307,9 +308,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card", default="Card")
|
||||
network_name = network or _("payment_network_card")
|
||||
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or _("payment_network_generic", default="Payment method")
|
||||
network_name = network or _("payment_network_generic")
|
||||
return _("payment_method_generic_title", network=network_name)
|
||||
|
||||
title = _format_pm_title(billing.card_network, billing.card_last4)
|
||||
@@ -317,20 +318,20 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
|
||||
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:history"))
|
||||
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
@@ -371,8 +372,8 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
|
||||
if pm.get("id") == selected_pm_provider_id:
|
||||
filtered.append(p)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
user_payments = filtered
|
||||
|
||||
if not user_payments:
|
||||
@@ -390,8 +391,15 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
|
||||
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||
return
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
|
||||
def _format_item(p: Payment) -> str:
|
||||
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||
if traffic_mode:
|
||||
units_val = p.subscription_duration_months or 0
|
||||
units_display = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
||||
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
|
||||
else:
|
||||
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
|
||||
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||
|
||||
@@ -433,9 +441,9 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card", default="Card")
|
||||
network_name = network or get_text("payment_network_card")
|
||||
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
return get_text("payment_method_generic_title", network=network_name)
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
@@ -452,7 +460,6 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
|
||||
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n))
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payment_methods.py: %s", exc)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_crypto_router")
|
||||
|
||||
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
cryptopay_service: CryptoPayService,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
callback_price_amount = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_amount = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_amount is None:
|
||||
logging.warning(
|
||||
"CryptoPay: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_amount - callback_price_amount) > 0.01:
|
||||
logging.warning(
|
||||
"CryptoPay: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_amount,
|
||||
resolved_price_amount,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
price_amount = resolved_price_amount
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
|
||||
invoice_url = await cryptopay_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_amount,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc)
|
||||
@@ -0,0 +1,259 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_freekassa_router")
|
||||
|
||||
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_fk:"))
|
||||
async def pay_fk_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
freekassa_service: FreeKassaService,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if not freekassa_service or not freekassa_service.configured:
|
||||
logging.error("FreeKassa service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
callback_price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"FreeKassa: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"FreeKassa: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
currency_code = getattr(freekassa_service, "default_currency", None) or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"original_amount": None,
|
||||
"discount_applied": None,
|
||||
"currency": currency_code,
|
||||
"status": "pending_freekassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "freekassa",
|
||||
"promo_code_id": None,
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
success, response_data = await freekassa_service.create_order(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=payment_record.user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=freekassa_service.default_currency,
|
||||
payment_method_id=freekassa_service.payment_method_id,
|
||||
ip_address=freekassa_service.server_ip,
|
||||
extra_params={
|
||||
"us_method": freekassa_service.payment_method_id,
|
||||
},
|
||||
promo_code_service=promo_code_service,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if success:
|
||||
location = response_data.get("location")
|
||||
order_hash = response_data.get("orderHash")
|
||||
order_id_api = response_data.get("orderId")
|
||||
provider_identifier = order_hash or order_id_api
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if location:
|
||||
order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id)
|
||||
order_info_text = get_text(
|
||||
"free_kassa_order_info",
|
||||
order_id=order_identifier_display,
|
||||
date=datetime.now().strftime("%Y-%m-%d"),
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f"{order_info_text}\n\n" + get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
f"{order_info_text}\n\n" + get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"FreeKassa: create_order failed for payment %s with response %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc)
|
||||
@@ -0,0 +1,257 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.platega_service import PlategaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_platega_router")
|
||||
|
||||
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_platega:"))
|
||||
async def pay_platega_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
platega_service: PlategaService,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
if not platega_service or not platega_service.configured:
|
||||
logging.error("Platega service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
callback_price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"Platega: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"Platega: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
currency_code = "RUB"
|
||||
|
||||
# Price is already discounted at payments_subscription.py stage
|
||||
# Service will handle discount metadata if needed
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"original_amount": None,
|
||||
"discount_applied": None,
|
||||
"currency": currency_code,
|
||||
"status": "pending_platega",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "platega",
|
||||
"promo_code_id": None,
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
payload_meta = json.dumps(
|
||||
{
|
||||
"payment_db_id": payment_record.payment_id,
|
||||
"user_id": user_id,
|
||||
"months": months,
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
)
|
||||
|
||||
success, response_data = await platega_service.create_transaction(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
payload=payload_meta,
|
||||
promo_code_service=promo_code_service,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if success:
|
||||
transaction_id = response_data.get("transactionId") or response_data.get("id")
|
||||
redirect_url = (
|
||||
response_data.get("redirect")
|
||||
or response_data.get("url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_status = response_data.get("status", payment_record.status)
|
||||
|
||||
if transaction_id and redirect_url:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(transaction_id),
|
||||
str(provider_status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc)
|
||||
@@ -0,0 +1,246 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_severpay_router")
|
||||
|
||||
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_severpay:"))
|
||||
async def pay_severpay_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
severpay_service: SeverPayService,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
if not severpay_service or not severpay_service.configured:
|
||||
logging.error("SeverPay service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
callback_price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"SeverPay: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"SeverPay: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
currency_code = "RUB"
|
||||
|
||||
# Price is already discounted at payments_subscription.py stage
|
||||
# Service will handle discount metadata if needed
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"original_amount": None,
|
||||
"discount_applied": None,
|
||||
"currency": currency_code,
|
||||
"status": "pending_severpay",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "severpay",
|
||||
"promo_code_id": None,
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
success, response_data = await severpay_service.create_payment(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
promo_code_service=promo_code_service,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if success:
|
||||
payment_link = (
|
||||
response_data.get("url")
|
||||
or response_data.get("payment_url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_identifier = response_data.get("id") or response_data.get("uid")
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if payment_link:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc)
|
||||
@@ -0,0 +1,148 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.stars_service import StarsService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_stars_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
promo_code_service=None,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
return
|
||||
|
||||
if not settings.STARS_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
stars_price = int(float(parts[1]))
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=human_value)
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
|
||||
payment_db_id = await stars_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
stars_price=stars_price,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
|
||||
if payment_db_id:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
"payment_invoice_sent_message_traffic" if sale_mode == "traffic" else "payment_invoice_sent_message",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=get_text("back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{human_value}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=get_text("cancel_button"),
|
||||
callback_data="main_action:subscribe",
|
||||
)
|
||||
],
|
||||
]
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_stars.py: %s", exc)
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await query.answer(ok=True)
|
||||
except Exception as exc:
|
||||
# Nothing else to do here; Telegram will show an error if not answered
|
||||
logging.debug("Failed to answer pre_checkout_query in payments_stars: %s", exc)
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def handle_successful_stars_payment(
|
||||
message: types.Message,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
payload = (message.successful_payment.invoice_payload
|
||||
if message and message.successful_payment else "")
|
||||
try:
|
||||
parts = (payload or "").split(":")
|
||||
payment_db_id = int(parts[0])
|
||||
months = float(parts[1]) if len(parts) > 1 else 0
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except Exception:
|
||||
return
|
||||
|
||||
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
|
||||
await stars_service.process_successful_payment(
|
||||
session=session,
|
||||
message=message,
|
||||
payment_db_id=payment_db_id,
|
||||
months=months,
|
||||
stars_amount=stars_amount,
|
||||
i18n_data=i18n_data,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_selection_router")
|
||||
|
||||
|
||||
async def resolve_fiat_offer_price_for_user(
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
user_id: int,
|
||||
months: float,
|
||||
sale_mode: str,
|
||||
promo_code_service=None,
|
||||
) -> Optional[float]:
|
||||
"""Resolve offer price server-side to prevent callback payload tampering."""
|
||||
price_source = (
|
||||
getattr(settings, "traffic_packages", {}) or {}
|
||||
if sale_mode == "traffic"
|
||||
else (settings.subscription_options or {})
|
||||
)
|
||||
base_price = price_source.get(months)
|
||||
if base_price is None:
|
||||
return None
|
||||
|
||||
resolved_price = float(base_price)
|
||||
if promo_code_service:
|
||||
active_discount_info = await promo_code_service.get_user_active_discount(session, user_id)
|
||||
if active_discount_info:
|
||||
discount_pct, _ = active_discount_info
|
||||
resolved_price, _ = promo_code_service.calculate_discounted_price(
|
||||
resolved_price,
|
||||
discount_pct,
|
||||
)
|
||||
return resolved_price
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
promo_code_service=None, # Injected from dispatcher
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
return
|
||||
|
||||
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||
try:
|
||||
months = float(callback.data.split(":")[-1])
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
return
|
||||
|
||||
price_source = traffic_packages if traffic_mode else settings.subscription_options
|
||||
stars_price_source = stars_traffic_packages if traffic_mode else settings.stars_subscription_options
|
||||
|
||||
price_rub = price_source.get(months)
|
||||
stars_price = stars_price_source.get(months)
|
||||
currency_symbol_val = "RUB"
|
||||
|
||||
# Check for active discount and apply if exists
|
||||
discount_text = ""
|
||||
if promo_code_service and (price_rub is not None or stars_price is not None):
|
||||
active_discount_info = await promo_code_service.get_user_active_discount(
|
||||
session, callback.from_user.id
|
||||
)
|
||||
|
||||
if active_discount_info:
|
||||
discount_pct, promo_code = active_discount_info
|
||||
if price_rub is not None:
|
||||
original_price_rub = price_rub
|
||||
price_rub, discount_amt = promo_code_service.calculate_discounted_price(
|
||||
price_rub, discount_pct
|
||||
)
|
||||
discount_text = get_text(
|
||||
"active_discount_notice",
|
||||
code=promo_code,
|
||||
discount_pct=discount_pct,
|
||||
original_price=original_price_rub,
|
||||
discounted_price=price_rub,
|
||||
discount_amount=discount_amt,
|
||||
currency_symbol=currency_symbol_val,
|
||||
)
|
||||
if stars_price is not None:
|
||||
original_stars_price = stars_price
|
||||
discounted_stars_price, _ = promo_code_service.calculate_discounted_price(
|
||||
float(stars_price), discount_pct
|
||||
)
|
||||
discounted_stars_price = math.ceil(discounted_stars_price)
|
||||
stars_price = discounted_stars_price
|
||||
if not discount_text:
|
||||
discount_amt = original_stars_price - discounted_stars_price
|
||||
discount_text = get_text(
|
||||
"active_discount_notice",
|
||||
code=promo_code,
|
||||
discount_pct=discount_pct,
|
||||
original_price=original_stars_price,
|
||||
discounted_price=discounted_stars_price,
|
||||
discount_amount=discount_amt,
|
||||
currency_symbol="⭐",
|
||||
)
|
||||
|
||||
if price_rub is None:
|
||||
if traffic_mode and not price_source and stars_price is not None:
|
||||
currency_methods_enabled = any(
|
||||
[
|
||||
settings.FREEKASSA_ENABLED,
|
||||
settings.PLATEGA_ENABLED,
|
||||
settings.SEVERPAY_ENABLED,
|
||||
settings.YOOKASSA_ENABLED,
|
||||
settings.CRYPTOPAY_ENABLED,
|
||||
]
|
||||
)
|
||||
if currency_methods_enabled:
|
||||
logging.error(
|
||||
"Currency price missing for traffic option %s while fiat providers are enabled.",
|
||||
months,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
return
|
||||
price_rub = 0.0
|
||||
currency_symbol_val = "⭐"
|
||||
else:
|
||||
logging.error(
|
||||
f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}."
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
return
|
||||
|
||||
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
|
||||
if discount_text:
|
||||
text_content = f"{discount_text}\n\n{text_content}"
|
||||
|
||||
reply_markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
stars_price,
|
||||
currency_symbol_val,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode="traffic" if traffic_mode else "subscription",
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text_content, reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_subscription.py: %s", exc)
|
||||
@@ -0,0 +1,968 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_main_menu_markup,
|
||||
get_payment_url_keyboard,
|
||||
get_yk_autopay_choice_keyboard,
|
||||
get_yk_saved_cards_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_billing_dal, active_discount_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_yookassa_router")
|
||||
|
||||
|
||||
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
||||
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
|
||||
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
||||
try:
|
||||
parts = payload.split(":")
|
||||
value = float(parts[0])
|
||||
price = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
return value, price, sale_mode
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
|
||||
def _is_yoomoney_network(name: Optional[str]) -> bool:
|
||||
s = (name or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
if _is_yoomoney_network(network):
|
||||
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
|
||||
title = get_text("payment_method_wallet_title", last4=inferred_last4)
|
||||
elif last4:
|
||||
network_name = network or get_text("payment_network_card")
|
||||
title = get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
else:
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
title = get_text("payment_method_generic_title", network=network_name)
|
||||
return f"⭐ {title}" if is_default else title
|
||||
|
||||
|
||||
async def _initiate_yk_payment(
|
||||
callback: types.CallbackQuery,
|
||||
*,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
promo_code_service, # NEW: Added promo_code_service
|
||||
i18n: Optional[JsonI18n],
|
||||
current_lang: str,
|
||||
get_text,
|
||||
user_id: int,
|
||||
months: int,
|
||||
price_rub: float,
|
||||
currency_code_for_yk: str,
|
||||
save_payment_method: bool,
|
||||
back_callback: str,
|
||||
payment_method_id: Optional[str] = None,
|
||||
selected_method_internal_id: Optional[int] = None,
|
||||
sale_mode: str = "subscription",
|
||||
) -> bool:
|
||||
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
|
||||
if not callback.message:
|
||||
return False
|
||||
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_price = None
|
||||
discount_amount = None
|
||||
active_promo_code_id = None
|
||||
|
||||
if promo_code_service:
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
active_promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
price_source = (
|
||||
getattr(settings, "traffic_packages", {}) or {}
|
||||
if sale_mode == "traffic"
|
||||
else (settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_price = fallback_original
|
||||
discount_amount = original_price - price_rub
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for YooKassa payment: "
|
||||
f"original {original_price:.2f} -> final {price_rub}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"YooKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_price = price_rub / denominator
|
||||
discount_amount = original_price - price_rub
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for YooKassa payment: "
|
||||
f"original {original_price:.2f} -> final {price_rub}"
|
||||
)
|
||||
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||
if sale_mode == "traffic"
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub, # Discounted amount
|
||||
"original_amount": original_price if discount_amount else None, # NEW
|
||||
"discount_applied": discount_amount, # NEW
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"promo_code_id": active_promo_code_id, # NEW: Link to promo code
|
||||
}
|
||||
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return False
|
||||
|
||||
if not db_payment_record:
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return False
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
if sale_mode == "traffic":
|
||||
yookassa_metadata["traffic_gb"] = str(months)
|
||||
if payment_method_id:
|
||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk,
|
||||
save_payment_method=save_payment_method,
|
||||
payment_method_id=payment_method_id,
|
||||
)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
pm = payment_response_yk.get("payment_method")
|
||||
try:
|
||||
if pm and pm.get("id"):
|
||||
pm_type = pm.get("type")
|
||||
title = pm.get("title")
|
||||
card = pm.get("card") or {}
|
||||
account_number = pm.get("account_number") or pm.get("account")
|
||||
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
display_network = "YooMoney"
|
||||
display_last4 = (
|
||||
account_number[-4:]
|
||||
if isinstance(account_number, str) and len(account_number) >= 4
|
||||
else None
|
||||
)
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm["id"],
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm["id"],
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=save_payment_method,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Failed to save YooKassa payment method preliminarily")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to set default payment method after initiating payment")
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return False
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment link failed: {e_edit}. Sending new one."
|
||||
)
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text(
|
||||
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return True
|
||||
|
||||
if payment_response_yk and payment_method_id:
|
||||
status_to_store = payment_response_yk.get("status", "pending")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=status_to_store,
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to set default payment method after saved-card payment start")
|
||||
await session.commit()
|
||||
except Exception as e_db_update_saved:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return False
|
||||
|
||||
message_text = get_text("yookassa_autopay_charge_initiated")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return True
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, db_payment_record.payment_id, "failed_creation"
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
months, callback_price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"YooKassa: no server-side price for user %s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"YooKassa: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
currency_code_for_yk = "RUB"
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
autopay_require_binding = bool(
|
||||
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||
)
|
||||
saved_methods: List = []
|
||||
if autopay_enabled:
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if autopay_enabled and saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
promo_code_service=promo_code_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_new:"))
|
||||
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_new.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
months, callback_price_rub, sale_mode = parsed
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"YooKassa: no server-side price for new-card flow, user=%s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"YooKassa: callback price mismatch in new-card flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
currency_code_for_yk = "RUB"
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
autopay_require_binding = bool(
|
||||
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||
)
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
promo_code_service=promo_code_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
|
||||
async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 2:
|
||||
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
callback_price_rub = float(parts[1])
|
||||
page = int(parts[2]) if len(parts) > 2 else 0
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
if not autopay_enabled:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"YooKassa: no server-side price for saved-list flow, user=%s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"YooKassa: callback price mismatch in saved-list flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if not saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
cards: List[Tuple[str, str]] = []
|
||||
for method in saved_methods:
|
||||
title = _format_saved_payment_method_title(
|
||||
get_text, method.card_network, method.card_last4, method.is_default
|
||||
)
|
||||
cards.append((str(method.method_id), title))
|
||||
|
||||
per_page = 5
|
||||
max_page = max(0, (len(cards) - 1) // per_page)
|
||||
page = max(0, min(page, max_page))
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display saved card list: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
|
||||
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 3:
|
||||
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
callback_price_rub = float(parts[1])
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
||||
if not autopay_enabled:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
method_identifier = parts[2]
|
||||
user_id = callback.from_user.id
|
||||
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
||||
session=session,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
promo_code_service=promo_code_service,
|
||||
)
|
||||
if resolved_price_rub is None:
|
||||
logging.warning(
|
||||
"YooKassa: no server-side price for use-saved flow, user=%s, value=%s, mode=%s",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
||||
logging.warning(
|
||||
"YooKassa: callback price mismatch in use-saved flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
||||
user_id,
|
||||
months,
|
||||
sale_mode,
|
||||
callback_price_rub,
|
||||
resolved_price_rub,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
selected_method = None
|
||||
for method in saved_methods:
|
||||
if method_identifier.isdigit():
|
||||
if method.method_id == int(method_identifier):
|
||||
selected_method = method
|
||||
break
|
||||
if method.provider_payment_method_id == method_identifier:
|
||||
selected_method = method
|
||||
break
|
||||
|
||||
if not selected_method:
|
||||
logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
return
|
||||
|
||||
price_rub = resolved_price_rub
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
promo_code_service=promo_code_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=False,
|
||||
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
||||
payment_method_id=selected_method.provider_payment_method_id,
|
||||
selected_method_internal_id=selected_method.method_id,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
||||
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_main_menu_inline_keyboard,
|
||||
get_connect_and_main_keyboard,
|
||||
)
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -34,8 +35,8 @@ async def request_trial_confirmation_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
return
|
||||
|
||||
show_trial_btn_in_menu_if_fail = False
|
||||
@@ -52,8 +53,8 @@ async def request_trial_confirmation_handler(
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
return
|
||||
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
@@ -65,8 +66,8 @@ async def request_trial_confirmation_handler(
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
return
|
||||
|
||||
# Directly activate trial without confirmation
|
||||
@@ -76,18 +77,21 @@ async def request_trial_confirmation_handler(
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
settings, activation_result.get("subscription_url")
|
||||
)
|
||||
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||
|
||||
traffic_gb_val = activation_result.get(
|
||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
@@ -130,8 +134,8 @@ async def request_trial_confirmation_handler(
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
try:
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_had_any_subscription(
|
||||
@@ -142,7 +146,11 @@ async def request_trial_confirmation_handler(
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link_for_trial
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -189,15 +197,15 @@ async def confirm_activate_trial_handler(
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
return
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
try:
|
||||
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
@@ -208,8 +216,8 @@ async def confirm_activate_trial_handler(
|
||||
await callback.answer(
|
||||
_("trial_already_had_subscription_or_trial"), show_alert=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
@@ -221,18 +229,21 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
settings, activation_result.get("subscription_url")
|
||||
)
|
||||
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||
|
||||
traffic_gb_val = activation_result.get(
|
||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
@@ -263,8 +274,8 @@ async def confirm_activate_trial_handler(
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
try:
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Suppressed exception in bot/handlers/user/trial_handler.py: %s", exc)
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_had_any_subscription(
|
||||
@@ -275,7 +286,11 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link_for_trial
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
|
||||
@@ -26,7 +26,7 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
||||
callback_data="admin_section:promo_marketing")
|
||||
|
||||
# Реклама
|
||||
builder.button(text=_(key="admin_ads_section", default="📈 Реклама"),
|
||||
builder.button(text=_(key="admin_ads_section"),
|
||||
callback_data="admin_action:ads")
|
||||
|
||||
# Системные функции
|
||||
@@ -43,7 +43,7 @@ def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
||||
|
||||
builder.button(text=_(key="admin_stats_button"),
|
||||
callback_data="admin_action:stats")
|
||||
builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"),
|
||||
builder.button(text=_(key="admin_view_payments_button"),
|
||||
callback_data="admin_action:view_payments")
|
||||
builder.button(text=_(key="admin_view_logs_menu_button"),
|
||||
callback_data="admin_action:view_logs_menu")
|
||||
@@ -59,13 +59,15 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_users_management_button"),
|
||||
callback_data="admin_action:users_management")
|
||||
callback_data="admin_action:users_list:0")
|
||||
builder.button(text=_(key="admin_users_search_button"),
|
||||
callback_data="admin_action:users_search_prompt")
|
||||
builder.button(text=_(key="admin_ban_management_section"),
|
||||
callback_data="admin_section:ban_management")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1)
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -123,7 +125,7 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
||||
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
builder.button(text=_(key="admin_ads_create_button"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
@@ -154,7 +156,7 @@ def get_ads_list_keyboard(
|
||||
if current_page > 0:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
||||
text="⬅️ " + _("prev_page_button"),
|
||||
callback_data=f"admin_ads:page:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
@@ -167,14 +169,14 @@ def get_ads_list_keyboard(
|
||||
if current_page < total_pages - 1:
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button", default="Next") + " ➡️",
|
||||
text=_("next_page_button") + " ➡️",
|
||||
callback_data=f"admin_ads:page:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
if row:
|
||||
builder.row(*row)
|
||||
|
||||
builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"),
|
||||
builder.button(text=_(key="admin_ads_create_button"),
|
||||
callback_data="admin_action:ads_create")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
@@ -186,9 +188,9 @@ def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
# Dangerous action: Delete campaign
|
||||
builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"),
|
||||
builder.button(text=_(key="admin_ads_delete_button"),
|
||||
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
|
||||
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
|
||||
builder.button(text=_(key="back_to_ads_list_button"),
|
||||
callback_data=f"admin_ads:page:{back_page}")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
@@ -225,12 +227,12 @@ def get_logs_pagination_keyboard(
|
||||
if current_page > 0:
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
||||
text="⬅️ " + _("prev_page_button"),
|
||||
callback_data=f"{base_callback_data}:{current_page - 1}"))
|
||||
if current_page < total_pages - 1:
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button", default="Next") + " ➡️",
|
||||
text=_("next_page_button") + " ➡️",
|
||||
callback_data=f"{base_callback_data}:{current_page + 1}"))
|
||||
|
||||
if row_buttons: builder.row(*row_buttons)
|
||||
@@ -305,6 +307,68 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_users_list_keyboard(users: List[User], current_page: int,
|
||||
total_users: int, i18n_instance, lang: str,
|
||||
page_size: int = 15) -> InlineKeyboardMarkup:
|
||||
"""Generate keyboard for paginated user list"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Add user buttons
|
||||
for user in users:
|
||||
user_display_parts = []
|
||||
if user.username:
|
||||
user_display_parts.append(f"@{user.username}")
|
||||
user_display_parts.append(f"ID: {user.user_id}")
|
||||
if user.first_name:
|
||||
user_display_parts.append(f"- {user.first_name}")
|
||||
|
||||
button_text = " ".join(user_display_parts)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_card_from_list:{user.user_id}:{current_page}"
|
||||
)
|
||||
)
|
||||
|
||||
# Pagination buttons
|
||||
if total_users > page_size:
|
||||
total_pages = math.ceil(total_users / page_size)
|
||||
pagination_buttons = []
|
||||
if current_page > 0:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("prev_page_button"),
|
||||
callback_data=f"admin_action:users_list:{current_page - 1}"
|
||||
)
|
||||
)
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{current_page + 1}/{total_pages}",
|
||||
callback_data="stub_page_display"
|
||||
)
|
||||
)
|
||||
if current_page < total_pages - 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button"),
|
||||
callback_data=f"admin_action:users_list:{current_page + 1}"
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
# Back button
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("back_to_user_management_button"),
|
||||
callback_data="admin_section:user_management"
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_user_card_keyboard(user_id: int,
|
||||
is_banned: bool,
|
||||
i18n_instance,
|
||||
@@ -320,6 +384,12 @@ def get_user_card_keyboard(user_id: int,
|
||||
builder.button(
|
||||
text=_(key="user_card_ban_button"),
|
||||
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
||||
builder.button(
|
||||
text=_(
|
||||
key="user_card_open_profile_button"
|
||||
),
|
||||
url=f"tg://user?id={user_id}"
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="user_card_back_to_banned_list_button"),
|
||||
callback_data=f"admin_action:view_banned:{banned_list_page}")
|
||||
@@ -347,16 +417,13 @@ def get_broadcast_confirmation_keyboard(lang: str,
|
||||
|
||||
# Row: target selection (all / active / inactive)
|
||||
target_all_label = _(
|
||||
key="broadcast_target_all_button",
|
||||
default="👥 Все"
|
||||
key="broadcast_target_all_button"
|
||||
)
|
||||
target_active_label = _(
|
||||
key="broadcast_target_active_button",
|
||||
default="✅ Активные"
|
||||
key="broadcast_target_active_button"
|
||||
)
|
||||
target_inactive_label = _(
|
||||
key="broadcast_target_inactive_button",
|
||||
default="⌛ Неактивные"
|
||||
key="broadcast_target_inactive_button"
|
||||
)
|
||||
|
||||
# Highlight current selection with a prefix
|
||||
@@ -378,9 +445,9 @@ def get_broadcast_confirmation_keyboard(lang: str,
|
||||
builder.adjust(3)
|
||||
|
||||
# Row: confirmation
|
||||
builder.button(text=_(key="confirm_broadcast_send_button", default="🚀 Отправить"),
|
||||
builder.button(text=_(key="confirm_broadcast_send_button"),
|
||||
callback_data="broadcast_final_action:send")
|
||||
builder.button(text=_(key="cancel_broadcast_button", default="❌ Отмена"),
|
||||
builder.button(text=_(key="cancel_broadcast_button"),
|
||||
callback_data="broadcast_final_action:cancel")
|
||||
builder.adjust(2)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -28,13 +28,16 @@ def get_main_menu_inline_keyboard(
|
||||
)
|
||||
)
|
||||
|
||||
referral_button = InlineKeyboardButton(
|
||||
text=_(key="menu_referral_inline"),
|
||||
callback_data="main_action:referral")
|
||||
promo_button = InlineKeyboardButton(
|
||||
text=_(key="menu_apply_promo_button"),
|
||||
callback_data="main_action:apply_promo")
|
||||
builder.row(referral_button, promo_button)
|
||||
if settings.REFERRAL_ENABLED:
|
||||
referral_button = InlineKeyboardButton(
|
||||
text=_(key="menu_referral_inline"),
|
||||
callback_data="main_action:referral")
|
||||
builder.row(referral_button, promo_button)
|
||||
else:
|
||||
builder.row(promo_button)
|
||||
|
||||
language_button = InlineKeyboardButton(
|
||||
text=_(key="menu_language_settings_inline"),
|
||||
@@ -91,19 +94,31 @@ def get_trial_confirmation_keyboard(lang: str,
|
||||
|
||||
|
||||
def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
int, Optional[int]], currency_symbol_val: str, lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
float, Optional[float]], currency_symbol_val: str, lang: str,
|
||||
i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
def _format_gb(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
if subscription_options:
|
||||
for months, price in subscription_options.items():
|
||||
if price is not None:
|
||||
button_text = _("subscribe_for_months_button",
|
||||
months=months,
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val)
|
||||
if traffic_mode:
|
||||
button_text = _(
|
||||
"buy_traffic_package_button",
|
||||
traffic_gb=_format_gb(months),
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val,
|
||||
)
|
||||
callback_data = f"subscribe_period:{_format_gb(months)}"
|
||||
else:
|
||||
button_text = _("subscribe_for_months_button",
|
||||
months=months,
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val)
|
||||
callback_data = f"subscribe_period:{months}"
|
||||
builder.button(text=button_text,
|
||||
callback_data=f"subscribe_period:{months}")
|
||||
callback_data=callback_data)
|
||||
builder.adjust(1)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
||||
@@ -112,26 +127,46 @@ def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
|
||||
|
||||
def get_payment_method_keyboard(months: int, price: float,
|
||||
tribute_url: Optional[str],
|
||||
stars_price: Optional[int],
|
||||
currency_symbol_val: str, lang: str,
|
||||
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
||||
i18n_instance, settings: Settings, sale_mode: str = "subscription") -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
if settings.FREEKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_sbp_button"),
|
||||
callback_data=f"pay_fk:{months}:{price}")
|
||||
if settings.YOOKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{months}:{price}")
|
||||
if settings.TRIBUTE_ENABLED and tribute_url:
|
||||
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
||||
if settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{months}:{stars_price}")
|
||||
if settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{months}:{price}")
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
value_str = _format_value(months)
|
||||
mode_suffix = f":{sale_mode}"
|
||||
for method in settings.payment_methods_order:
|
||||
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
|
||||
builder.button(
|
||||
text=_("pay_with_severpay_button"),
|
||||
callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "freekassa" and settings.FREEKASSA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_sbp_button"),
|
||||
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "platega" and settings.PLATEGA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_platega_button"),
|
||||
callback_data=f"pay_platega:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
elif method == "stars" and settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(
|
||||
text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}",
|
||||
)
|
||||
elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(
|
||||
text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}",
|
||||
)
|
||||
builder.button(text=_(key="cancel_button"),
|
||||
callback_data="main_action:subscribe")
|
||||
builder.adjust(1)
|
||||
@@ -162,28 +197,33 @@ def get_yk_autopay_choice_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
has_saved_cards: bool = True,
|
||||
sale_mode: str = "subscription",
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
price_str = str(price)
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
value_str = _format_value(months)
|
||||
suffix = f":{sale_mode}"
|
||||
if has_saved_cards:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{months}",
|
||||
callback_data=f"subscribe_period:{value_str}",
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -196,6 +236,7 @@ def get_yk_saved_cards_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
page: int = 0,
|
||||
sale_mode: str = "subscription",
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Paginated keyboard for selecting a saved YooKassa card."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
@@ -205,12 +246,16 @@ def get_yk_saved_cards_keyboard(
|
||||
start = page * per_page
|
||||
end = min(total, start + per_page)
|
||||
price_str = str(price)
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
value_str = _format_value(months)
|
||||
suffix = f":{sale_mode}"
|
||||
|
||||
for method_id, title in cards[start:end]:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=title,
|
||||
callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}",
|
||||
callback_data=f"pay_yk_use_saved:{value_str}:{price_str}:{method_id}{suffix}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -219,14 +264,14 @@ def get_yk_saved_cards_keyboard(
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page-1}{suffix}",
|
||||
)
|
||||
)
|
||||
if end < total:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page+1}{suffix}",
|
||||
)
|
||||
)
|
||||
if nav_buttons:
|
||||
@@ -235,13 +280,13 @@ def get_yk_saved_cards_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||
callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_autopay_method_choice_button"),
|
||||
callback_data=f"pay_yk:{months}:{price_str}",
|
||||
callback_data=f"pay_yk:{value_str}:{price_str}{suffix}",
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -333,10 +378,12 @@ def get_connect_and_main_keyboard(
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
config_link: Optional[str],
|
||||
connect_button_url: Optional[str] = None,
|
||||
preserve_message: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
button_target = connect_button_url or config_link
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
@@ -345,9 +392,9 @@ def get_connect_and_main_keyboard(
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
elif config_link:
|
||||
elif button_target:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_("connect_button"), url=config_link)
|
||||
InlineKeyboardButton(text=_("connect_button"), url=button_target)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
|
||||
+48
-39
@@ -33,7 +33,6 @@ from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService, tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
|
||||
|
||||
from bot.handlers.user import payment as user_payment_webhook_module
|
||||
@@ -59,52 +58,48 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
|
||||
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
||||
if telegram_webhook_url_to_set:
|
||||
full_telegram_webhook_url = (
|
||||
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
)
|
||||
full_telegram_webhook_url = settings.telegram_full_webhook_url
|
||||
if not full_telegram_webhook_url:
|
||||
logging.error(
|
||||
"STARTUP: Telegram webhook URL could not be built (WEBHOOK_BASE_URL missing)."
|
||||
)
|
||||
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
|
||||
|
||||
logging.info(
|
||||
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
|
||||
"STARTUP: Attempting to set Telegram webhook (path=%s)",
|
||||
settings.telegram_webhook_path,
|
||||
)
|
||||
|
||||
if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED":
|
||||
try:
|
||||
current_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
try:
|
||||
current_webhook_info = await bot.get_webhook_info()
|
||||
if current_webhook_info.url:
|
||||
logging.info("STARTUP: Telegram webhook already set (non-empty URL).")
|
||||
else:
|
||||
logging.info("STARTUP: Telegram webhook currently empty (will set).")
|
||||
|
||||
set_success = await bot.set_webhook(
|
||||
url=full_telegram_webhook_url,
|
||||
drop_pending_updates=True,
|
||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||
)
|
||||
if set_success:
|
||||
logging.info(
|
||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)."
|
||||
)
|
||||
telegram_webhook_secret = (settings.TELEGRAM_WEBHOOK_SECRET or "").strip() or None
|
||||
set_success = await bot.set_webhook(
|
||||
url=full_telegram_webhook_url,
|
||||
drop_pending_updates=True,
|
||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||
secret_token=telegram_webhook_secret,
|
||||
)
|
||||
if set_success:
|
||||
logging.info("STARTUP: bot.set_webhook returned SUCCESS (True).")
|
||||
else:
|
||||
logging.error("STARTUP: bot.set_webhook returned FAILURE (False).")
|
||||
|
||||
new_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
if not new_webhook_info.url:
|
||||
logging.error(
|
||||
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
|
||||
)
|
||||
|
||||
except Exception as e_setwebhook:
|
||||
new_webhook_info = await bot.get_webhook_info()
|
||||
if not new_webhook_info.url:
|
||||
logging.error(
|
||||
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
||||
exc_info=True,
|
||||
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
|
||||
)
|
||||
else:
|
||||
|
||||
except Exception as e_setwebhook:
|
||||
logging.error(
|
||||
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
||||
"STARTUP: EXCEPTION during set/get Telegram webhook: %s",
|
||||
e_setwebhook,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
@@ -150,6 +145,18 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
|
||||
|
||||
# Initialize promo discount expiration worker
|
||||
try:
|
||||
promo_code_service: Optional[PromoCodeService] = dispatcher.get("promo_code_service")
|
||||
if promo_code_service:
|
||||
await promo_code_service.setup_discount_expiration_worker(async_session_factory)
|
||||
logging.info("STARTUP: Promo discount expiration worker initialized")
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"STARTUP: Failed to initialize promo discount expiration worker: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Automatic sync on startup
|
||||
try:
|
||||
logging.info("STARTUP: Running automatic panel sync...")
|
||||
@@ -200,13 +207,15 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
"panel_service",
|
||||
"cryptopay_service",
|
||||
"freekassa_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
"promo_code_service",
|
||||
"stars_service",
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
await close_service(service_key)
|
||||
|
||||
|
||||
@@ -40,13 +40,14 @@ class ActionLoggerMiddleware(BaseMiddleware):
|
||||
is_admin_event_flag = True
|
||||
|
||||
raw_update_snippet = None
|
||||
try:
|
||||
raw_update_snippet = event.model_dump_json(exclude_none=True,
|
||||
indent=None)[:1000]
|
||||
except AttributeError:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
except Exception:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
if self.settings.LOG_STORE_RAW_UPDATES:
|
||||
try:
|
||||
raw_update_snippet = event.model_dump_json(exclude_none=True,
|
||||
indent=None)[:1000]
|
||||
except AttributeError:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
except Exception:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
|
||||
current_event_type = event.event_type
|
||||
|
||||
@@ -83,7 +84,7 @@ class ActionLoggerMiddleware(BaseMiddleware):
|
||||
"telegram_username": telegram_username,
|
||||
"telegram_first_name": telegram_first_name,
|
||||
"event_type": current_event_type,
|
||||
"content": content[:1000] if content else "N/A",
|
||||
"content": (content[:1000] if content else "N/A") if self.settings.LOG_STORE_MESSAGE_CONTENT else None,
|
||||
"raw_update_preview": raw_update_snippet,
|
||||
"is_admin_event": is_admin_event_flag,
|
||||
"target_user_id": target_user_id_for_log,
|
||||
|
||||
@@ -32,6 +32,9 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
if not self.settings.REQUIRED_CHANNEL_SUBSCRIBE_TO_USE:
|
||||
return await handler(event, data)
|
||||
|
||||
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
|
||||
if not required_channel_id:
|
||||
return await handler(event, data)
|
||||
@@ -40,6 +43,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
if not event_user or event_user.id in self.settings.ADMIN_IDS:
|
||||
return await handler(event, data)
|
||||
|
||||
callback_query = event.callback_query
|
||||
if (
|
||||
callback_query
|
||||
and callback_query.data
|
||||
and callback_query.data == "channel_subscription:verify"
|
||||
):
|
||||
return await handler(event, data)
|
||||
|
||||
# Allow /start to reach the handler so the check can be re-run.
|
||||
message_object: Optional[Message] = event.message
|
||||
if (
|
||||
@@ -116,8 +127,8 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
) -> None:
|
||||
try:
|
||||
await callback.answer(prompt_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Failed to answer callback for channel gate prompt: %s", exc)
|
||||
|
||||
if callback.message:
|
||||
try:
|
||||
|
||||
@@ -17,6 +17,7 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class CryptoPayService:
|
||||
@@ -63,23 +64,69 @@ class CryptoPayService:
|
||||
months: int,
|
||||
amount: float,
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
promo_code_service=None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
return None
|
||||
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_amount = None
|
||||
discount_amount = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service:
|
||||
from db.dal import active_discount_dal
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
price_source = (
|
||||
getattr(self.settings, "traffic_packages", {}) or {}
|
||||
if sale_mode == "traffic"
|
||||
else (self.settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_amount = fallback_original
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for CryptoPay payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"CryptoPay discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_amount = amount / denominator
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for CryptoPay payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
|
||||
# Create pending payment in DB and commit to persist
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"amount": amount,
|
||||
"original_amount": original_amount,
|
||||
"discount_applied": discount_amount,
|
||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "cryptopay",
|
||||
"promo_code_id": promo_code_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -94,6 +141,8 @@ class CryptoPayService:
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": str(months) if sale_mode == "traffic" else None,
|
||||
})
|
||||
try:
|
||||
invoice = await self.client.create_invoice(
|
||||
@@ -132,8 +181,10 @@ class CryptoPayService:
|
||||
try:
|
||||
meta = json.loads(invoice.payload)
|
||||
user_id = int(meta["user_id"])
|
||||
months = int(meta["subscription_months"])
|
||||
months = float(meta.get("subscription_months") or 0)
|
||||
payment_db_id = int(meta["payment_db_id"])
|
||||
sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription")
|
||||
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to parse CryptoPay payload: {e}")
|
||||
return
|
||||
@@ -147,27 +198,101 @@ class CryptoPayService:
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
# Fetch payment record to get promo_code_id
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment_record:
|
||||
logging.error(f"CryptoPay: Payment record {payment_db_id} not found")
|
||||
return
|
||||
|
||||
if payment_record.user_id != user_id:
|
||||
logging.error(
|
||||
"CryptoPay webhook: user mismatch for payment %s (db=%s, payload=%s)",
|
||||
payment_db_id,
|
||||
payment_record.user_id,
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
provider_currency = None
|
||||
for candidate in (
|
||||
getattr(invoice, "fiat", None),
|
||||
getattr(invoice, "asset", None),
|
||||
getattr(invoice, "paid_asset", None),
|
||||
settings.CRYPTOPAY_ASSET,
|
||||
):
|
||||
if candidate:
|
||||
provider_currency = str(candidate).upper()
|
||||
break
|
||||
expected_currency = str(payment_record.currency or "").upper()
|
||||
if expected_currency and provider_currency and expected_currency != provider_currency:
|
||||
logging.error(
|
||||
"CryptoPay webhook: currency mismatch for payment %s (expected %s, got %s)",
|
||||
payment_db_id,
|
||||
expected_currency,
|
||||
provider_currency,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_record.status == "succeeded":
|
||||
logging.info("CryptoPay webhook: payment %s already succeeded", payment_db_id)
|
||||
return
|
||||
|
||||
try:
|
||||
expected_amount = float(payment_record.amount)
|
||||
incoming_amount = float(invoice.amount)
|
||||
if round(incoming_amount, 2) != round(expected_amount, 2):
|
||||
logging.error(
|
||||
"CryptoPay webhook: amount mismatch for payment %s (expected %.2f, got %.2f)",
|
||||
payment_db_id,
|
||||
expected_amount,
|
||||
incoming_amount,
|
||||
)
|
||||
return
|
||||
except Exception as amount_exc:
|
||||
logging.error(
|
||||
"CryptoPay webhook: failed to compare amount for payment %s: %s",
|
||||
payment_db_id,
|
||||
amount_exc,
|
||||
)
|
||||
return
|
||||
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment_db_id,
|
||||
str(invoice.invoice_id),
|
||||
"succeeded",
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"CryptoPay webhook: payment %s already processed atomically",
|
||||
payment_db_id,
|
||||
)
|
||||
return
|
||||
|
||||
activation = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(invoice.amount),
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=payment_record.promo_code_id,
|
||||
provider="cryptopay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
if not activation or not activation.get("end_date"):
|
||||
raise RuntimeError(
|
||||
f"CryptoPay webhook: activation failed for payment {payment_db_id}"
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
int(months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
@@ -179,14 +304,21 @@ class CryptoPayService:
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
config_link = activation.get("subscription_url") or _("config_link_not_available")
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
display_link, button_link = await prepare_config_links(settings, raw_config_link)
|
||||
config_link_text = display_link or _("config_link_not_available")
|
||||
final_end = activation.get("end_date")
|
||||
applied_days = 0
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
if applied_days:
|
||||
if sale_mode == "traffic":
|
||||
text = _("payment_successful_traffic_full",
|
||||
traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}",
|
||||
end_date=final_end.strftime('%Y-%m-%d') if final_end else "—",
|
||||
config_link=config_link_text)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
@@ -197,20 +329,25 @@ class CryptoPayService:
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
text = _("payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
months=int(months),
|
||||
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link)
|
||||
config_link=config_link_text)
|
||||
else:
|
||||
text = _("payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link)
|
||||
months=int(months),
|
||||
end_date=final_end.strftime('%Y-%m-%d') if final_end else "—",
|
||||
config_link=config_link_text)
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang, i18n, settings, config_link, preserve_message=True
|
||||
lang,
|
||||
i18n,
|
||||
settings,
|
||||
display_link,
|
||||
connect_button_url=button_link,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
@@ -230,8 +367,9 @@ class CryptoPayService:
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=float(invoice.amount),
|
||||
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=months,
|
||||
currency=invoice.asset or "RUB",
|
||||
months=int(months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
|
||||
payment_provider="crypto_pay",
|
||||
username=user.username if user else None
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class FreeKassaService:
|
||||
@@ -43,8 +44,9 @@ class FreeKassaService:
|
||||
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
|
||||
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
|
||||
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
self.default_currency: str = "RUB"
|
||||
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
|
||||
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
|
||||
|
||||
self.api_base_url: str = "https://api.fk.life/v1"
|
||||
self._timeout = ClientTimeout(total=15)
|
||||
@@ -72,15 +74,74 @@ class FreeKassaService:
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
method_code: int,
|
||||
email: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
payment_method_id: Optional[int] = None,
|
||||
extra_params: Optional[Dict[str, Any]] = None,
|
||||
promo_code_service=None,
|
||||
session=None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("FreeKassaService is not configured. Cannot create order.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_amount = None
|
||||
discount_amount = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service and session:
|
||||
from db.dal import active_discount_dal
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
|
||||
price_source = (
|
||||
getattr(self.settings, "traffic_packages", {}) or {}
|
||||
if traffic_mode
|
||||
else (self.settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_amount = fallback_original
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for FreeKassa payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"FreeKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_amount = amount / denominator
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for FreeKassa payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
|
||||
# Update payment record with discount metadata
|
||||
try:
|
||||
await payment_dal.update_payment_discount_info(
|
||||
session,
|
||||
payment_db_id,
|
||||
original_amount,
|
||||
discount_amount,
|
||||
promo_code_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_update:
|
||||
logging.warning(
|
||||
f"FreeKassa: failed to update discount metadata for payment {payment_db_id}: {e_update}"
|
||||
)
|
||||
|
||||
ip_address = ip_address or self.server_ip
|
||||
if not ip_address:
|
||||
logging.error("FreeKassaService: payment IP is required but not configured.")
|
||||
@@ -94,7 +155,7 @@ class FreeKassaService:
|
||||
"shopId": int(self.shop_id),
|
||||
"nonce": await self._generate_nonce(),
|
||||
"paymentId": str(payment_db_id),
|
||||
"i": int(method_code),
|
||||
"i": int(payment_method_id),
|
||||
"amount": amount_str,
|
||||
"currency": currency_code,
|
||||
"email": email,
|
||||
@@ -177,7 +238,10 @@ class FreeKassaService:
|
||||
|
||||
if self.shop_id and self.second_secret:
|
||||
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
||||
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
||||
expected_signature = hashlib.md5(
|
||||
signature_source.encode("utf-8"),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()
|
||||
if expected_signature.lower() == provided_signature.lower():
|
||||
return True
|
||||
|
||||
@@ -256,6 +320,16 @@ class FreeKassaService:
|
||||
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.currency and str(payment.currency).upper() != str(self.default_currency or payment.currency).upper():
|
||||
# FreeKassa sends amount without currency; ensure DB currency matches configured service currency
|
||||
logging.error(
|
||||
"FreeKassa webhook: currency mismatch for payment %s (db=%s, expected=%s)",
|
||||
payment_db_id,
|
||||
payment.currency,
|
||||
self.default_currency,
|
||||
)
|
||||
return web.Response(status=400, text="currency_mismatch")
|
||||
|
||||
if payment.status == "succeeded":
|
||||
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
|
||||
return web.Response(text="YES")
|
||||
@@ -265,41 +339,59 @@ class FreeKassaService:
|
||||
amount_decimal = Decimal(amount_str)
|
||||
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount:
|
||||
logging.warning(
|
||||
logging.error(
|
||||
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
|
||||
f"(expected {expected_amount}, got {amount_decimal})"
|
||||
)
|
||||
return web.Response(status=400, text="amount_mismatch")
|
||||
except Exception as e:
|
||||
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
|
||||
logging.error(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
|
||||
return web.Response(status=400, text="amount_validation_error")
|
||||
|
||||
activation = None
|
||||
referral_bonus = None
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
provider_id = str(provider_payment_id or f"freekassa:{order_id_str}")
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session=session,
|
||||
payment_db_id=payment.payment_id,
|
||||
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
|
||||
new_status="succeeded",
|
||||
provider_payment_id=provider_id,
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"FreeKassa webhook: payment %s already processed atomically",
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.Response(text="YES")
|
||||
|
||||
months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
months,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
promo_code_id_from_payment=payment.promo_code_id,
|
||||
provider="freekassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
if not activation or not activation.get("end_date"):
|
||||
raise RuntimeError(
|
||||
f"FreeKassa webhook: activation failed for payment {payment.payment_id}"
|
||||
)
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
months,
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
@@ -311,12 +403,12 @@ class FreeKassaService:
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
config_link = None
|
||||
final_end = None
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
months = payment.subscription_duration_months or 1
|
||||
if activation:
|
||||
config_link = activation.get("subscription_url")
|
||||
final_end = activation.get("end_date")
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
applied_days = 0
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
@@ -326,14 +418,19 @@ class FreeKassaService:
|
||||
if not final_end and activation and activation.get("end_date"):
|
||||
final_end = activation["end_date"]
|
||||
|
||||
if not config_link:
|
||||
config_link = _("config_link_not_available")
|
||||
if final_end:
|
||||
end_date_str = final_end.strftime("%Y-%m-%d")
|
||||
else:
|
||||
end_date_str = _("config_link_not_available")
|
||||
|
||||
if applied_days:
|
||||
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
|
||||
if sale_mode == "traffic":
|
||||
text = _("payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date_str if final_end else "",
|
||||
config_link=config_link_text)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
@@ -350,14 +447,14 @@ class FreeKassaService:
|
||||
bonus_days=applied_days,
|
||||
final_end_date=end_date_str,
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=end_date_str,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
if provider_payment_id:
|
||||
order_info_text = _(
|
||||
@@ -371,7 +468,8 @@ class FreeKassaService:
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
@@ -391,7 +489,8 @@ class FreeKassaService:
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=self.default_currency,
|
||||
months=months,
|
||||
months=int(months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
payment_provider="freekassa",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
LKNPD API client for self-employed (NPD) tax receipts.
|
||||
Custom implementation for lknpd.nalog.ru API.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentType(str, Enum):
|
||||
"""Payment type for income registration."""
|
||||
CASH = "CASH"
|
||||
WIRE = "WIRE"
|
||||
|
||||
|
||||
class IncomeType(str, Enum):
|
||||
"""Income source type."""
|
||||
FROM_INDIVIDUAL = "FROM_INDIVIDUAL"
|
||||
FROM_LEGAL_ENTITY = "FROM_LEGAL_ENTITY"
|
||||
FROM_FOREIGN_AGENCY = "FROM_FOREIGN_AGENCY"
|
||||
|
||||
|
||||
class LknpdApiError(Exception):
|
||||
"""Base exception for LKNPD API errors."""
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class LknpdAuthError(LknpdApiError):
|
||||
"""Authentication error (401)."""
|
||||
pass
|
||||
|
||||
|
||||
class LknpdValidationError(LknpdApiError):
|
||||
"""Validation error (400)."""
|
||||
pass
|
||||
|
||||
|
||||
def _generate_device_id() -> str:
|
||||
"""Generate device ID for API requests."""
|
||||
return str(uuid.uuid4()).replace("-", "")[:21].lower()
|
||||
|
||||
|
||||
def _format_datetime(dt: datetime) -> str:
|
||||
"""Format datetime to ISO/ATOM format with Z suffix."""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
elif dt.tzinfo != UTC:
|
||||
dt = dt.astimezone(UTC)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class LknpdClient:
|
||||
"""
|
||||
Async client for LKNPD (lknpd.nalog.ru) self-employed API.
|
||||
|
||||
Supports:
|
||||
- INN + password authentication
|
||||
- Token refresh
|
||||
- Income registration with proper payment types (CASH/WIRE)
|
||||
"""
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Referrer": "https://lknpd.nalog.ru/auth/login",
|
||||
}
|
||||
|
||||
DEVICE_INFO_TEMPLATE = {
|
||||
"sourceType": "WEB",
|
||||
"appVersion": "1.0.0",
|
||||
"metaDetails": {
|
||||
"userAgent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36"
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://lknpd.nalog.ru/api",
|
||||
timeout: float = 10.0,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.device_id = _generate_device_id()
|
||||
self._token_data: dict[str, Any] | None = None
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
|
||||
def _get_device_info(self) -> dict[str, Any]:
|
||||
"""Get device info with current device ID."""
|
||||
info = self.DEVICE_INFO_TEMPLATE.copy()
|
||||
info["sourceDeviceId"] = self.device_id
|
||||
return info
|
||||
|
||||
async def authenticate(self, inn: str, password: str) -> bool:
|
||||
"""
|
||||
Authenticate with INN and password.
|
||||
|
||||
Returns True if authentication was successful.
|
||||
"""
|
||||
request_data = {
|
||||
"username": inn,
|
||||
"password": password,
|
||||
"deviceInfo": self._get_device_info(),
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/v1/auth/lkfl",
|
||||
json=request_data,
|
||||
headers=self.DEFAULT_HEADERS,
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise LknpdAuthError("Invalid credentials", 401)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise LknpdApiError(
|
||||
f"Authentication failed: {response.text}",
|
||||
response.status_code,
|
||||
)
|
||||
|
||||
self._token_data = response.json()
|
||||
logger.info("LKNPD authentication successful")
|
||||
return True
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.exception("Network error during authentication")
|
||||
raise LknpdApiError(f"Network error: {e}")
|
||||
|
||||
async def _refresh_token(self) -> bool:
|
||||
"""Refresh access token using refresh token."""
|
||||
async with self._refresh_lock:
|
||||
if not self._token_data or "refreshToken" not in self._token_data:
|
||||
return False
|
||||
|
||||
request_data = {
|
||||
"deviceInfo": self._get_device_info(),
|
||||
"refreshToken": self._token_data["refreshToken"],
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/v1/auth/token",
|
||||
json=request_data,
|
||||
headers=self.DEFAULT_HEADERS,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
|
||||
self._token_data = response.json()
|
||||
logger.info("LKNPD token refreshed")
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Token refresh failed")
|
||||
return False
|
||||
|
||||
def _get_auth_headers(self) -> dict[str, str]:
|
||||
"""Get authorization headers from current token."""
|
||||
if not self._token_data or "token" not in self._token_data:
|
||||
return {}
|
||||
return {"Authorization": f"Bearer {self._token_data['token']}"}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
retry_on_401: bool = True,
|
||||
) -> httpx.Response:
|
||||
"""Make authenticated API request with auto-retry on 401."""
|
||||
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
|
||||
url = f"{self.base_url}/v1{path}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Handle 401 with token refresh
|
||||
if response.status_code == 401 and retry_on_401:
|
||||
if await self._refresh_token():
|
||||
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if client has valid token data."""
|
||||
return self._token_data is not None and "token" in self._token_data
|
||||
|
||||
async def create_income(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
amount: Decimal | float,
|
||||
quantity: Decimal | float | int = 1,
|
||||
payment_type: PaymentType = PaymentType.WIRE,
|
||||
income_type: IncomeType = IncomeType.FROM_INDIVIDUAL,
|
||||
client_inn: str | None = None,
|
||||
client_name: str | None = None,
|
||||
client_phone: str | None = None,
|
||||
operation_time: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Register income and create receipt.
|
||||
|
||||
Args:
|
||||
name: Service/item description
|
||||
amount: Price per unit
|
||||
quantity: Number of units
|
||||
payment_type: CASH or WIRE (for card/bank payments)
|
||||
income_type: Source type (individual, legal entity, foreign)
|
||||
client_inn: Client's INN (required for legal entities)
|
||||
client_name: Client's display name
|
||||
client_phone: Client's phone number
|
||||
operation_time: Time of operation (defaults to now)
|
||||
|
||||
Returns:
|
||||
Receipt UUID if successful, None otherwise
|
||||
"""
|
||||
if not self.is_authenticated:
|
||||
raise LknpdAuthError("Not authenticated")
|
||||
|
||||
# Prepare times
|
||||
now = datetime.now(UTC)
|
||||
op_time = operation_time or now
|
||||
|
||||
# Calculate total
|
||||
amount_decimal = Decimal(str(amount))
|
||||
qty_decimal = Decimal(str(quantity))
|
||||
total = amount_decimal * qty_decimal
|
||||
|
||||
# API expects quantity as integer when it's a whole number
|
||||
qty_value: int | str
|
||||
if qty_decimal == qty_decimal.to_integral_value():
|
||||
qty_value = int(qty_decimal)
|
||||
else:
|
||||
qty_value = str(qty_decimal)
|
||||
|
||||
# Build request
|
||||
request_data = {
|
||||
"operationTime": _format_datetime(op_time),
|
||||
"requestTime": _format_datetime(now),
|
||||
"services": [
|
||||
{
|
||||
"name": name,
|
||||
"amount": str(amount_decimal),
|
||||
"quantity": qty_value,
|
||||
}
|
||||
],
|
||||
"totalAmount": str(total),
|
||||
"client": {
|
||||
"contactPhone": client_phone,
|
||||
"displayName": client_name,
|
||||
"incomeType": income_type.value,
|
||||
"inn": client_inn,
|
||||
},
|
||||
"paymentType": payment_type.value,
|
||||
"ignoreMaxTotalIncomeRestriction": False,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._request("POST", "/income", json_data=request_data)
|
||||
|
||||
if response.status_code == 400:
|
||||
logger.error("LKNPD validation error: %s", response.text)
|
||||
raise LknpdValidationError(response.text, 400)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise LknpdAuthError("Authentication expired", 401)
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"LKNPD API error: status=%d body=%s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
raise LknpdApiError(response.text, response.status_code)
|
||||
|
||||
payload = response.json()
|
||||
receipt_uuid = (
|
||||
payload.get("approvedReceiptUuid")
|
||||
or payload.get("receiptUuid")
|
||||
or payload.get("receipt_uuid")
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
logger.info("LKNPD receipt created: %s", receipt_uuid)
|
||||
|
||||
return receipt_uuid
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.exception("Network error creating income")
|
||||
raise LknpdApiError(f"Network error: {e}")
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .lknpd_client import LknpdClient, PaymentType, LknpdApiError
|
||||
|
||||
|
||||
class LknpdService:
|
||||
def __init__(
|
||||
self,
|
||||
inn: Optional[str],
|
||||
password: Optional[str],
|
||||
api_url: str = "https://lknpd.nalog.ru/api",
|
||||
) -> None:
|
||||
self.inn = inn.strip() if inn else None
|
||||
self.password = password
|
||||
self.configured = bool(self.inn and self.password)
|
||||
self._client = LknpdClient(base_url=api_url) if self.configured else None
|
||||
self._auth_lock = asyncio.Lock()
|
||||
|
||||
if not self.configured:
|
||||
logging.warning("LKNPD credentials are missing. Receipt sending disabled.")
|
||||
|
||||
async def _ensure_authenticated(self) -> bool:
|
||||
if not self._client:
|
||||
return False
|
||||
|
||||
async with self._auth_lock:
|
||||
if self._client.is_authenticated:
|
||||
return True
|
||||
|
||||
try:
|
||||
await self._client.authenticate(self.inn, self.password)
|
||||
return True
|
||||
except LknpdApiError:
|
||||
logging.exception("LKNPD authentication failed.")
|
||||
return False
|
||||
|
||||
async def create_income_receipt(
|
||||
self,
|
||||
*,
|
||||
item_name: str,
|
||||
amount: float,
|
||||
quantity: float = 1.0,
|
||||
operation_time: Optional[datetime] = None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured:
|
||||
return None
|
||||
if not await self._ensure_authenticated():
|
||||
return None
|
||||
|
||||
try:
|
||||
receipt_uuid = await self._client.create_income(
|
||||
name=item_name,
|
||||
amount=amount,
|
||||
quantity=quantity,
|
||||
payment_type=PaymentType.WIRE,
|
||||
operation_time=operation_time,
|
||||
)
|
||||
if not receipt_uuid:
|
||||
logging.info("LKNPD receipt created without a UUID in response.")
|
||||
return receipt_uuid
|
||||
except LknpdApiError:
|
||||
logging.exception("Failed to create LKNPD receipt.")
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from aiogram.exceptions import TelegramRetryAfter
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union, Dict, Any
|
||||
from typing import Optional, Union, Dict, Any, Callable
|
||||
|
||||
from config.settings import Settings
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -14,6 +15,10 @@ from bot.utils.text_sanitizer import (
|
||||
display_name_or_fallback,
|
||||
username_for_display,
|
||||
)
|
||||
from bot.utils.telegram_markup import (
|
||||
is_profile_link_error,
|
||||
remove_profile_link_buttons,
|
||||
)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@@ -34,8 +39,43 @@ class NotificationService:
|
||||
if username:
|
||||
base_display = f"{base_display} ({username_for_display(username)})"
|
||||
return base_display
|
||||
|
||||
@staticmethod
|
||||
def _build_profile_keyboard(
|
||||
translate: Callable[..., str],
|
||||
user_id: int,
|
||||
referrer_id: Optional[int] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Create inline keyboard with links to user (and referrer) profiles."""
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=translate(
|
||||
"log_open_profile_link",
|
||||
),
|
||||
url=f"tg://user?id={user_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
if referrer_id:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=translate(
|
||||
"log_open_referrer_profile_button",
|
||||
),
|
||||
url=f"tg://user?id={referrer_id}",
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
||||
async def _send_to_log_channel(
|
||||
self,
|
||||
message: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
):
|
||||
"""Send message to configured log channel/group using message queue"""
|
||||
if not self.settings.LOG_CHAT_ID:
|
||||
return
|
||||
@@ -43,13 +83,42 @@ class NotificationService:
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
logging.warning("Message queue manager not available, falling back to direct send")
|
||||
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
|
||||
|
||||
def _build_kwargs(markup: Optional[InlineKeyboardMarkup]) -> Dict[str, Any]:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": self.settings.LOG_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if markup:
|
||||
kwargs["reply_markup"] = markup
|
||||
if final_thread_id:
|
||||
kwargs["message_thread_id"] = final_thread_id
|
||||
return kwargs
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=self.settings.LOG_CHAT_ID,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
||||
await self.bot.send_message(**_build_kwargs(reply_markup))
|
||||
except TelegramBadRequest as exc:
|
||||
if is_profile_link_error(exc):
|
||||
fallback_markup = remove_profile_link_buttons(reply_markup)
|
||||
logging.warning(
|
||||
"Telegram rejected profile buttons for log chat %s: %s. "
|
||||
"Retrying without tg:// links.",
|
||||
self.settings.LOG_CHAT_ID,
|
||||
getattr(exc, "message", "") or str(exc),
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(**_build_kwargs(fallback_markup))
|
||||
except Exception as retry_exc:
|
||||
logging.error(
|
||||
"Failed to send notification without profile buttons to log "
|
||||
f"channel {self.settings.LOG_CHAT_ID}: {retry_exc}"
|
||||
)
|
||||
return
|
||||
logging.error(
|
||||
f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
@@ -64,6 +133,8 @@ class NotificationService:
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True
|
||||
}
|
||||
if reply_markup:
|
||||
kwargs["reply_markup"] = reply_markup
|
||||
|
||||
# Add thread ID for supergroups if specified
|
||||
if final_thread_id:
|
||||
@@ -124,26 +195,28 @@ class NotificationService:
|
||||
|
||||
referral_text = ""
|
||||
if referred_by_id:
|
||||
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
|
||||
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
|
||||
referral_text = _(
|
||||
"log_referral_suffix",
|
||||
referrer_link=referrer_link,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_new_user_registration",
|
||||
default="👤 <b>Новый пользователь</b>\n\n"
|
||||
"🆔 ID: <code>{user_id}</code>\n"
|
||||
"👤 Имя: {user_display}{referral_text}\n"
|
||||
"📅 Время: {timestamp}",
|
||||
user_id=user_id,
|
||||
user_display=user_display,
|
||||
referral_text=referral_text,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||
months: int, payment_provider: str,
|
||||
username: Optional[str] = None):
|
||||
username: Optional[str] = None,
|
||||
traffic_gb: Optional[float] = None):
|
||||
"""Send notification about successful payment"""
|
||||
if not self.settings.LOG_PAYMENTS:
|
||||
return
|
||||
@@ -161,58 +234,89 @@ class NotificationService:
|
||||
"freekassa": "💳",
|
||||
"cryptopay": "₿",
|
||||
"stars": "⭐",
|
||||
"tribute": "💎"
|
||||
"platega": "💳",
|
||||
"severpay": "💳",
|
||||
}.get(payment_provider.lower(), "💰")
|
||||
|
||||
message = _(
|
||||
"log_payment_received",
|
||||
default="{provider_emoji} <b>Получен платеж</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"💰 Сумма: <b>{amount} {currency}</b>\n"
|
||||
"📅 Период: <b>{months} мес.</b>\n"
|
||||
"🏦 Провайдер: {payment_provider}\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
if traffic_gb is not None:
|
||||
traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}"
|
||||
message = _(
|
||||
"log_payment_received_traffic",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
traffic_gb=traffic_label,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
else:
|
||||
message = _(
|
||||
"log_payment_received",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about promo code activation"""
|
||||
if not self.settings.LOG_PROMO_ACTIVATIONS:
|
||||
return
|
||||
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
|
||||
message = _(
|
||||
"log_promo_activation",
|
||||
default="🎁 <b>Активирован промокод</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"🏷 Код: <code>{promo_code}</code>\n"
|
||||
"🎯 Бонус: <b>+{bonus_days} дн.</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
promo_code=promo_code,
|
||||
bonus_days=bonus_days,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_discount_promo_activation(self, user_id: int, promo_code: str, discount_percentage: int,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about discount promo code activation"""
|
||||
if not self.settings.LOG_PROMO_ACTIVATIONS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_promo_discount_activation",
|
||||
user_display=user_display,
|
||||
promo_code=promo_code,
|
||||
discount_percentage=discount_percentage,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
||||
username: Optional[str] = None):
|
||||
@@ -230,17 +334,14 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_trial_activation",
|
||||
default="🆓 <b>Активирован триал</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"⏰ Действует до: <b>{end_date}</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
end_date=end_date.strftime("%Y-%m-%d %H:%M"),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_panel_sync(self, status: str, details: str,
|
||||
users_processed: int, subs_synced: int,
|
||||
@@ -261,12 +362,6 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_panel_sync",
|
||||
default="{status_emoji} <b>Синхронизация с панелью</b>\n\n"
|
||||
"📊 Статус: <b>{status}</b>\n"
|
||||
"👥 Обработано пользователей: <b>{users_processed}</b>\n"
|
||||
"📋 Синхронизировано подписок: <b>{subs_synced}</b>\n"
|
||||
"🕐 Время: {timestamp}\n\n"
|
||||
"📝 Детали:\n{details}",
|
||||
status_emoji=status_emoji,
|
||||
status=status,
|
||||
users_processed=users_processed,
|
||||
@@ -275,7 +370,7 @@ class NotificationService:
|
||||
details=details
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_suspicious_promo_attempt(
|
||||
@@ -297,18 +392,14 @@ class NotificationService:
|
||||
|
||||
message = _(
|
||||
"log_suspicious_promo",
|
||||
default="⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"🆔 ID: <code>{user_id}</code>\n"
|
||||
"📝 Ввод: <pre>{suspicious_input}</pre>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=hd.quote(user_display),
|
||||
user_id=user_id,
|
||||
suspicious_input=hd.quote(suspicious_input),
|
||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
||||
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import aiohttp
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
@@ -58,6 +59,29 @@ class PanelApiService:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_payload_for_log(payload: Any) -> Any:
|
||||
if isinstance(payload, dict):
|
||||
redacted: Dict[str, Any] = {}
|
||||
for key, value in payload.items():
|
||||
lowered = str(key).lower()
|
||||
if any(mask_key in lowered for mask_key in (
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"authorization",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"key",
|
||||
)):
|
||||
redacted[key] = "***"
|
||||
else:
|
||||
redacted[key] = PanelApiService._sanitize_payload_for_log(value)
|
||||
return redacted
|
||||
if isinstance(payload, list):
|
||||
return [PanelApiService._sanitize_payload_for_log(item) for item in payload]
|
||||
return payload
|
||||
|
||||
async def _request(self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
@@ -82,8 +106,8 @@ class PanelApiService:
|
||||
if current_params:
|
||||
try:
|
||||
url_with_params_for_log += "?" + urlencode(current_params)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.debug("Failed to encode params for panel API log URL: %s", exc)
|
||||
|
||||
json_payload_for_log = kwargs.get('json') if method.upper() in [
|
||||
"POST", "PATCH", "PUT"
|
||||
@@ -91,10 +115,11 @@ class PanelApiService:
|
||||
log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}"
|
||||
if json_payload_for_log:
|
||||
try:
|
||||
payload_str = json.dumps(json_payload_for_log)
|
||||
sanitized_payload = self._sanitize_payload_for_log(json_payload_for_log)
|
||||
payload_str = json.dumps(sanitized_payload)
|
||||
log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}"
|
||||
except Exception:
|
||||
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
|
||||
log_prefix += " | Payload: <unavailable>"
|
||||
try:
|
||||
async with aiohttp_session.request(method.upper(),
|
||||
url_for_request,
|
||||
@@ -105,7 +130,8 @@ class PanelApiService:
|
||||
|
||||
log_suffix = f"| Status: {response_status}"
|
||||
|
||||
if log_full_response or not (200 <= response_status < 300):
|
||||
should_log_full_body = bool(log_full_response and self.settings.LOG_LEVEL == "DEBUG")
|
||||
if should_log_full_body or not (200 <= response_status < 300):
|
||||
try:
|
||||
parsed_json_for_log = json.loads(response_text)
|
||||
pretty_response_text = json.dumps(parsed_json_for_log,
|
||||
@@ -339,23 +365,25 @@ class PanelApiService:
|
||||
default_traffic_limit_strategy: str = "NO_RESET",
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
specific_squad_uuids: Optional[List[str]] = None,
|
||||
external_squad_uuid: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
status: str = "ACTIVE",
|
||||
log_response: bool = True) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not (6 <= len(username_on_panel) <= 34 and
|
||||
username_on_panel.replace('_', '').replace('-', '').isalnum()):
|
||||
if not (username_on_panel.startswith("tg_")
|
||||
and username_on_panel.split("tg_")[-1].isdigit()):
|
||||
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
|
||||
logging.error(msg)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"message": msg,
|
||||
"errorCode": "VALIDATION_ERROR_USERNAME"
|
||||
}
|
||||
username_is_valid = (
|
||||
3 <= len(username_on_panel) <= 36
|
||||
and re.match(r"^[A-Za-z0-9_-]+$", username_on_panel) is not None
|
||||
)
|
||||
if not username_is_valid:
|
||||
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
|
||||
logging.error(msg)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"message": msg,
|
||||
"errorCode": "VALIDATION_ERROR_USERNAME"
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expire_at_dt = now + timedelta(days=default_expire_days)
|
||||
@@ -383,6 +411,8 @@ class PanelApiService:
|
||||
)
|
||||
if specific_squad_uuids:
|
||||
payload["activeInternalSquads"] = specific_squad_uuids
|
||||
if external_squad_uuid:
|
||||
payload["externalSquadUuid"] = external_squad_uuid
|
||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||
if email: payload["email"] = email
|
||||
if description: payload["description"] = description
|
||||
@@ -399,7 +429,10 @@ class PanelApiService:
|
||||
return response
|
||||
|
||||
logging.error(
|
||||
f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}"
|
||||
"Failed to create panel user '%s'. Payload: %s, Response: %s",
|
||||
username_on_panel,
|
||||
self._sanitize_payload_for_log(payload),
|
||||
response if not log_response else "(full response logged above)",
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -421,7 +454,10 @@ class PanelApiService:
|
||||
return full_response.get("response")
|
||||
|
||||
logging.error(
|
||||
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}"
|
||||
"Failed to update user %s details on panel. Payload: %s, Response: %s",
|
||||
user_uuid,
|
||||
self._sanitize_payload_for_log(update_payload),
|
||||
full_response if not log_response else "(logged above)",
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -455,6 +491,37 @@ class PanelApiService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def delete_user_from_panel(self,
|
||||
user_uuid: str,
|
||||
log_response: bool = True) -> bool:
|
||||
"""Delete a user from the panel. Treat not-found as already deleted."""
|
||||
endpoint = f"/users/{user_uuid}"
|
||||
response_data = await self._request(
|
||||
"DELETE", endpoint, log_full_response=log_response
|
||||
)
|
||||
|
||||
if not response_data:
|
||||
logging.error(
|
||||
f"Panel API delete_user_from_panel returned no data for user {user_uuid}."
|
||||
)
|
||||
return False
|
||||
|
||||
if response_data.get("error"):
|
||||
details = response_data.get("details") or {}
|
||||
error_code = details.get("errorCode") or response_data.get("errorCode")
|
||||
if error_code in {"A062", "A040"}:
|
||||
logging.info(
|
||||
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
|
||||
)
|
||||
return True
|
||||
logging.error(
|
||||
f"Failed to delete user {user_uuid} on panel. Response: {response_data}"
|
||||
)
|
||||
return False
|
||||
|
||||
logging.info(f"Panel user {user_uuid} deleted successfully.")
|
||||
return True
|
||||
|
||||
async def get_subscription_link(
|
||||
self,
|
||||
short_uuid_or_sub_uuid: str,
|
||||
@@ -526,3 +593,20 @@ class PanelApiService:
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
return None
|
||||
|
||||
async def encrypt_happ_link(self, link_to_encrypt: str) -> Optional[str]:
|
||||
"""Encrypt a subscription link using the panel's happ crypt4 API.
|
||||
|
||||
Returns the encrypted link string or None if encryption failed.
|
||||
"""
|
||||
payload = {"linkToEncrypt": link_to_encrypt}
|
||||
response_data = await self._request(
|
||||
"POST",
|
||||
"/system/tools/happ/encrypt",
|
||||
json=payload,
|
||||
log_full_response=False
|
||||
)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response", {}).get("encryptedLink")
|
||||
logging.error(f"Failed to encrypt happ link. Response: {response_data}")
|
||||
return None
|
||||
|
||||
@@ -12,7 +12,6 @@ from .panel_api_service import PanelApiService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
|
||||
from db.dal import user_dal
|
||||
from bot.utils.date_utils import add_months
|
||||
|
||||
EVENT_MAP = {
|
||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
||||
@@ -44,128 +43,6 @@ class PanelWebhookService:
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to {user_id}: {e}")
|
||||
|
||||
async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict,
|
||||
lang: str, markup, first_name: str) -> bool:
|
||||
"""Handle expired subscription - auto-renew tribute users if no cancellation was received.
|
||||
|
||||
Returns True if an auto-renewal was performed (and renewal message sent), False otherwise.
|
||||
"""
|
||||
from db.dal import subscription_dal, payment_dal
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
auto_renewed = False
|
||||
# Check if user has tribute subscriptions that weren't cancelled
|
||||
user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||
|
||||
for sub in user_subs:
|
||||
# Check if this subscription was marked as cancelled (from tribute cancellation webhook)
|
||||
if sub.status_from_panel == 'CANCELLED':
|
||||
logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal")
|
||||
continue
|
||||
|
||||
# Check if this user has tribute payments
|
||||
last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id)
|
||||
|
||||
if last_tribute_duration is not None:
|
||||
# This user has tribute payments, auto-renew for the same duration
|
||||
logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months")
|
||||
|
||||
# Extend subscription by the last payment duration (calendar months)
|
||||
new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration)
|
||||
|
||||
# Update local DB subscription
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
'end_date': new_end_date,
|
||||
'status_from_panel': 'ACTIVE',
|
||||
'is_active': True
|
||||
}
|
||||
)
|
||||
# Update panel expiry to ensure actual service access is extended
|
||||
try:
|
||||
panel_payload = {
|
||||
"uuid": sub.panel_user_uuid,
|
||||
"expireAt": new_end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
panel_update_resp = await self.panel_service.update_user_details_on_panel(
|
||||
sub.panel_user_uuid,
|
||||
panel_payload,
|
||||
log_response=True,
|
||||
)
|
||||
if panel_update_resp:
|
||||
logging.info(
|
||||
f"Panel expiry updated for user {user_id} (panel_uuid {sub.panel_user_uuid}) to {new_end_date}"
|
||||
)
|
||||
except Exception as e_panel:
|
||||
logging.error(
|
||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {sub.panel_user_uuid}): {e_panel}")
|
||||
|
||||
# Create a succeeded payment record in DB with the same amount/currency as last tribute payment
|
||||
try:
|
||||
last_payment = await payment_dal.get_last_tribute_payment(session, user_id)
|
||||
if last_payment and last_payment.amount and last_payment.currency:
|
||||
provider_payment_id = (
|
||||
f"tribute_auto_{user_id}_{sub.subscription_id}_"
|
||||
f"{new_end_date.strftime('%Y%m%d')}"
|
||||
)
|
||||
created_payment = await payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=float(last_payment.amount),
|
||||
currency=last_payment.currency,
|
||||
months=last_tribute_duration,
|
||||
description="Auto-renewal (panel webhook)",
|
||||
provider="tribute",
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
if created_payment:
|
||||
logging.info(
|
||||
f"Auto-renew payment recorded (id={created_payment.payment_id}) for user {user_id} amount={created_payment.amount} {created_payment.currency} months={last_tribute_duration}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not create auto-renew payment for user {user_id}: previous tribute payment not found or missing amount/currency")
|
||||
except Exception as e_pay:
|
||||
logging.error(
|
||||
f"Failed to create auto-renew payment record for user {user_id}: {e_pay}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Send auto-renewal notification
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
auto_renewal_msg = _(
|
||||
"tribute_auto_renewal",
|
||||
default="🔄 <b>Подписка автоматически продлена</b>\n\n"
|
||||
"Ваша подписка Tribute была автоматически продлена на {months} мес.\n"
|
||||
"Новая дата окончания: {end_date}",
|
||||
user_name=first_name,
|
||||
months=last_tribute_duration,
|
||||
end_date=new_end_date.strftime('%Y-%m-%d')
|
||||
)
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
auto_renewal_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
auto_renewed = True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
return auto_renewed
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error handling expired subscription for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
async def handle_event(self, event_name: str, user_payload: dict):
|
||||
telegram_id = user_payload.get("telegramId")
|
||||
if not telegram_id:
|
||||
@@ -193,7 +70,7 @@ class PanelWebhookService:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
|
||||
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(session, sub)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early
|
||||
@@ -208,7 +85,7 @@ class PanelWebhookService:
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button
|
||||
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
||||
if days_left == 2:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
@@ -220,7 +97,7 @@ class PanelWebhookService:
|
||||
getattr(sub, 'auto_renew_enabled', None) if sub else None,
|
||||
getattr(sub, 'provider', None) if sub else None,
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
|
||||
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
|
||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||
await self._send_message(
|
||||
user_id,
|
||||
@@ -239,11 +116,7 @@ class PanelWebhookService:
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
elif event_name == "user.expired":
|
||||
# Check if this is a tribute user that should be auto-renewed (regardless of notification settings)
|
||||
auto_renewed = await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name)
|
||||
|
||||
# If auto-renewed via Tribute, suppress expiration notification. Otherwise, send it if enabled.
|
||||
if not auto_renewed and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
@@ -263,16 +136,19 @@ class PanelWebhookService:
|
||||
)
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
if self.settings.PANEL_WEBHOOK_SECRET:
|
||||
if not signature_header:
|
||||
return web.Response(status=403, text="no_signature")
|
||||
expected_sig = hmac.new(
|
||||
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
logging.critical("Panel webhook rejected: PANEL_WEBHOOK_SECRET is not configured")
|
||||
return web.Response(status=503, text="panel_webhook_secret_required")
|
||||
|
||||
if not signature_header:
|
||||
return web.Response(status=403, text="no_signature")
|
||||
expected_sig = hmac.new(
|
||||
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class PlategaService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
default_return_url: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
|
||||
self.merchant_id = settings.PLATEGA_MERCHANT_ID
|
||||
self.secret = settings.PLATEGA_SECRET
|
||||
self.payment_method = settings.PLATEGA_PAYMENT_METHOD
|
||||
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||
self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
|
||||
|
||||
self._timeout = ClientTimeout(total=20)
|
||||
self._session: Optional[ClientSession] = None
|
||||
self._auth_headers = {
|
||||
"X-MerchantId": self.merchant_id or "",
|
||||
"X-Secret": self.secret or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self.configured: bool = bool(
|
||||
settings.PLATEGA_ENABLED and self.merchant_id and self.secret
|
||||
)
|
||||
if not self.configured:
|
||||
logging.warning("PlategaService initialized but not fully configured. Payments disabled.")
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(timeout=self._timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def create_transaction(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
payload: Optional[str] = None,
|
||||
promo_code_service=None,
|
||||
session=None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("PlategaService is not configured. Cannot create transaction.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_amount = None
|
||||
discount_amount = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service and session:
|
||||
from db.dal import active_discount_dal
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
|
||||
price_source = (
|
||||
getattr(self.settings, "traffic_packages", {}) or {}
|
||||
if traffic_mode
|
||||
else (self.settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_amount = fallback_original
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for Platega payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"Platega discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_amount = amount / denominator
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for Platega payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
|
||||
# Update payment record with discount metadata
|
||||
try:
|
||||
await payment_dal.update_payment_discount_info(
|
||||
session,
|
||||
payment_db_id,
|
||||
original_amount,
|
||||
discount_amount,
|
||||
promo_code_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_update:
|
||||
logging.warning(
|
||||
f"Platega: failed to update discount metadata for payment {payment_db_id}: {e_update}"
|
||||
)
|
||||
|
||||
http_session = await self._get_session()
|
||||
url = f"{self.base_url}/transaction/process"
|
||||
currency_code = (currency or "RUB").upper()
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"paymentMethod": int(self.payment_method),
|
||||
"paymentDetails": {"amount": float(amount), "currency": currency_code},
|
||||
"description": description,
|
||||
"return": self.return_url,
|
||||
"failedUrl": self.failed_url,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
# Remove optional keys with falsy values to avoid validation errors
|
||||
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
|
||||
|
||||
try:
|
||||
async with http_session.post(url, json=clean_body, headers=self._auth_headers) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("Platega create_transaction: invalid JSON response: %s", response_text)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": "invalid_json",
|
||||
"raw": response_text,
|
||||
}
|
||||
|
||||
if response.status != 200:
|
||||
logging.error(
|
||||
"Platega create_transaction: API returned error (status=%s, body=%s)",
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.error("Platega create_transaction: request failed: %s", exc, exc_info=True)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="platega_disabled")
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to parse JSON: %s", exc)
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
header_merchant = request.headers.get("X-MerchantId")
|
||||
header_secret = request.headers.get("X-Secret")
|
||||
if header_merchant != self.merchant_id or header_secret != self.secret:
|
||||
logging.error("Platega webhook: invalid auth headers")
|
||||
return web.Response(status=403, text="forbidden")
|
||||
|
||||
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
|
||||
status = str(data.get("status") or "").upper()
|
||||
amount_raw = data.get("amount")
|
||||
currency = data.get("currency") or "RUB"
|
||||
|
||||
if not transaction_id or not status:
|
||||
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
|
||||
return web.Response(status=400, text="missing_fields")
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id)
|
||||
if not payment:
|
||||
logging.error("Platega webhook: payment not found for transaction %s", transaction_id)
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.status == "succeeded" and status == "CONFIRMED":
|
||||
return web.Response(text="ok")
|
||||
|
||||
payment_months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
|
||||
if status == "CONFIRMED":
|
||||
if currency:
|
||||
provider_currency = str(currency).upper()
|
||||
expected_currency = str(payment.currency or "").upper()
|
||||
if expected_currency and expected_currency != provider_currency:
|
||||
logging.error(
|
||||
"Platega webhook: currency mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_currency,
|
||||
provider_currency,
|
||||
)
|
||||
return web.Response(status=400, text="currency_mismatch")
|
||||
|
||||
if amount_raw is not None:
|
||||
try:
|
||||
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if incoming_amount != expected_amount:
|
||||
logging.error(
|
||||
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_amount,
|
||||
incoming_amount,
|
||||
)
|
||||
return web.Response(status=400, text="amount_mismatch")
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
|
||||
return web.Response(status=400, text="amount_validation_error")
|
||||
|
||||
try:
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"Platega webhook: payment %s already processed atomically",
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
promo_code_id_from_payment=payment.promo_code_id,
|
||||
provider="platega",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
)
|
||||
if not activation or not activation.get("end_date"):
|
||||
raise RuntimeError(
|
||||
f"Platega webhook: activation failed for payment {payment.payment_id}"
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("Platega webhook: failed to process payment %s: %s", transaction_id, exc, exc_info=True)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
applied_days = 0
|
||||
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
|
||||
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||
|
||||
if sale_mode == "traffic":
|
||||
text = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
|
||||
text = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=payment_months,
|
||||
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_days and final_end:
|
||||
text = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=payment_months,
|
||||
bonus_days=applied_promo_days,
|
||||
end_date=final_end.strftime("%Y-%m-%d"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=payment_months,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
payment.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to notify user %s: %s", payment.user_id, exc)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=currency,
|
||||
months=int(payment_months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
payment_provider="platega",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error("Platega webhook: failed to notify admins: %s", exc)
|
||||
|
||||
return web.Response(text="ok")
|
||||
|
||||
if status in {"CANCELED", "CANCELLED", "CHARGEBACK", "CHARGEBACKED"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
"canceled",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("Platega webhook: failed to cancel payment %s: %s", transaction_id, exc)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
try:
|
||||
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||
except Exception as exc:
|
||||
logging.debug("Platega webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
|
||||
return web.Response(text="ok_canceled")
|
||||
|
||||
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
|
||||
return web.Response(status=202, text="status_ignored")
|
||||
|
||||
|
||||
async def platega_webhook_route(request: web.Request) -> web.Response:
|
||||
service: PlategaService = request.app["platega_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -1,13 +1,14 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Tuple, Dict
|
||||
from typing import Optional, Tuple
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from db.dal import promo_code_dal, user_dal
|
||||
from db.models import PromoCode, User
|
||||
from db.dal import promo_code_dal, user_dal, active_discount_dal, payment_dal
|
||||
|
||||
from .subscription_service import SubscriptionService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
@@ -23,6 +24,122 @@ class PromoCodeService:
|
||||
self.subscription_service = subscription_service
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.discount_payment_timeout_minutes = max(
|
||||
1,
|
||||
int(getattr(settings, "DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES", 10) or 10),
|
||||
)
|
||||
self._discount_expiration_task: Optional[asyncio.Task] = None
|
||||
self._async_session_factory: Optional[sessionmaker] = None
|
||||
|
||||
async def setup_discount_expiration_worker(
|
||||
self,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> None:
|
||||
"""Attach DB session factory and start background cleanup loop."""
|
||||
self._async_session_factory = async_session_factory
|
||||
if self._discount_expiration_task and not self._discount_expiration_task.done():
|
||||
return
|
||||
self._discount_expiration_task = asyncio.create_task(
|
||||
self._discount_expiration_loop(),
|
||||
name="PromoDiscountExpirationLoop",
|
||||
)
|
||||
logging.info("PromoCodeService: started discount expiration background worker.")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Gracefully stop background workers."""
|
||||
if not self._discount_expiration_task:
|
||||
return
|
||||
self._discount_expiration_task.cancel()
|
||||
try:
|
||||
await self._discount_expiration_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logging.exception("PromoCodeService: failed while stopping expiration worker")
|
||||
finally:
|
||||
self._discount_expiration_task = None
|
||||
|
||||
async def _discount_expiration_loop(self) -> None:
|
||||
"""Periodically clears expired discount reservations and notifies users."""
|
||||
while True:
|
||||
try:
|
||||
if not self._async_session_factory:
|
||||
await asyncio.sleep(30)
|
||||
continue
|
||||
|
||||
await self._process_expired_discounts_once()
|
||||
except asyncio.CancelledError:
|
||||
logging.info("PromoCodeService: discount expiration loop cancelled.")
|
||||
raise
|
||||
except Exception:
|
||||
logging.exception("PromoCodeService: unhandled error in discount expiration loop")
|
||||
|
||||
await asyncio.sleep(30)
|
||||
|
||||
async def _process_expired_discounts_once(self) -> None:
|
||||
if not self._async_session_factory:
|
||||
return
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
notifications_to_send: list[tuple[int, str]] = []
|
||||
|
||||
async with self._async_session_factory() as session:
|
||||
expired_discounts = await active_discount_dal.get_expired_active_discounts(
|
||||
session,
|
||||
now=now_utc,
|
||||
limit=100,
|
||||
)
|
||||
if not expired_discounts:
|
||||
return
|
||||
|
||||
for expired in expired_discounts:
|
||||
cleared = await active_discount_dal.clear_active_discount_if_matches(
|
||||
session,
|
||||
user_id=expired.user_id,
|
||||
promo_code_id=expired.promo_code_id,
|
||||
expires_at_lte=now_utc,
|
||||
)
|
||||
if not cleared:
|
||||
continue
|
||||
|
||||
await promo_code_dal.decrement_promo_code_usage(session, expired.promo_code_id)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, expired.user_id)
|
||||
user_lang = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, expired.promo_code_id)
|
||||
promo_code = promo.code if promo else ""
|
||||
message_text = self.i18n.gettext(
|
||||
user_lang,
|
||||
"discount_promo_expired_need_reactivate",
|
||||
code_part=(f" (<code>{promo_code}</code>)" if promo_code else ""),
|
||||
)
|
||||
|
||||
notifications_to_send.append((expired.user_id, message_text))
|
||||
|
||||
logging.info(
|
||||
"Expired discount reservation removed: user=%s, promo=%s",
|
||||
expired.user_id,
|
||||
expired.promo_code_id,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
for user_id, message_text in notifications_to_send:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send discount expiration message to user %s",
|
||||
user_id,
|
||||
)
|
||||
|
||||
async def apply_promo_code(
|
||||
self,
|
||||
@@ -34,7 +151,7 @@ class PromoCodeService:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
|
||||
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||
promo_data = await promo_code_dal.get_active_bonus_promo_code_by_code_str(
|
||||
session, code_input_upper)
|
||||
|
||||
if not promo_data:
|
||||
@@ -83,3 +200,285 @@ class PromoCodeService:
|
||||
return False, _("error_applying_promo_bonus")
|
||||
else:
|
||||
return False, _("error_applying_promo_bonus")
|
||||
|
||||
async def apply_discount_promo_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
code_input: str,
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, int | str]:
|
||||
"""
|
||||
Apply a discount promo code (sets active discount for user).
|
||||
Returns: (success: bool, discount_percentage or error_message)
|
||||
"""
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
|
||||
# Check if user already has an active discount
|
||||
existing_discount = await active_discount_dal.get_active_discount(
|
||||
session,
|
||||
user_id,
|
||||
include_expired=True,
|
||||
)
|
||||
if existing_discount:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if existing_discount.expires_at <= now_utc:
|
||||
cleared = await active_discount_dal.clear_active_discount_if_expired(
|
||||
session,
|
||||
user_id,
|
||||
now=now_utc,
|
||||
)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(
|
||||
session,
|
||||
existing_discount.promo_code_id,
|
||||
)
|
||||
existing_discount = None
|
||||
|
||||
if existing_discount:
|
||||
# Get the promo code for the existing discount
|
||||
existing_promo = await promo_code_dal.get_promo_code_by_id(
|
||||
session, existing_discount.promo_code_id
|
||||
)
|
||||
if existing_promo:
|
||||
return False, _("discount_promo_already_active",
|
||||
code=existing_promo.code,
|
||||
discount_pct=existing_discount.discount_percentage)
|
||||
else:
|
||||
# Existing discount but promo not found - clear it and continue
|
||||
await active_discount_dal.clear_active_discount(session, user_id)
|
||||
|
||||
# Get discount promo code
|
||||
promo_data = await promo_code_dal.get_active_discount_promo_code_by_code_str(
|
||||
session, code_input_upper
|
||||
)
|
||||
|
||||
if not promo_data:
|
||||
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
|
||||
|
||||
# Check if user already used this code
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_data.promo_code_id, user_id
|
||||
)
|
||||
if existing_activation:
|
||||
return False, _("promo_code_already_used_by_user", code=code_input_upper)
|
||||
|
||||
# Reserve discount for limited time and count activation immediately
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=self.discount_payment_timeout_minutes,
|
||||
)
|
||||
active_discount = await active_discount_dal.set_active_discount(
|
||||
session,
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_data.promo_code_id,
|
||||
discount_percentage=promo_data.discount_percentage,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
if not active_discount:
|
||||
# This shouldn't happen since we checked above, but just in case
|
||||
return False, _("error_applying_promo_discount")
|
||||
|
||||
promo_incremented = await promo_code_dal.increment_promo_code_usage(
|
||||
session,
|
||||
promo_data.promo_code_id,
|
||||
)
|
||||
if not promo_incremented:
|
||||
await active_discount_dal.clear_active_discount_if_matches(
|
||||
session,
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_data.promo_code_id,
|
||||
)
|
||||
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
|
||||
|
||||
logging.info(
|
||||
f"Discount promo code {code_input_upper} activated for user {user_id}: "
|
||||
f"{promo_data.discount_percentage}% off until {expires_at.isoformat()}"
|
||||
)
|
||||
return True, promo_data.discount_percentage
|
||||
|
||||
async def get_user_active_discount(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[Tuple[int, str]]:
|
||||
"""
|
||||
Get user's active discount if any.
|
||||
Returns: (discount_percentage, promo_code) or None
|
||||
"""
|
||||
active_discount = await active_discount_dal.get_active_discount(
|
||||
session,
|
||||
user_id,
|
||||
include_expired=True,
|
||||
)
|
||||
if not active_discount:
|
||||
return None
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if active_discount.expires_at <= now_utc:
|
||||
cleared = await active_discount_dal.clear_active_discount_if_expired(
|
||||
session,
|
||||
user_id,
|
||||
now=now_utc,
|
||||
)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(
|
||||
session,
|
||||
active_discount.promo_code_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Fetch promo code for code string
|
||||
promo = await promo_code_dal.get_promo_code_by_id(
|
||||
session, active_discount.promo_code_id
|
||||
)
|
||||
if not promo:
|
||||
# Discount exists but promo not found - clear it
|
||||
await active_discount_dal.clear_active_discount(session, user_id)
|
||||
return None
|
||||
|
||||
# Check if promo code has expired
|
||||
if promo.valid_until and promo.valid_until <= datetime.now(timezone.utc):
|
||||
# Promo code expired - clear the discount
|
||||
logging.info(
|
||||
f"Promo code {promo.code} expired (valid_until: {promo.valid_until}). "
|
||||
f"Clearing active discount for user {user_id}"
|
||||
)
|
||||
cleared = await active_discount_dal.clear_active_discount(session, user_id)
|
||||
if cleared:
|
||||
await promo_code_dal.decrement_promo_code_usage(session, promo.promo_code_id)
|
||||
return None
|
||||
|
||||
return (active_discount.discount_percentage, promo.code)
|
||||
|
||||
def calculate_discounted_price(
|
||||
self,
|
||||
original_price: float,
|
||||
discount_percentage: int
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Calculate discounted price and discount amount.
|
||||
Returns: (final_price, discount_amount)
|
||||
"""
|
||||
discount_amount = round(original_price * (discount_percentage / 100), 2)
|
||||
final_price = round(original_price - discount_amount, 2)
|
||||
|
||||
# Ensure price doesn't go negative
|
||||
if final_price < 0:
|
||||
final_price = 0
|
||||
discount_amount = original_price
|
||||
|
||||
return final_price, discount_amount
|
||||
|
||||
async def consume_discount(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
payment_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Consume discount after successful payment.
|
||||
|
||||
The payment record is the source of truth. Even if the active reservation was
|
||||
concurrently expired/cleared, we still record promo activation and reconcile
|
||||
current_activations so successful discounted payments are always accounted for.
|
||||
"""
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||
if not payment_record:
|
||||
logging.warning(
|
||||
"Payment %s not found for discount consumption (user %s).",
|
||||
payment_id,
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
if not payment_record.discount_applied:
|
||||
return False
|
||||
|
||||
promo_code_id = payment_record.promo_code_id
|
||||
if not promo_code_id:
|
||||
logging.warning(
|
||||
"Payment %s for user %s has discount_applied but no promo_code_id.",
|
||||
payment_id,
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_code_id, user_id
|
||||
)
|
||||
|
||||
activation_created = False
|
||||
if existing_activation:
|
||||
if existing_activation.payment_id is None:
|
||||
updated_payment = await promo_code_dal.set_activation_payment_id(
|
||||
session, promo_code_id, user_id, payment_id
|
||||
)
|
||||
if updated_payment:
|
||||
logging.info(
|
||||
"Linked discount promo %s activation to payment %s for user %s.",
|
||||
promo_code_id,
|
||||
payment_id,
|
||||
user_id,
|
||||
)
|
||||
else:
|
||||
activation_recorded = await promo_code_dal.record_promo_activation(
|
||||
session,
|
||||
promo_code_id,
|
||||
user_id,
|
||||
payment_id=payment_id,
|
||||
)
|
||||
if not activation_recorded:
|
||||
logging.error(
|
||||
"Failed to record discount activation for user %s, promo %s.",
|
||||
user_id,
|
||||
promo_code_id,
|
||||
)
|
||||
return False
|
||||
activation_created = True
|
||||
|
||||
active_discount = await active_discount_dal.get_active_discount(
|
||||
session,
|
||||
user_id,
|
||||
include_expired=True,
|
||||
)
|
||||
|
||||
# Reservation is best-effort cleanup at this point; payment success already happened.
|
||||
if active_discount and active_discount.promo_code_id == promo_code_id:
|
||||
await active_discount_dal.clear_active_discount_if_matches(
|
||||
session,
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_code_id,
|
||||
)
|
||||
elif active_discount and active_discount.promo_code_id != promo_code_id:
|
||||
logging.info(
|
||||
"Active discount promo %s differs from payment promo %s during consumption.",
|
||||
active_discount.promo_code_id,
|
||||
promo_code_id,
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"Discount reservation already absent at consumption time (user=%s, promo=%s, payment=%s)",
|
||||
user_id,
|
||||
promo_code_id,
|
||||
payment_id,
|
||||
)
|
||||
|
||||
# If reservation was already expired/removed and we had to create activation now,
|
||||
# restore current_activations to match the successful payment.
|
||||
if activation_created:
|
||||
await promo_code_dal.increment_promo_code_usage(
|
||||
session,
|
||||
promo_code_id,
|
||||
allow_overflow=True,
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
logging.info(
|
||||
"Discount consumed for user %s, promo %s, payment %s",
|
||||
user_id,
|
||||
promo_code_id,
|
||||
payment_id,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -32,6 +32,13 @@ class ReferralService:
|
||||
current_payment_db_id: Optional[int] = None,
|
||||
skip_if_active_before_payment: bool = True) -> Dict[str, Any]:
|
||||
|
||||
if not getattr(self.settings, "REFERRAL_ENABLED", True):
|
||||
return {
|
||||
"referee_bonus_applied_days": None,
|
||||
"referee_new_end_date": None,
|
||||
"inviter_bonus_applied_flag": False,
|
||||
}
|
||||
|
||||
referee_final_end_date: Optional[datetime] = None
|
||||
referee_bonus_applied_days: Optional[int] = None
|
||||
inviter_bonus_successfully_applied = False
|
||||
@@ -177,6 +184,8 @@ class ReferralService:
|
||||
"ACTIVE_BONUS",
|
||||
"traffic_limit_bytes":
|
||||
self.settings.user_traffic_limit_bytes,
|
||||
"auto_renew_enabled":
|
||||
False,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
@@ -255,9 +264,38 @@ class ReferralService:
|
||||
|
||||
raise
|
||||
|
||||
def generate_referral_link(self, bot_username: str,
|
||||
inviter_user_id: int) -> str:
|
||||
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
|
||||
async def generate_referral_link(self, session: AsyncSession,
|
||||
bot_username: str,
|
||||
inviter_user_id: int) -> Optional[str]:
|
||||
if not getattr(self.settings, "REFERRAL_ENABLED", True):
|
||||
return None
|
||||
|
||||
try:
|
||||
user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||
if not user:
|
||||
logging.warning(
|
||||
"Unable to generate referral link: user %s not found.",
|
||||
inviter_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
referral_code = await user_dal.ensure_referral_code(session, user)
|
||||
if not referral_code:
|
||||
logging.warning(
|
||||
"User %s has no referral code even after regeneration attempt.",
|
||||
inviter_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
return f"https://t.me/{bot_username}?start=ref_u{referral_code}"
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s: %s",
|
||||
inviter_user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
|
||||
"""Get referral statistics for a user"""
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import hmac
|
||||
import hashlib
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class SeverPayService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
default_return_url: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip("/")
|
||||
self.mid = settings.SEVERPAY_MID
|
||||
self.token = settings.SEVERPAY_TOKEN or ""
|
||||
self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||
self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES
|
||||
|
||||
self._timeout = ClientTimeout(total=15)
|
||||
self._session: Optional[ClientSession] = None
|
||||
|
||||
self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token)
|
||||
if not self.configured:
|
||||
logging.warning("SeverPayService initialized but not fully configured. Payments disabled.")
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(timeout=self._timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount: float) -> str:
|
||||
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
return f"{quantized:.2f}"
|
||||
|
||||
def _sign_payload(self, payload: Dict[str, Any]) -> str:
|
||||
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
return hmac.new(self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {
|
||||
"mid": self.mid,
|
||||
"salt": secrets.token_hex(8),
|
||||
}
|
||||
body.update(extra)
|
||||
sorted_body = dict(sorted(body.items()))
|
||||
sorted_body["sign"] = self._sign_payload(sorted_body)
|
||||
return sorted_body
|
||||
|
||||
def _validate_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
provided_sign = str(payload.get("sign") or "")
|
||||
if not provided_sign or not self.token:
|
||||
return False
|
||||
# Webhook signatures are calculated on the original payload order (without sorting).
|
||||
data = {k: v for k, v in payload.items() if k != "sign"}
|
||||
expected_sign = self._sign_payload(data)
|
||||
return hmac.compare_digest(provided_sign, expected_sign)
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
promo_code_service=None,
|
||||
session=None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("SeverPayService is not configured. Cannot create payment.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
# Check for active discount to save metadata (price already discounted from previous step)
|
||||
original_amount = None
|
||||
discount_amount = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service and session:
|
||||
from db.dal import active_discount_dal
|
||||
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
||||
if active_discount:
|
||||
# Price is already discounted, calculate original price backwards
|
||||
discount_pct = active_discount.discount_percentage
|
||||
promo_code_id = active_discount.promo_code_id
|
||||
denominator = 1 - discount_pct / 100
|
||||
if denominator <= 0:
|
||||
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
|
||||
price_source = (
|
||||
getattr(self.settings, "traffic_packages", {}) or {}
|
||||
if traffic_mode
|
||||
else (self.settings.subscription_options or {})
|
||||
)
|
||||
fallback_original = price_source.get(months)
|
||||
if fallback_original is not None:
|
||||
original_amount = fallback_original
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for SeverPay payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"SeverPay discount %s%% has invalid denominator and no fallback price for months=%s.",
|
||||
discount_pct,
|
||||
months,
|
||||
)
|
||||
else:
|
||||
original_amount = amount / denominator
|
||||
discount_amount = original_amount - amount
|
||||
logging.info(
|
||||
f"Recording {discount_pct}% discount for SeverPay payment: "
|
||||
f"original {original_amount:.2f} -> final {amount}"
|
||||
)
|
||||
|
||||
# Update payment record with discount metadata
|
||||
try:
|
||||
await payment_dal.update_payment_discount_info(
|
||||
session,
|
||||
payment_db_id,
|
||||
original_amount,
|
||||
discount_amount,
|
||||
promo_code_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_update:
|
||||
logging.warning(
|
||||
f"SeverPay: failed to update discount metadata for payment {payment_db_id}: {e_update}"
|
||||
)
|
||||
|
||||
http_session = await self._get_session()
|
||||
url = f"{self.base_url}/payin/create"
|
||||
currency_code = (currency or "RUB").upper()
|
||||
amount_str = self._format_amount(amount)
|
||||
|
||||
body = {
|
||||
"order_id": str(payment_db_id),
|
||||
"amount": amount_str,
|
||||
"currency": currency_code,
|
||||
"client_email": f"{user_id}@telegram.org",
|
||||
"client_id": str(user_id),
|
||||
"url_return": self.return_url,
|
||||
}
|
||||
|
||||
if self.lifetime_minutes:
|
||||
body["lifetime"] = int(self.lifetime_minutes)
|
||||
|
||||
signed_body = self._build_signed_body(body)
|
||||
|
||||
try:
|
||||
async with http_session.post(url, json=signed_body) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("SeverPay create_payment: invalid JSON response: %s", response_text)
|
||||
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
|
||||
|
||||
if response.status != 200 or not response_data.get("status"):
|
||||
logging.error(
|
||||
"SeverPay create_payment: API returned error (status=%s, body=%s)",
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
|
||||
return True, response_data.get("data") or response_data
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay create_payment: request failed: %s", exc, exc_info=True)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay webhook: failed to parse JSON: %s", exc)
|
||||
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
|
||||
|
||||
if not isinstance(payload, dict) or not self._validate_signature(payload):
|
||||
logging.error("SeverPay webhook: invalid signature or payload.")
|
||||
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
|
||||
|
||||
event_type = str(payload.get("type") or "").lower()
|
||||
data = payload.get("data") or {}
|
||||
|
||||
if event_type != "payin" or not isinstance(data, dict):
|
||||
logging.warning("SeverPay webhook: unsupported event type '%s'", event_type)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
provider_payment_id = str(data.get("id") or data.get("uid") or "")
|
||||
order_id_raw = data.get("order_id")
|
||||
status = str(data.get("status") or "").lower()
|
||||
amount_raw = data.get("amount")
|
||||
currency_raw = data.get("currency")
|
||||
|
||||
payment_db_id: Optional[int] = None
|
||||
try:
|
||||
if isinstance(order_id_raw, int):
|
||||
payment_db_id = order_id_raw
|
||||
elif isinstance(order_id_raw, str) and order_id_raw.isdigit():
|
||||
payment_db_id = int(order_id_raw)
|
||||
except Exception:
|
||||
payment_db_id = None
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = None
|
||||
if payment_db_id is not None:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment and provider_payment_id:
|
||||
payment = await payment_dal.get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||
|
||||
if not payment:
|
||||
logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id)
|
||||
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||
|
||||
if payment.status == "succeeded" and status == "success":
|
||||
logging.info("SeverPay webhook: payment %s already succeeded", payment.payment_id)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status == "success" and amount_raw is not None:
|
||||
try:
|
||||
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if incoming_amount != expected_amount:
|
||||
logging.error(
|
||||
"SeverPay webhook: amount mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_amount,
|
||||
incoming_amount,
|
||||
)
|
||||
return web.json_response({"status": False, "msg": "amount_mismatch"}, status=400)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"SeverPay webhook: failed to compare amounts for payment %s: %s",
|
||||
payment.payment_id,
|
||||
exc,
|
||||
)
|
||||
return web.json_response({"status": False, "msg": "amount_validation_error"}, status=400)
|
||||
|
||||
if currency_raw:
|
||||
provider_currency = str(currency_raw).upper()
|
||||
expected_currency = str(payment.currency or "").upper()
|
||||
if expected_currency and provider_currency != expected_currency:
|
||||
logging.error(
|
||||
"SeverPay webhook: currency mismatch for payment %s (expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
expected_currency,
|
||||
provider_currency,
|
||||
)
|
||||
return web.json_response({"status": False, "msg": "currency_mismatch"}, status=400)
|
||||
|
||||
payment_months = payment.subscription_duration_months or 1
|
||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
if status == "success":
|
||||
try:
|
||||
provider_id = provider_payment_id or str(payment.payment_id)
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_id,
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"SeverPay webhook: payment %s already processed atomically",
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months) if sale_mode != "traffic" else 0,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
promo_code_id_from_payment=payment.promo_code_id,
|
||||
provider="severpay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(payment_months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("SeverPay webhook: failed to process payment %s: %s", provider_payment_id, exc, exc_info=True)
|
||||
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
applied_days = 0
|
||||
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
|
||||
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||
|
||||
if sale_mode == "traffic":
|
||||
text = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
|
||||
text = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=payment_months,
|
||||
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_days and final_end:
|
||||
text = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=payment_months,
|
||||
bonus_days=applied_promo_days,
|
||||
end_date=final_end.strftime("%Y-%m-%d"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=payment_months,
|
||||
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
payment.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay webhook: failed to notify user %s: %s", payment.user_id, exc)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=payment.currency,
|
||||
months=int(payment_months) if sale_mode != "traffic" else 0,
|
||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
||||
payment_provider="severpay",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error("SeverPay webhook: failed to notify admins: %s", exc)
|
||||
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status in {"fail", "decline"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_payment_id or str(payment.payment_id),
|
||||
"failed",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("SeverPay webhook: failed to mark payment %s as failed: %s", provider_payment_id, exc)
|
||||
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
try:
|
||||
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||
except Exception as exc:
|
||||
logging.debug("SeverPay webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status in {"process", "new"}:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
provider_payment_id or str(payment.payment_id),
|
||||
"pending_severpay",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logging.error("SeverPay webhook: failed to update pending status for %s: %s", provider_payment_id, exc)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
|
||||
async def severpay_webhook_route(request: web.Request) -> web.Response:
|
||||
service: SeverPayService = request.app["severpay_service"]
|
||||
return await service.webhook_route(request)
|
||||
+156
-42
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot, types
|
||||
@@ -13,6 +14,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
|
||||
|
||||
class StarsService:
|
||||
@@ -25,16 +27,94 @@ class StarsService:
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
|
||||
stars_price: int, description: str) -> Optional[int]:
|
||||
def _resolve_base_stars_price(self, months: float, sale_mode: str) -> Optional[int]:
|
||||
stars_price_source = (
|
||||
self.settings.stars_traffic_packages
|
||||
if sale_mode == "traffic"
|
||||
else self.settings.stars_subscription_options
|
||||
)
|
||||
|
||||
if sale_mode != "traffic":
|
||||
months_key = int(months) if float(months).is_integer() else months
|
||||
base_price = stars_price_source.get(months_key)
|
||||
if base_price is not None:
|
||||
return base_price
|
||||
|
||||
if float(months).is_integer():
|
||||
return stars_price_source.get(float(months_key))
|
||||
|
||||
return None
|
||||
|
||||
base_price = stars_price_source.get(months)
|
||||
if base_price is not None:
|
||||
return base_price
|
||||
|
||||
for package_size, package_price in stars_price_source.items():
|
||||
if math.isclose(float(package_size), float(months), rel_tol=0.0, abs_tol=1e-9):
|
||||
return package_price
|
||||
|
||||
return None
|
||||
|
||||
async def create_invoice(self, session: AsyncSession, user_id: int, months: float,
|
||||
stars_price: int, description: str, sale_mode: str = "subscription",
|
||||
promo_code_service=None) -> Optional[int]:
|
||||
# Always resolve base price server-side and reject unknown packages.
|
||||
resolved_base_price = self._resolve_base_stars_price(months, sale_mode)
|
||||
if resolved_base_price is None:
|
||||
logging.warning(
|
||||
"Stars invoice rejected: base price not found for sale_mode=%s months=%s.",
|
||||
sale_mode,
|
||||
months,
|
||||
)
|
||||
return None
|
||||
|
||||
original_stars_price = int(resolved_base_price)
|
||||
|
||||
# Detect callback tampering (or stale callback payload) and prefer server-side price.
|
||||
if int(stars_price) != original_stars_price:
|
||||
logging.warning(
|
||||
"Stars callback price mismatch for user %s: callback=%s, resolved=%s, sale_mode=%s, months=%s",
|
||||
user_id,
|
||||
stars_price,
|
||||
original_stars_price,
|
||||
sale_mode,
|
||||
months,
|
||||
)
|
||||
|
||||
# Invoice amount starts from the base price and discount is applied once.
|
||||
stars_price = original_stars_price
|
||||
discount_amount_stars = None
|
||||
promo_code_id = None
|
||||
|
||||
if promo_code_service:
|
||||
# Import here to avoid circular import
|
||||
from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment
|
||||
|
||||
# Apply discount and round up using ceiling
|
||||
final_price_float, discount_float, promo_code_id = await apply_discount_to_payment(
|
||||
session, user_id, float(original_stars_price), promo_code_service
|
||||
)
|
||||
if discount_float:
|
||||
stars_price = math.ceil(final_price_float)
|
||||
discount_amount_stars = original_stars_price - stars_price
|
||||
logging.info(
|
||||
"Stars discount applied: %s -> %.2f -> %s (ceiling)",
|
||||
original_stars_price,
|
||||
final_price_float,
|
||||
stars_price,
|
||||
)
|
||||
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": float(stars_price),
|
||||
"original_amount": float(original_stars_price) if discount_amount_stars else None,
|
||||
"discount_applied": float(discount_amount_stars) if discount_amount_stars else None,
|
||||
"currency": "XTR",
|
||||
"status": "pending_stars",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "telegram_stars",
|
||||
"promo_code_id": promo_code_id,
|
||||
}
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(
|
||||
@@ -46,7 +126,7 @@ class StarsService:
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
payload = f"{db_payment_record.payment_id}:{months}"
|
||||
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
|
||||
prices = [LabeledPrice(label=description, amount=stars_price)]
|
||||
try:
|
||||
await self.bot.send_invoice(
|
||||
@@ -54,7 +134,7 @@ class StarsService:
|
||||
title=description,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
provider_token=self.settings.STARS_PROVIDER_TOKEN or "",
|
||||
currency="XTR",
|
||||
prices=prices,
|
||||
)
|
||||
@@ -69,42 +149,63 @@ class StarsService:
|
||||
payment_db_id: int,
|
||||
months: int,
|
||||
stars_amount: int,
|
||||
i18n_data: dict) -> None:
|
||||
i18n_data: dict,
|
||||
sale_mode: str = "subscription") -> None:
|
||||
# Fetch payment record to get promo_code_id
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
promo_code_id_from_payment = payment_record.promo_code_id if payment_record else None
|
||||
|
||||
activation_details = None
|
||||
referral_bonus = None
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session, payment_db_id,
|
||||
message.successful_payment.provider_payment_charge_id,
|
||||
"succeeded")
|
||||
provider_payment_id = str(
|
||||
message.successful_payment.provider_payment_charge_id
|
||||
or f"stars:{payment_db_id}"
|
||||
)
|
||||
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||
session,
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
)
|
||||
if not marked:
|
||||
logging.info(
|
||||
"Stars payment %s already processed atomically",
|
||||
payment_db_id,
|
||||
)
|
||||
return
|
||||
|
||||
activation_details = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
message.from_user.id,
|
||||
int(months) if sale_mode != "traffic" else 0,
|
||||
float(stars_amount),
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id_from_payment,
|
||||
provider="telegram_stars",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
if not activation_details or not activation_details.get("end_date"):
|
||||
raise RuntimeError(
|
||||
f"Failed to activate subscription after stars payment {payment_db_id}"
|
||||
)
|
||||
|
||||
if sale_mode != "traffic":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
message.from_user.id,
|
||||
int(months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_upd:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update stars payment record {payment_db_id}: {e_upd}",
|
||||
f"Failed to process stars payment record {payment_db_id}: {e_upd}",
|
||||
exc_info=True)
|
||||
return
|
||||
|
||||
activation_details = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
message.from_user.id,
|
||||
months,
|
||||
float(stars_amount),
|
||||
payment_db_id,
|
||||
provider="telegram_stars",
|
||||
)
|
||||
if not activation_details or not activation_details.get("end_date"):
|
||||
logging.error(
|
||||
f"Failed to activate subscription after stars payment for user {message.from_user.id}")
|
||||
return
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
message.from_user.id,
|
||||
months,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None
|
||||
final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None
|
||||
if not final_end:
|
||||
@@ -116,11 +217,18 @@ class StarsService:
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
raw_config_link = activation_details.get("subscription_url") if activation_details else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
if applied_days:
|
||||
if sale_mode == "traffic":
|
||||
success_msg = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}",
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
if db_user and db_user.referred_by_id:
|
||||
@@ -138,17 +246,22 @@ class StarsService:
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, self.settings, config_link, preserve_message=True
|
||||
current_lang,
|
||||
i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
@@ -170,9 +283,10 @@ class StarsService:
|
||||
user_id=message.from_user.id,
|
||||
amount=float(stars_amount),
|
||||
currency="XTR",
|
||||
months=months,
|
||||
months=int(months) if sale_mode != "traffic" else 0,
|
||||
payment_provider="stars",
|
||||
username=user.username if user else None
|
||||
username=user.username if user else None,
|
||||
traffic_gb=months if sale_mode == "traffic" else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send stars payment notification: {e}")
|
||||
|
||||
@@ -5,8 +5,9 @@ from typing import Optional, Dict, Any, List, Tuple
|
||||
from aiogram import Bot
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, user_billing_dal, payment_dal
|
||||
from bot.utils.date_utils import add_months
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from db.models import User, Subscription
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -125,6 +126,7 @@ class SubscriptionService:
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]),
|
||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
@@ -153,6 +155,7 @@ class SubscriptionService:
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]),
|
||||
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
|
||||
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
@@ -417,6 +420,119 @@ class SubscriptionService:
|
||||
"subscription_url": final_subscription_url,
|
||||
}
|
||||
|
||||
async def _activate_traffic_package(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
traffic_gb: float,
|
||||
payment_amount: float,
|
||||
payment_db_id: int,
|
||||
provider: str = "yookassa",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Activate or extend a traffic-based package instead of a time-based subscription."""
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error("User %s not found for traffic package activation", user_id)
|
||||
return None
|
||||
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, _ = (
|
||||
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
|
||||
)
|
||||
|
||||
if not panel_user_uuid or not panel_sub_link_id:
|
||||
logging.error("Failed to ensure panel linkage for user %s during traffic activation", user_id)
|
||||
return None
|
||||
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
|
||||
traffic_info = panel_user_data.get("userTraffic") or {}
|
||||
current_limit = panel_user_data.get("trafficLimitBytes")
|
||||
current_used = traffic_info.get("usedTrafficBytes")
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
if current_limit is None and active_sub:
|
||||
current_limit = active_sub.traffic_limit_bytes
|
||||
if current_used is None and active_sub:
|
||||
current_used = active_sub.traffic_used_bytes
|
||||
|
||||
purchase_bytes = int(float(traffic_gb) * (1024**3))
|
||||
new_limit = (current_limit or 0) + purchase_bytes
|
||||
|
||||
start_date = datetime.now(timezone.utc)
|
||||
# Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further.
|
||||
far_future = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
final_end_date = far_future
|
||||
if active_sub and active_sub.end_date and active_sub.end_date > final_end_date:
|
||||
final_end_date = active_sub.end_date
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date,
|
||||
"end_date": final_end_date,
|
||||
"duration_months": 0,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE",
|
||||
"traffic_limit_bytes": new_limit,
|
||||
"traffic_used_bytes": current_used,
|
||||
"provider": provider,
|
||||
"skip_notifications": True,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload)
|
||||
except Exception as exc:
|
||||
logging.error("Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True)
|
||||
return None
|
||||
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
panel_user_uuid=panel_user_uuid,
|
||||
expire_at=final_end_date,
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=new_limit,
|
||||
traffic_limit_strategy="NO_RESET",
|
||||
)
|
||||
|
||||
panel_update_payload["description"] = "\n".join(
|
||||
[
|
||||
(db_user.username or "") if db_user else "",
|
||||
(db_user.first_name or "") if db_user else "",
|
||||
(db_user.last_name or "") if db_user else "",
|
||||
]
|
||||
)
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload
|
||||
)
|
||||
if not updated_panel_user or updated_panel_user.get("error"):
|
||||
logging.warning(
|
||||
"Panel user details update FAILED for traffic package user %s. Response: %s",
|
||||
panel_user_uuid,
|
||||
updated_panel_user,
|
||||
)
|
||||
return None
|
||||
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
"is_active": True,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_short_uuid": final_panel_short_uuid,
|
||||
"subscription_url": final_subscription_url,
|
||||
"applied_promo_bonus_days": 0,
|
||||
"traffic_limit_bytes": new_limit,
|
||||
}
|
||||
|
||||
async def activate_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -426,8 +542,21 @@ class SubscriptionService:
|
||||
payment_db_id: int,
|
||||
promo_code_id_from_payment: Optional[int] = None,
|
||||
provider: str = "yookassa",
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if sale_mode == "traffic" or getattr(self.settings, "traffic_sale_mode", False):
|
||||
target_gb = traffic_gb if traffic_gb is not None else float(months)
|
||||
return await self._activate_traffic_package(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
traffic_gb=target_gb,
|
||||
payment_amount=payment_amount,
|
||||
payment_db_id=payment_db_id,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
@@ -445,6 +574,11 @@ class SubscriptionService:
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
months_int = int(months)
|
||||
except Exception:
|
||||
months_int = 1
|
||||
|
||||
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
@@ -457,7 +591,7 @@ class SubscriptionService:
|
||||
start_date = current_active_sub.end_date
|
||||
|
||||
# base duration by months
|
||||
end_after_months = add_months(start_date, months)
|
||||
end_after_months = add_months(start_date, months_int)
|
||||
duration_days_total = (end_after_months - start_date).days
|
||||
applied_promo_bonus_days = 0
|
||||
|
||||
@@ -470,7 +604,7 @@ class SubscriptionService:
|
||||
and promo_model.is_active
|
||||
and promo_model.current_activations < promo_model.max_activations
|
||||
):
|
||||
applied_promo_bonus_days = promo_model.bonus_days
|
||||
applied_promo_bonus_days = promo_model.bonus_days or 0
|
||||
duration_days_total += applied_promo_bonus_days
|
||||
|
||||
activation = await promo_code_dal.record_promo_activation(
|
||||
@@ -481,7 +615,7 @@ class SubscriptionService:
|
||||
)
|
||||
if activation:
|
||||
await promo_code_dal.increment_promo_code_usage(
|
||||
session, promo_code_id_from_payment
|
||||
session, promo_code_id_from_payment, allow_overflow=True
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
@@ -498,19 +632,25 @@ class SubscriptionService:
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
auto_renew_should_enable = False
|
||||
if provider == "yookassa" and self.settings.yookassa_autopayments_active:
|
||||
auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method(
|
||||
session, user_id
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date,
|
||||
"end_date": final_end_date,
|
||||
"duration_months": months,
|
||||
"duration_months": months_int,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE",
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"provider": provider,
|
||||
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
||||
"auto_renew_enabled": True,
|
||||
"skip_notifications": False,
|
||||
"auto_renew_enabled": auto_renew_should_enable,
|
||||
}
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||
@@ -551,6 +691,22 @@ class SubscriptionService:
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
# Consume discount promo code if payment had one
|
||||
try:
|
||||
promo_code_service = getattr(self, "promo_code_service", None)
|
||||
if not promo_code_service:
|
||||
from .promo_code_service import PromoCodeService
|
||||
|
||||
promo_code_service = PromoCodeService(
|
||||
self.settings, self, self.bot, self.i18n
|
||||
)
|
||||
await promo_code_service.consume_discount(session, user_id, payment_db_id)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to consume discount for user {user_id}, payment {payment_db_id}: {e}"
|
||||
)
|
||||
# Don't fail the subscription activation if discount consumption fails
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
@@ -616,6 +772,7 @@ class SubscriptionService:
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": traffic_limit,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_uuid
|
||||
@@ -705,7 +862,8 @@ class SubscriptionService:
|
||||
update_payload_local = {}
|
||||
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
||||
panel_expire_at_str = panel_user_data.get("expireAt")
|
||||
panel_traffic_used = panel_user_data.get("usedTrafficBytes")
|
||||
traffic_stats = panel_user_data.get("userTraffic") or {}
|
||||
panel_traffic_used = traffic_stats.get("usedTrafficBytes")
|
||||
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
||||
panel_sub_uuid_from_panel = panel_user_data.get(
|
||||
"subscriptionUuid"
|
||||
@@ -759,6 +917,8 @@ class SubscriptionService:
|
||||
if panel_user_data.get("expireAt")
|
||||
else None
|
||||
)
|
||||
config_link_raw = panel_user_data.get("subscriptionUrl")
|
||||
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
|
||||
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||
if hwid_limit is None:
|
||||
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
||||
@@ -767,9 +927,10 @@ class SubscriptionService:
|
||||
"user_id": panel_user_data.get("uuid"),
|
||||
"end_date": panel_end_date,
|
||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||
"config_link": panel_user_data.get("subscriptionUrl"),
|
||||
"config_link": display_link,
|
||||
"connect_button_url": connect_button_url,
|
||||
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
||||
"traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"),
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
"max_devices": hwid_limit,
|
||||
@@ -813,13 +974,16 @@ class SubscriptionService:
|
||||
sub: Subscription,
|
||||
) -> bool:
|
||||
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure."""
|
||||
if getattr(self.settings, "traffic_sale_mode", False):
|
||||
logging.info("Auto-renew skipped: traffic sale mode enabled")
|
||||
return True
|
||||
if not sub.auto_renew_enabled:
|
||||
return True
|
||||
# If autopayments are disabled globally, skip charging attempts
|
||||
if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
if not self.settings.yookassa_autopayments_active:
|
||||
return True
|
||||
if sub.provider == "tribute":
|
||||
# Tribute is paid externally; we do not auto-charge here
|
||||
if sub.provider != "yookassa":
|
||||
logging.info("Auto-renew skipped: provider %s does not support auto-renew", sub.provider)
|
||||
return True
|
||||
|
||||
from db.dal.user_billing_dal import get_user_default_payment_method
|
||||
@@ -843,15 +1007,30 @@ class SubscriptionService:
|
||||
logging.error(f"Auto-renew price missing for {months} months")
|
||||
return False
|
||||
|
||||
payment_description = f"Auto-renewal for {months} months"
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": sub.user_id,
|
||||
"amount": float(amount),
|
||||
"currency": "RUB",
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months),
|
||||
"provider": "yookassa",
|
||||
},
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"user_id": str(sub.user_id),
|
||||
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
}
|
||||
resp = await yk.create_payment(
|
||||
amount=float(amount),
|
||||
currency="RUB",
|
||||
description=f"Auto-renewal for {months} months",
|
||||
description=payment_description,
|
||||
metadata=metadata,
|
||||
payment_method_id=default_pm.provider_payment_method_id,
|
||||
save_payment_method=False,
|
||||
@@ -860,6 +1039,14 @@ class SubscriptionService:
|
||||
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
|
||||
logging.error(f"Auto-renew create_payment failed: {resp}")
|
||||
return False
|
||||
provider_payment_id = resp.get("id")
|
||||
if provider_payment_id:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_db_id=payment_record.payment_id,
|
||||
provider_payment_id=provider_payment_id,
|
||||
new_status="pending_yookassa",
|
||||
)
|
||||
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
|
||||
return True
|
||||
|
||||
@@ -892,6 +1079,7 @@ class SubscriptionService:
|
||||
status: Optional[str] = None,
|
||||
traffic_limit_bytes: Optional[int] = None,
|
||||
include_uuid: bool = True,
|
||||
traffic_limit_strategy: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if include_uuid and panel_user_uuid:
|
||||
@@ -902,7 +1090,9 @@ class SubscriptionService:
|
||||
payload["status"] = status
|
||||
if traffic_limit_bytes is not None:
|
||||
payload["trafficLimitBytes"] = traffic_limit_bytes
|
||||
payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
|
||||
payload["trafficLimitStrategy"] = traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY
|
||||
if self.settings.parsed_user_squad_uuids:
|
||||
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
|
||||
if self.settings.parsed_user_external_squad_uuid:
|
||||
payload["externalSquadUuid"] = self.settings.parsed_user_external_squad_uuid
|
||||
return payload
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal, subscription_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
def convert_period_to_months(period: Optional[str]) -> int:
|
||||
"""Map Tribute subscription period strings to months."""
|
||||
if not period:
|
||||
return 1
|
||||
|
||||
mapping = {
|
||||
"monthly": 1,
|
||||
"quarterly": 3,
|
||||
"3-month": 3,
|
||||
"3months": 3,
|
||||
"3-months": 3,
|
||||
"q": 3,
|
||||
"halfyearly": 6,
|
||||
"yearly": 12,
|
||||
"annual": 12,
|
||||
"y": 12,
|
||||
}
|
||||
return mapping.get(period.lower(), 1)
|
||||
|
||||
|
||||
class TributeService:
|
||||
def __init__(
|
||||
self,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
settings = self.settings
|
||||
bot = self.bot
|
||||
i18n = self.i18n
|
||||
async_session_factory = self.async_session_factory
|
||||
subscription_service = self.subscription_service
|
||||
referral_service = self.referral_service
|
||||
|
||||
def ok(data: Optional[dict] = None) -> web.Response:
|
||||
payload = {"status": "ok"}
|
||||
if data:
|
||||
payload.update(data)
|
||||
return web.json_response(payload, status=200)
|
||||
|
||||
def ignored(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "ignored", "reason": reason}, status=200)
|
||||
|
||||
def bad_request(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "error", "reason": reason}, status=400)
|
||||
|
||||
if settings.TRIBUTE_API_KEY:
|
||||
if not signature_header:
|
||||
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
|
||||
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
|
||||
hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
except Exception:
|
||||
return bad_request("invalid_json")
|
||||
|
||||
logging.info(
|
||||
"Tribute webhook data: %s",
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# Tribute webhook spec: only two events are sent
|
||||
# name: new_subscription | cancelled_subscription
|
||||
event_name = payload.get("name")
|
||||
data = payload.get("payload", {})
|
||||
|
||||
# Mandatory routing fields
|
||||
user_id = data.get("telegram_user_id")
|
||||
if not user_id:
|
||||
# Permanent format issue — acknowledge to avoid retries
|
||||
return ignored("missing_telegram_user_id")
|
||||
|
||||
period_val = data.get("period")
|
||||
months = convert_period_to_months(period_val)
|
||||
|
||||
# Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting.
|
||||
amount_value = data.get("amount") or data.get("price")
|
||||
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
if amount_value is not None:
|
||||
try:
|
||||
amount_minor_units = float(amount_value)
|
||||
except (TypeError, ValueError):
|
||||
amount_minor_units = 0.0
|
||||
amount_float = round(amount_minor_units / 100.0, 2)
|
||||
else:
|
||||
amount_float = 0.0
|
||||
|
||||
async with async_session_factory() as session:
|
||||
if event_name == "new_subscription":
|
||||
# Use a unique, idempotent provider payment id per webhook event
|
||||
# Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix
|
||||
candidate_event_id = (
|
||||
str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "")
|
||||
)
|
||||
if candidate_event_id:
|
||||
provider_payment_id = candidate_event_id
|
||||
else:
|
||||
# Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event
|
||||
sub_id_part = str(data.get("subscription_id") or "sub")
|
||||
payload_hash = hashlib.sha256(raw_body).hexdigest()[:16]
|
||||
provider_payment_id = f"{sub_id_part}:{payload_hash}"
|
||||
|
||||
# Idempotent ensure payment
|
||||
payment_record = await payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=int(user_id),
|
||||
amount=amount_float,
|
||||
currency=currency,
|
||||
months=months,
|
||||
description="Tribute subscription",
|
||||
provider="tribute",
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
int(user_id),
|
||||
months,
|
||||
float(amount_float),
|
||||
payment_record.payment_id,
|
||||
provider="tribute",
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
int(user_id),
|
||||
months,
|
||||
current_payment_db_id=payment_record.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, int(user_id))
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
|
||||
final_end = (referral_bonus.get('referee_new_end_date')
|
||||
if referral_bonus else None)
|
||||
if not final_end:
|
||||
final_end = activation_details.get('end_date')
|
||||
|
||||
if final_end:
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if applied_ref_days:
|
||||
inviter_name_display = _('friend_placeholder')
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_ref_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link,
|
||||
preserve_message=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Use user's DB language in success messages prepared above
|
||||
await bot.send_message(
|
||||
int(user_id),
|
||||
success_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send Tribute payment success message to user {user_id}: {e}")
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
user = await user_dal.get_user_by_id(session, int(user_id))
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=int(user_id),
|
||||
amount=float(amount_float),
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider="tribute",
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send tribute payment notification: {e}")
|
||||
elif event_name == "cancelled_subscription":
|
||||
await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
|
||||
|
||||
else:
|
||||
await session.commit()
|
||||
# Acknowledge to Tribute that webhook was received and processed/accepted
|
||||
return ok({"event": event_name or "unknown"})
|
||||
|
||||
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
|
||||
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from db.dal import subscription_dal, user_dal
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
|
||||
try:
|
||||
grace_days = 1
|
||||
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
||||
|
||||
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||
|
||||
panel_users_updated: set[str] = set()
|
||||
for sub in active_subscriptions:
|
||||
updated_sub = await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
"end_date": grace_end,
|
||||
"status_from_panel": "CANCELLED",
|
||||
"skip_notifications": True,
|
||||
},
|
||||
)
|
||||
|
||||
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
|
||||
if panel_uuid and panel_uuid not in panel_users_updated:
|
||||
panel_users_updated.add(panel_uuid)
|
||||
panel_payload = {
|
||||
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
}
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
panel_payload,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as panel_err:
|
||||
logging.error(
|
||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Send notification about cancellation if enabled
|
||||
if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k
|
||||
markup = get_subscribe_only_markup(lang, i18n)
|
||||
|
||||
cancellation_msg = _(
|
||||
"tribute_subscription_cancelled",
|
||||
default="🚨 <b>Подписка отменена</b>\n\n"
|
||||
"Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, "
|
||||
"после чего подписка будет заблокирована.\n\n"
|
||||
"Для продления подписки нажмите кнопку ниже.",
|
||||
user_name=first_name
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
int(user_id),
|
||||
cancellation_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}")
|
||||
|
||||
logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error handling tribute cancellation for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def tribute_webhook_route(request: web.Request):
|
||||
"""AIOHTTP route handler for Tribute webhook calls."""
|
||||
tribute_service: TributeService = request.app['tribute_service']
|
||||
raw_body = await request.read()
|
||||
signature_header = request.headers.get('trbt-signature')
|
||||
return await tribute_service.handle_webhook(raw_body, signature_header)
|
||||
@@ -21,7 +21,10 @@ class YooKassaService:
|
||||
|
||||
self.settings = settings_obj
|
||||
|
||||
if not shop_id or not secret_key:
|
||||
if self.settings and not self.settings.YOOKASSA_ENABLED:
|
||||
logging.warning("YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED.")
|
||||
self.configured = False
|
||||
elif not shop_id or not secret_key:
|
||||
logging.warning(
|
||||
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
|
||||
"Payment functionality will be DISABLED.")
|
||||
@@ -111,10 +114,12 @@ class YooKassaService:
|
||||
capture = False
|
||||
amount = max(amount, 1.00)
|
||||
builder.set_capture(capture)
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
if not payment_method_id:
|
||||
# Saved payment_method_id charges must omit confirmation per YooKassa API
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
if save_payment_method:
|
||||
|
||||
@@ -6,8 +6,10 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_broadcast_message = State()
|
||||
confirming_broadcast = State()
|
||||
waiting_for_promo_details = State()
|
||||
waiting_for_promo_type_selection = State()
|
||||
waiting_for_promo_code = State()
|
||||
waiting_for_promo_bonus_days = State()
|
||||
waiting_for_promo_discount_percentage = State()
|
||||
waiting_for_promo_max_activations = State()
|
||||
waiting_for_promo_validity_days = State()
|
||||
waiting_for_promo_edit_details = State()
|
||||
@@ -28,6 +30,7 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_user_search = State()
|
||||
waiting_for_subscription_days_to_add = State()
|
||||
waiting_for_direct_message_to_user = State()
|
||||
waiting_for_user_delete_confirmation = State()
|
||||
|
||||
# Ads campaigns
|
||||
waiting_for_ad_source = State()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
|
||||
async def _encrypt_raw_link(settings: Settings, raw_link: str) -> Optional[str]:
|
||||
"""Encrypt the raw subscription URL using the panel's happ crypt4 API."""
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
encrypted_link = await panel_service.encrypt_happ_link(raw_link)
|
||||
if encrypted_link:
|
||||
return encrypted_link
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Build the user-facing connection key and the URL for the connect button.
|
||||
|
||||
Returns (display_link, button_link). When CRYPT4 is enabled the display link
|
||||
is encrypted and prefixed with happ://crypt4/ by panel API, and the button link is wrapped
|
||||
with CRYPT4_REDIRECT_URL if provided.
|
||||
"""
|
||||
if not raw_link:
|
||||
return None, None
|
||||
|
||||
cleaned = raw_link.strip()
|
||||
if not cleaned:
|
||||
return None, None
|
||||
|
||||
display_link = cleaned
|
||||
button_link = cleaned
|
||||
|
||||
if settings.CRYPT4_ENABLED:
|
||||
encrypted_payload = await _encrypt_raw_link(settings, cleaned)
|
||||
if encrypted_payload:
|
||||
display_link = encrypted_payload
|
||||
button_link = display_link
|
||||
else:
|
||||
logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.")
|
||||
|
||||
redirect_base = (settings.CRYPT4_REDIRECT_URL or "").strip()
|
||||
if redirect_base and settings.CRYPT4_ENABLED and display_link:
|
||||
button_link = f"{redirect_base}{display_link}"
|
||||
|
||||
return display_link, button_link
|
||||
@@ -5,6 +5,12 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from collections import deque
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from bot.utils.telegram_markup import (
|
||||
is_profile_link_error,
|
||||
remove_profile_link_buttons,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -51,14 +57,31 @@ class MessageQueue:
|
||||
message = self.queue.popleft()
|
||||
try:
|
||||
await self._send_message(message)
|
||||
self.last_send_times.append(datetime.now())
|
||||
self.total_sent += 1
|
||||
self._record_send_time()
|
||||
|
||||
except TelegramBadRequest as exc:
|
||||
fallback_message = self._build_profile_link_fallback(message, exc)
|
||||
if fallback_message:
|
||||
logging.warning(
|
||||
"Telegram rejected profile buttons for chat %s: %s. "
|
||||
"Retrying without tg:// links.",
|
||||
message.chat_id,
|
||||
getattr(exc, "message", "") or str(exc),
|
||||
)
|
||||
try:
|
||||
await self._send_message(fallback_message)
|
||||
self._record_send_time()
|
||||
continue
|
||||
except Exception as retry_exc:
|
||||
self.total_failed += 1
|
||||
logging.error(
|
||||
f"Failed to send fallback message to {message.chat_id}: {retry_exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
self.total_failed += 1
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {exc}")
|
||||
|
||||
# Keep only recent send times (last minute)
|
||||
cutoff_time = datetime.now() - timedelta(seconds=60)
|
||||
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
||||
self.last_send_times.popleft()
|
||||
|
||||
except Exception as e:
|
||||
self.total_failed += 1
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
||||
@@ -77,6 +100,38 @@ class MessageQueue:
|
||||
if time_since_last < self.delay_between_messages:
|
||||
wait_time = self.delay_between_messages - time_since_last
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
def _record_send_time(self) -> None:
|
||||
"""Track sent message timestamps and purge old entries for rate limiting."""
|
||||
now = datetime.now()
|
||||
self.last_send_times.append(now)
|
||||
self.total_sent += 1
|
||||
|
||||
cutoff_time = now - timedelta(seconds=60)
|
||||
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
||||
self.last_send_times.popleft()
|
||||
|
||||
def _build_profile_link_fallback(
|
||||
self, message: QueuedMessage, exc: Exception
|
||||
) -> Optional[QueuedMessage]:
|
||||
"""Create a fallback message without tg://user buttons when Telegram rejects them."""
|
||||
if not is_profile_link_error(exc):
|
||||
return None
|
||||
|
||||
markup = message.kwargs.get("reply_markup")
|
||||
if markup is None:
|
||||
return None
|
||||
|
||||
safe_markup = remove_profile_link_buttons(markup)
|
||||
fallback_kwargs = dict(message.kwargs)
|
||||
fallback_kwargs["reply_markup"] = safe_markup
|
||||
|
||||
return QueuedMessage(
|
||||
chat_id=message.chat_id,
|
||||
method_name=message.method_name,
|
||||
kwargs=fallback_kwargs,
|
||||
callback=message.callback,
|
||||
)
|
||||
|
||||
async def _send_message(self, message: QueuedMessage) -> Any:
|
||||
"""Send a single message - to be implemented by subclass"""
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import types
|
||||
|
||||
PROFILE_BUTTON_ERROR_CODES = ("BUTTON_USER_INVALID", "BUTTON_USER_PRIVACY_RESTRICTED")
|
||||
TG_USER_LINK_PREFIX = "tg://user?id="
|
||||
|
||||
|
||||
def remove_profile_link_buttons(
|
||||
markup: Optional[types.InlineKeyboardMarkup],
|
||||
) -> Optional[types.InlineKeyboardMarkup]:
|
||||
"""Remove buttons that point to tg://user links to avoid privacy-related errors."""
|
||||
inline_keyboard = getattr(markup, "inline_keyboard", None)
|
||||
if not markup or not inline_keyboard:
|
||||
return None
|
||||
|
||||
cleaned_rows = []
|
||||
for row in inline_keyboard:
|
||||
filtered_row = [
|
||||
button
|
||||
for button in row
|
||||
if not (
|
||||
getattr(button, "url", None)
|
||||
and str(button.url).startswith(TG_USER_LINK_PREFIX)
|
||||
)
|
||||
]
|
||||
if filtered_row:
|
||||
cleaned_rows.append(filtered_row)
|
||||
|
||||
if not cleaned_rows:
|
||||
return None
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows)
|
||||
|
||||
|
||||
def is_profile_link_error(exc: BaseException) -> bool:
|
||||
"""Return True if Telegram rejected markup because of profile link buttons."""
|
||||
message = getattr(exc, "message", "") or str(exc)
|
||||
return any(code in message for code in PROFILE_BUTTON_ERROR_CODES)
|
||||
+343
-44
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import Field, ValidationError, computed_field, field_validator
|
||||
from pydantic import Field, ValidationError, computed_field, field_validator, model_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
@@ -18,11 +18,14 @@ class Settings(BaseSettings):
|
||||
POSTGRES_DB: str = Field(default="vpn_shop_db")
|
||||
|
||||
DEFAULT_LANGUAGE: str = Field(default="ru")
|
||||
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
|
||||
|
||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
||||
REQUIRED_CHANNEL_SUBSCRIBE_TO_USE: bool = Field(
|
||||
default=False,
|
||||
description="Require users to subscribe to REQUIRED_CHANNEL_ID before using the bot",
|
||||
)
|
||||
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Telegram channel ID the user must join to access the bot")
|
||||
@@ -41,14 +44,61 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(
|
||||
default=True,
|
||||
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
||||
)
|
||||
|
||||
LKNPD_INN: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="NALOGO_INN",
|
||||
description="INN for lknpd.nalog.ru (self-employed) authentication"
|
||||
)
|
||||
LKNPD_PASSWORD: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="NALOGO_PASSWORD",
|
||||
description="Password for lknpd.nalog.ru (self-employed) authentication"
|
||||
)
|
||||
LKNPD_API_URL: str = Field(
|
||||
default="https://lknpd.nalog.ru/api",
|
||||
alias="NALOGO_API_URL",
|
||||
description="Base URL for LKNPD API (can be overridden for proxies)"
|
||||
)
|
||||
LKNPD_RECEIPT_NAME_SUBSCRIPTION: str = Field(
|
||||
default="subscription {months} months",
|
||||
alias="NALOGO_RECEIPT_NAME_SUBSCRIPTION",
|
||||
description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration."
|
||||
)
|
||||
LKNPD_RECEIPT_NAME_TRAFFIC: str = Field(
|
||||
default="traffic package {gb} GB",
|
||||
alias="NALOGO_RECEIPT_NAME_TRAFFIC",
|
||||
description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount."
|
||||
)
|
||||
|
||||
WEBHOOK_BASE_URL: Optional[str] = None
|
||||
TELEGRAM_WEBHOOK_PATH: str = Field(
|
||||
default="/webhook/telegram",
|
||||
description="Relative path for Telegram webhook endpoint",
|
||||
)
|
||||
TELEGRAM_WEBHOOK_SECRET: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Secret token for Telegram webhook header validation",
|
||||
)
|
||||
|
||||
CRYPTOPAY_TOKEN: Optional[str] = None
|
||||
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
|
||||
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
|
||||
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
||||
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
||||
PLATEGA_ENABLED: bool = Field(default=False)
|
||||
PLATEGA_BASE_URL: str = Field(default="https://app.platega.io")
|
||||
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||
PLATEGA_SECRET: Optional[str] = None
|
||||
PLATEGA_PAYMENT_METHOD: int = Field(
|
||||
default=2, description="Platega payment method ID (e.g., 2 for SBP QR)"
|
||||
)
|
||||
PLATEGA_RETURN_URL: Optional[str] = Field(default=None)
|
||||
PLATEGA_FAILED_URL: Optional[str] = Field(default=None)
|
||||
|
||||
FREEKASSA_ENABLED: bool = Field(default=False)
|
||||
FREEKASSA_MERCHANT_ID: Optional[str] = None
|
||||
@@ -57,10 +107,28 @@ class Settings(BaseSettings):
|
||||
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
||||
FREEKASSA_API_KEY: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
||||
|
||||
SEVERPAY_ENABLED: bool = Field(default=False)
|
||||
SEVERPAY_MID: Optional[int] = None
|
||||
SEVERPAY_TOKEN: Optional[str] = None
|
||||
SEVERPAY_RETURN_URL: Optional[str] = None
|
||||
SEVERPAY_BASE_URL: str = Field(default="https://severpay.io/api/merchant")
|
||||
SEVERPAY_LIFETIME_MINUTES: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Lifetime of the payment link in minutes (30-4320, defaults to provider value)",
|
||||
)
|
||||
|
||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||
STARS_ENABLED: bool = Field(default=True)
|
||||
TRIBUTE_ENABLED: bool = Field(default=True)
|
||||
STARS_PROVIDER_TOKEN: Optional[str] = Field(
|
||||
default="",
|
||||
description="Provider token for Telegram invoices. For Stars (XTR) should stay empty.",
|
||||
)
|
||||
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)",
|
||||
)
|
||||
|
||||
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
||||
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
||||
@@ -76,17 +144,17 @@ class Settings(BaseSettings):
|
||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
|
||||
TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments")
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments")
|
||||
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
|
||||
|
||||
TRAFFIC_PACKAGES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of traffic packages in the format '<GB>:<price>', e.g. '10:199,50:799'",
|
||||
)
|
||||
STARS_TRAFFIC_PACKAGES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'",
|
||||
)
|
||||
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||
@@ -113,7 +181,15 @@ class Settings(BaseSettings):
|
||||
# Referral program configuration
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
|
||||
default=True,
|
||||
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user – on their first successful payment."
|
||||
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment."
|
||||
)
|
||||
REFERRAL_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description="Enable referral links, referral menu and referral bonuses",
|
||||
)
|
||||
LEGACY_REFS: bool = Field(
|
||||
default=True,
|
||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
|
||||
)
|
||||
|
||||
PANEL_API_URL: Optional[str] = None
|
||||
@@ -124,11 +200,18 @@ class Settings(BaseSettings):
|
||||
default=None,
|
||||
description=
|
||||
"Comma-separated UUIDs of internal squads to assign to new panel users")
|
||||
USER_EXTERNAL_SQUAD_UUID: Optional[str] = Field(
|
||||
default=None,
|
||||
description=
|
||||
"UUID of the external squad to assign to new panel users (optional)")
|
||||
|
||||
TRIAL_ENABLED: bool = Field(default=True)
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||
|
||||
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs")
|
||||
CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled")
|
||||
|
||||
WEB_SERVER_HOST: str = Field(default="0.0.0.0")
|
||||
WEB_SERVER_PORT: int = Field(default=8080)
|
||||
LOGS_PAGE_SIZE: int = Field(default=10)
|
||||
@@ -206,6 +289,31 @@ class Settings(BaseSettings):
|
||||
]
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_user_external_squad_uuid(self) -> Optional[str]:
|
||||
if self.USER_EXTERNAL_SQUAD_UUID:
|
||||
cleaned = self.USER_EXTERNAL_SQUAD_UUID.strip()
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def telegram_webhook_path(self) -> str:
|
||||
path = (self.TELEGRAM_WEBHOOK_PATH or "").strip() or "/webhook/telegram"
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
return path
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def telegram_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.telegram_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_webhook_path(self) -> str:
|
||||
@@ -220,19 +328,6 @@ class Settings(BaseSettings):
|
||||
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_webhook_path(self) -> str:
|
||||
return "/webhook/tribute"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def panel_webhook_path(self) -> str:
|
||||
@@ -272,6 +367,32 @@ class Settings(BaseSettings):
|
||||
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def severpay_webhook_path(self) -> str:
|
||||
return "/webhook/severpay"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def severpay_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.severpay_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def platega_webhook_path(self) -> str:
|
||||
return "/webhook/platega"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def platega_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.platega_webhook_path}"
|
||||
return None
|
||||
|
||||
# Computed YooKassa receipt fields based on recurring toggle
|
||||
@computed_field
|
||||
@property
|
||||
@@ -316,17 +437,59 @@ class Settings(BaseSettings):
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_payment_links(self) -> Dict[int, str]:
|
||||
links: Dict[int, str] = {}
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
|
||||
links[1] = self.TRIBUTE_LINK_1_MONTH
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
|
||||
links[3] = self.TRIBUTE_LINK_3_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
|
||||
links[6] = self.TRIBUTE_LINK_6_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
|
||||
links[12] = self.TRIBUTE_LINK_12_MONTHS
|
||||
return links
|
||||
def traffic_packages(self) -> Dict[float, float]:
|
||||
"""
|
||||
Mapping of traffic size in GB to price in the default currency.
|
||||
"""
|
||||
packages: Dict[float, float] = {}
|
||||
raw = (self.TRAFFIC_PACKAGES or "").strip()
|
||||
if not raw:
|
||||
return packages
|
||||
for part in raw.split(","):
|
||||
chunk = part.strip()
|
||||
if not chunk or ":" not in chunk:
|
||||
continue
|
||||
size_str, price_str = chunk.split(":", 1)
|
||||
try:
|
||||
size_gb = float(size_str.strip())
|
||||
price_val = float(price_str.strip())
|
||||
if size_gb > 0 and price_val >= 0:
|
||||
packages[size_gb] = price_val
|
||||
except ValueError:
|
||||
logging.warning("Invalid TRAFFIC_PACKAGES entry skipped: %s", chunk)
|
||||
continue
|
||||
return packages
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def stars_traffic_packages(self) -> Dict[float, int]:
|
||||
"""
|
||||
Mapping of traffic size in GB to price in Telegram Stars.
|
||||
"""
|
||||
packages: Dict[float, int] = {}
|
||||
raw = (self.STARS_TRAFFIC_PACKAGES or "").strip()
|
||||
if not raw:
|
||||
return packages
|
||||
for part in raw.split(","):
|
||||
chunk = part.strip()
|
||||
if not chunk or ":" not in chunk:
|
||||
continue
|
||||
size_str, price_str = chunk.split(":", 1)
|
||||
try:
|
||||
size_gb = float(size_str.strip())
|
||||
price_val = int(float(price_str.strip()))
|
||||
if size_gb > 0 and price_val >= 0:
|
||||
packages[size_gb] = price_val
|
||||
except ValueError:
|
||||
logging.warning("Invalid STARS_TRAFFIC_PACKAGES entry skipped: %s", chunk)
|
||||
continue
|
||||
return packages
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def traffic_sale_mode(self) -> bool:
|
||||
"""When true, the bot sells traffic packages instead of time-based subscriptions."""
|
||||
return bool(self.traffic_packages or self.stars_traffic_packages)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
@@ -355,27 +518,130 @@ class Settings(BaseSettings):
|
||||
if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None:
|
||||
bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
|
||||
return bonuses
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_autopayments_active(self) -> bool:
|
||||
"""Autopay features are available only when YooKassa itself is enabled."""
|
||||
return bool(self.YOOKASSA_ENABLED and self.YOOKASSA_AUTOPAYMENTS_ENABLED)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def payment_methods_order(self) -> List[str]:
|
||||
"""
|
||||
Ordered list of payment providers to show in the subscription payment keyboard.
|
||||
"""
|
||||
default_order = [
|
||||
"freekassa",
|
||||
"platega",
|
||||
"severpay",
|
||||
"yookassa",
|
||||
"stars",
|
||||
"cryptopay",
|
||||
]
|
||||
if not self.PAYMENT_METHODS_ORDER:
|
||||
return default_order
|
||||
methods = []
|
||||
for item in self.PAYMENT_METHODS_ORDER.split(","):
|
||||
slug = item.strip().lower()
|
||||
if slug:
|
||||
methods.append(slug)
|
||||
return methods or default_order
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL: str = Field(
|
||||
default="INFO",
|
||||
description="Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||
)
|
||||
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
|
||||
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
|
||||
LOG_STORE_MESSAGE_CONTENT: bool = Field(
|
||||
default=False,
|
||||
description="Store message/callback content in message logs",
|
||||
)
|
||||
LOG_STORE_RAW_UPDATES: bool = Field(
|
||||
default=False,
|
||||
description="Store raw update previews in message logs",
|
||||
)
|
||||
LOG_EXPORT_INCLUDE_SENSITIVE: bool = Field(
|
||||
default=False,
|
||||
description="Include content/raw update fields in admin CSV export",
|
||||
)
|
||||
|
||||
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
|
||||
LOG_ADMIN_HIDE: bool = Field(
|
||||
default=False,
|
||||
description="Hide admin-generated events from admin logs UI and CSV export",
|
||||
)
|
||||
|
||||
@field_validator('LOG_LEVEL', mode='before')
|
||||
@classmethod
|
||||
def validate_optional_int_fields(cls, v):
|
||||
"""Convert empty strings to None for optional integer fields"""
|
||||
if isinstance(v, str) and v.strip() == '':
|
||||
return None
|
||||
def normalize_log_level(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip().upper()
|
||||
if not v:
|
||||
return "INFO"
|
||||
return v
|
||||
|
||||
@field_validator('REQUIRED_CHANNEL_LINK', mode='before')
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def drop_comment_placeholder_values(cls, values: Any):
|
||||
"""
|
||||
dotenv parses lines like `KEY= # comment` as `"# comment"`.
|
||||
Treat such values as unset so defaults/optionals work as expected.
|
||||
"""
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
|
||||
sanitized: Dict[str, Any] = {}
|
||||
for key, value in values.items():
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
if trimmed == "#" or trimmed.startswith("# "):
|
||||
continue
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
@field_validator(
|
||||
'TELEGRAM_WEBHOOK_PATH',
|
||||
mode='before',
|
||||
)
|
||||
@classmethod
|
||||
def normalize_webhook_path(cls, v):
|
||||
if not isinstance(v, str):
|
||||
return "/webhook/telegram"
|
||||
cleaned = v.strip()
|
||||
if not cleaned:
|
||||
return "/webhook/telegram"
|
||||
if not cleaned.startswith("/"):
|
||||
cleaned = f"/{cleaned}"
|
||||
return cleaned
|
||||
|
||||
@field_validator(
|
||||
'REQUIRED_CHANNEL_LINK',
|
||||
'PLATEGA_RETURN_URL',
|
||||
'PLATEGA_FAILED_URL',
|
||||
'SEVERPAY_RETURN_URL',
|
||||
'CRYPT4_REDIRECT_URL',
|
||||
'TELEGRAM_WEBHOOK_SECRET',
|
||||
'PANEL_WEBHOOK_SECRET',
|
||||
mode='before',
|
||||
)
|
||||
@classmethod
|
||||
def sanitize_optional_link(cls, v):
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
|
||||
@field_validator(
|
||||
'REQUIRED_CHANNEL_ID',
|
||||
'FREEKASSA_PAYMENT_METHOD_ID',
|
||||
'USER_HWID_DEVICE_LIMIT',
|
||||
'SEVERPAY_MID',
|
||||
'SEVERPAY_LIFETIME_MINUTES',
|
||||
'LOG_CHAT_ID',
|
||||
'LOG_THREAD_ID',
|
||||
mode='before'
|
||||
)
|
||||
@classmethod
|
||||
def validate_optional_int(cls, v):
|
||||
if isinstance(v, str):
|
||||
@@ -390,6 +656,10 @@ class Settings(BaseSettings):
|
||||
LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations")
|
||||
LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations")
|
||||
LOG_SUSPICIOUS_ACTIVITY: bool = Field(default=True, description="Send notifications for suspicious promo attempts")
|
||||
DISCOUNT_PROMO_PAYMENT_TIMEOUT_MINUTES: int = Field(
|
||||
default=10,
|
||||
description="How long a discount promo reservation is kept before user payment",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(env_file='.env',
|
||||
env_file_encoding='utf-8',
|
||||
@@ -414,10 +684,25 @@ def get_settings() -> Settings:
|
||||
logging.warning(
|
||||
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
|
||||
)
|
||||
if _settings_instance.WEBHOOK_BASE_URL and not _settings_instance.TELEGRAM_WEBHOOK_SECRET:
|
||||
logging.warning(
|
||||
"WARNING: TELEGRAM_WEBHOOK_SECRET is empty while webhook mode is enabled. "
|
||||
"Set TELEGRAM_WEBHOOK_SECRET to validate X-Telegram-Bot-Api-Secret-Token header."
|
||||
)
|
||||
if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
|
||||
logging.warning(
|
||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||
)
|
||||
if (
|
||||
_settings_instance.LKNPD_INN
|
||||
or _settings_instance.LKNPD_PASSWORD
|
||||
) and not (
|
||||
_settings_instance.LKNPD_INN
|
||||
and _settings_instance.LKNPD_PASSWORD
|
||||
):
|
||||
logging.warning(
|
||||
"WARNING: LKNPD credentials are incomplete. Receipt sending will be disabled."
|
||||
)
|
||||
if _settings_instance.FREEKASSA_ENABLED:
|
||||
if (
|
||||
not _settings_instance.FREEKASSA_MERCHANT_ID
|
||||
@@ -435,6 +720,20 @@ def get_settings() -> Settings:
|
||||
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
|
||||
)
|
||||
|
||||
if _settings_instance.PLATEGA_ENABLED:
|
||||
if (
|
||||
not _settings_instance.PLATEGA_MERCHANT_ID
|
||||
or not _settings_instance.PLATEGA_SECRET
|
||||
):
|
||||
logging.warning(
|
||||
"CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work."
|
||||
)
|
||||
if _settings_instance.SEVERPAY_ENABLED:
|
||||
if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN:
|
||||
logging.warning(
|
||||
"CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work."
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
logging.critical(
|
||||
f"Pydantic validation error while loading settings: {e}")
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
import os
|
||||
_BASELINE_REVISION = "0001_initial_schema"
|
||||
|
||||
|
||||
def _build_alembic_config(settings: Settings) -> Config:
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
config = Config(str(project_root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(project_root / "alembic"))
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
return config
|
||||
|
||||
|
||||
def _inspect_database_state(connection: Connection) -> Tuple[bool, bool, bool]:
|
||||
db_inspector = inspect(connection)
|
||||
has_alembic_version = db_inspector.has_table("alembic_version")
|
||||
has_users_table = db_inspector.has_table("users")
|
||||
has_legacy_migrator_table = db_inspector.has_table("schema_migrations")
|
||||
return has_alembic_version, has_users_table, has_legacy_migrator_table
|
||||
|
||||
|
||||
def _run_legacy_migrator_compatibility(connection: Connection) -> None:
|
||||
db_inspector = inspect(connection)
|
||||
if not db_inspector.has_table("users"):
|
||||
return
|
||||
|
||||
users_columns = {
|
||||
column["name"]
|
||||
for column in db_inspector.get_columns("users")
|
||||
}
|
||||
user_alter_statements = []
|
||||
|
||||
if "channel_subscription_verified" not in users_columns:
|
||||
user_alter_statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
|
||||
)
|
||||
if "channel_subscription_checked_at" not in users_columns:
|
||||
user_alter_statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
|
||||
)
|
||||
if "channel_subscription_verified_for" not in users_columns:
|
||||
user_alter_statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
|
||||
)
|
||||
if "referral_code" not in users_columns:
|
||||
user_alter_statements.append(
|
||||
"ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)"
|
||||
)
|
||||
|
||||
for statement in user_alter_statements:
|
||||
connection.execute(text(statement))
|
||||
|
||||
users_columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns("users")
|
||||
}
|
||||
if "referral_code" in users_columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET referral_code = NULLIF(UPPER(BTRIM(referral_code)), '')
|
||||
WHERE referral_code IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
WITH duplicate_codes AS (
|
||||
SELECT
|
||||
user_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY referral_code
|
||||
ORDER BY user_id
|
||||
) AS rn
|
||||
FROM users
|
||||
WHERE referral_code IS NOT NULL
|
||||
)
|
||||
UPDATE users AS u
|
||||
SET referral_code = UPPER(
|
||||
SUBSTRING(
|
||||
md5(
|
||||
u.user_id::text
|
||||
|| clock_timestamp()::text
|
||||
|| random()::text
|
||||
)
|
||||
FROM 1 FOR 9
|
||||
)
|
||||
)
|
||||
FROM duplicate_codes AS d
|
||||
WHERE u.user_id = d.user_id
|
||||
AND d.rn > 1
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
WITH generated_codes AS (
|
||||
SELECT
|
||||
user_id,
|
||||
UPPER(
|
||||
SUBSTRING(
|
||||
md5(
|
||||
user_id::text
|
||||
|| clock_timestamp()::text
|
||||
|| random()::text
|
||||
)
|
||||
FROM 1 FOR 9
|
||||
)
|
||||
) AS referral_code
|
||||
FROM users
|
||||
WHERE referral_code IS NULL
|
||||
)
|
||||
UPDATE users AS u
|
||||
SET referral_code = g.referral_code
|
||||
FROM generated_codes AS g
|
||||
WHERE u.user_id = g.user_id
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_referral_code
|
||||
ON users (referral_code)
|
||||
WHERE referral_code IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
db_inspector = inspect(connection)
|
||||
if db_inspector.has_table("payments"):
|
||||
payments_columns = {
|
||||
column["name"]
|
||||
for column in db_inspector.get_columns("payments")
|
||||
}
|
||||
if "original_amount" not in payments_columns:
|
||||
connection.execute(text("ALTER TABLE payments ADD COLUMN original_amount FLOAT"))
|
||||
if "discount_applied" not in payments_columns:
|
||||
connection.execute(text("ALTER TABLE payments ADD COLUMN discount_applied FLOAT"))
|
||||
|
||||
db_inspector = inspect(connection)
|
||||
has_promo_codes = db_inspector.has_table("promo_codes")
|
||||
if has_promo_codes:
|
||||
promo_columns = {
|
||||
column["name"]
|
||||
for column in db_inspector.get_columns("promo_codes")
|
||||
}
|
||||
if "promo_type" not in promo_columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_codes ADD COLUMN promo_type VARCHAR NOT NULL DEFAULT 'bonus_days'"
|
||||
)
|
||||
)
|
||||
if "discount_percentage" not in promo_columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE promo_codes ADD COLUMN discount_percentage INTEGER")
|
||||
)
|
||||
if "current_activations" not in promo_columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_codes ADD COLUMN current_activations INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
text(
|
||||
"UPDATE promo_codes SET current_activations = 0 "
|
||||
"WHERE current_activations IS NULL"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text("ALTER TABLE promo_codes ALTER COLUMN current_activations SET DEFAULT 0")
|
||||
)
|
||||
connection.execute(
|
||||
text("ALTER TABLE promo_codes ALTER COLUMN current_activations SET NOT NULL")
|
||||
)
|
||||
if "bonus_days" in promo_columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE promo_codes ALTER COLUMN bonus_days DROP NOT NULL")
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_promo_codes_promo_type ON promo_codes (promo_type)"
|
||||
)
|
||||
)
|
||||
|
||||
db_inspector = inspect(connection)
|
||||
has_active_discounts = db_inspector.has_table("active_discounts")
|
||||
if not has_active_discounts and has_promo_codes:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS active_discounts (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
promo_code_id INTEGER NOT NULL,
|
||||
discount_percentage INTEGER NOT NULL,
|
||||
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_active_discounts_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_active_discounts_promo_code
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
has_active_discounts = True
|
||||
|
||||
if has_active_discounts and has_promo_codes:
|
||||
connection.execute(
|
||||
text(
|
||||
"DELETE FROM active_discounts ad "
|
||||
"WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.user_id = ad.user_id) "
|
||||
"OR NOT EXISTS (SELECT 1 FROM promo_codes p WHERE p.promo_code_id = ad.promo_code_id)"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"DROP CONSTRAINT IF EXISTS active_discounts_user_id_fkey"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"DROP CONSTRAINT IF EXISTS fk_active_discounts_user"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"DROP CONSTRAINT IF EXISTS active_discounts_promo_code_id_fkey"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"DROP CONSTRAINT IF EXISTS fk_active_discounts_promo_code"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"ADD CONSTRAINT fk_active_discounts_user "
|
||||
"FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE active_discounts "
|
||||
"ADD CONSTRAINT fk_active_discounts_promo_code "
|
||||
"FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
elif has_active_discounts and not has_promo_codes:
|
||||
logging.warning(
|
||||
"Alembic legacy compatibility: skipped active_discounts FK repair "
|
||||
"because promo_codes table is missing."
|
||||
)
|
||||
|
||||
|
||||
def _run_stamp(connection: Connection, alembic_config: Config, revision: str) -> None:
|
||||
alembic_config.attributes["connection"] = connection
|
||||
command.stamp(alembic_config, revision)
|
||||
|
||||
|
||||
def _run_upgrade(connection: Connection, alembic_config: Config) -> None:
|
||||
alembic_config.attributes["connection"] = connection
|
||||
command.upgrade(alembic_config, "head")
|
||||
|
||||
|
||||
async def run_alembic_migrations(settings: Settings, async_engine: AsyncEngine) -> None:
|
||||
"""Apply Alembic migrations with bootstrap for existing installations."""
|
||||
|
||||
alembic_config = _build_alembic_config(settings)
|
||||
allow_bootstrap_without_legacy = os.getenv("ALEMBIC_ALLOW_STAMP_WITHOUT_LEGACY", "").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
async with async_engine.begin() as async_connection:
|
||||
(
|
||||
has_alembic_version,
|
||||
has_users_table,
|
||||
has_legacy_migrator_table,
|
||||
) = await async_connection.run_sync(
|
||||
_inspect_database_state
|
||||
)
|
||||
|
||||
if not has_alembic_version and has_users_table:
|
||||
if not has_legacy_migrator_table and not allow_bootstrap_without_legacy:
|
||||
raise RuntimeError(
|
||||
"Alembic bootstrap refused: found existing users table without "
|
||||
"alembic_version and without legacy schema_migrations marker. "
|
||||
"Set ALEMBIC_ALLOW_STAMP_WITHOUT_LEGACY=true to explicitly allow "
|
||||
f"stamping baseline {_BASELINE_REVISION} after manual verification."
|
||||
)
|
||||
|
||||
if has_legacy_migrator_table:
|
||||
logging.info(
|
||||
"Alembic: applying legacy migrator compatibility fixes before stamp."
|
||||
)
|
||||
await async_connection.run_sync(_run_legacy_migrator_compatibility)
|
||||
else:
|
||||
logging.warning(
|
||||
"Alembic: existing users table without legacy schema_migrations; "
|
||||
"proceeding with explicit bootstrap override flag."
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"Alembic: existing schema detected without alembic_version; stamping %s.",
|
||||
_BASELINE_REVISION,
|
||||
)
|
||||
await async_connection.run_sync(
|
||||
_run_stamp,
|
||||
alembic_config,
|
||||
_BASELINE_REVISION,
|
||||
)
|
||||
|
||||
logging.info("Alembic: running upgrade to head...")
|
||||
await async_connection.run_sync(_run_upgrade, alembic_config)
|
||||
|
||||
logging.info("Alembic: migrations applied successfully.")
|
||||
@@ -0,0 +1,163 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import delete
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from db.models import ActiveDiscount
|
||||
|
||||
|
||||
async def set_active_discount(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
promo_code_id: int,
|
||||
discount_percentage: int,
|
||||
expires_at: datetime,
|
||||
) -> Optional[ActiveDiscount]:
|
||||
"""
|
||||
Set active discount for user.
|
||||
Returns None if user already has an active discount (enforce one-at-a-time rule).
|
||||
"""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
|
||||
existing = await get_active_discount(session, user_id, include_expired=True)
|
||||
if existing and existing.expires_at > now_utc:
|
||||
logging.warning(
|
||||
f"User {user_id} already has active discount (promo_code_id: {existing.promo_code_id}). "
|
||||
f"Cannot activate new discount {promo_code_id}."
|
||||
)
|
||||
return None
|
||||
|
||||
if existing and existing.expires_at <= now_utc:
|
||||
await clear_active_discount_if_expired(session, user_id, now=now_utc)
|
||||
|
||||
# Create new active discount
|
||||
new_discount = ActiveDiscount(
|
||||
user_id=user_id,
|
||||
promo_code_id=promo_code_id,
|
||||
discount_percentage=discount_percentage,
|
||||
activated_at=now_utc,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(new_discount)
|
||||
await session.flush()
|
||||
await session.refresh(new_discount)
|
||||
logging.info(
|
||||
f"Active discount set for user {user_id}: promo_code_id={promo_code_id}, "
|
||||
f"discount={discount_percentage}%"
|
||||
)
|
||||
return new_discount
|
||||
|
||||
|
||||
async def get_active_discount(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
include_expired: bool = False,
|
||||
) -> Optional[ActiveDiscount]:
|
||||
"""Get active discount for user if exists."""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
stmt = select(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
|
||||
if not include_expired:
|
||||
stmt = stmt.where(ActiveDiscount.expires_at > now_utc)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def clear_active_discount(
|
||||
session: AsyncSession,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Clear active discount for user.
|
||||
Returns True if discount was cleared, False if no discount was found.
|
||||
"""
|
||||
stmt = delete(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
cleared = result.rowcount > 0
|
||||
if cleared:
|
||||
logging.info(f"Active discount cleared for user {user_id}")
|
||||
return cleared
|
||||
|
||||
|
||||
async def clear_active_discount_if_expired(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
now: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Clear active discount for user only when it has already expired.
|
||||
"""
|
||||
now_utc = now or datetime.now(timezone.utc)
|
||||
stmt = delete(ActiveDiscount).where(
|
||||
ActiveDiscount.user_id == user_id,
|
||||
ActiveDiscount.expires_at <= now_utc,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
cleared = result.rowcount > 0
|
||||
if cleared:
|
||||
logging.info("Expired active discount cleared for user %s", user_id)
|
||||
return cleared
|
||||
|
||||
|
||||
async def clear_active_discount_if_matches(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
promo_code_id: Optional[int] = None,
|
||||
expires_at_lte: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Clear active discount for user only when additional constraints match.
|
||||
"""
|
||||
conditions = [ActiveDiscount.user_id == user_id]
|
||||
if promo_code_id is not None:
|
||||
conditions.append(ActiveDiscount.promo_code_id == promo_code_id)
|
||||
if expires_at_lte is not None:
|
||||
conditions.append(ActiveDiscount.expires_at <= expires_at_lte)
|
||||
|
||||
stmt = delete(ActiveDiscount).where(*conditions)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
cleared = result.rowcount > 0
|
||||
if cleared:
|
||||
logging.info(
|
||||
"Active discount cleared for user %s by constrained cleanup.",
|
||||
user_id,
|
||||
)
|
||||
return cleared
|
||||
|
||||
|
||||
async def get_expired_active_discounts(
|
||||
session: AsyncSession,
|
||||
now: Optional[datetime] = None,
|
||||
limit: int = 100,
|
||||
) -> List[ActiveDiscount]:
|
||||
"""Get expired active discount reservations for cleanup/notifications."""
|
||||
now_utc = now or datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(ActiveDiscount)
|
||||
.where(ActiveDiscount.expires_at <= now_utc)
|
||||
.order_by(ActiveDiscount.expires_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def clear_active_discounts_by_promo_code(
|
||||
session: AsyncSession,
|
||||
promo_code_id: int
|
||||
) -> int:
|
||||
"""
|
||||
Clear all active discounts associated with a specific promo code.
|
||||
Returns the number of discounts cleared.
|
||||
"""
|
||||
stmt = delete(ActiveDiscount).where(ActiveDiscount.promo_code_id == promo_code_id)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
count = result.rowcount
|
||||
if count > 0:
|
||||
logging.info(f"Cleared {count} active discount(s) for promo_code_id={promo_code_id}")
|
||||
return count
|
||||
@@ -22,16 +22,23 @@ async def create_message_log(session: AsyncSession,
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_message_logs(session: AsyncSession, limit: int,
|
||||
offset: int) -> List[MessageLog]:
|
||||
stmt = select(MessageLog).order_by(
|
||||
MessageLog.timestamp.desc()).limit(limit).offset(offset)
|
||||
async def get_all_message_logs(session: AsyncSession,
|
||||
limit: int,
|
||||
offset: int,
|
||||
hide_admin_events: bool = False) -> List[MessageLog]:
|
||||
stmt = select(MessageLog)
|
||||
if hide_admin_events:
|
||||
stmt = stmt.where(MessageLog.is_admin_event.is_(False))
|
||||
stmt = stmt.order_by(MessageLog.timestamp.desc()).limit(limit).offset(offset)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_all_message_logs(session: AsyncSession) -> int:
|
||||
async def count_all_message_logs(session: AsyncSession,
|
||||
hide_admin_events: bool = False) -> int:
|
||||
stmt = select(func.count()).select_from(MessageLog)
|
||||
if hide_admin_events:
|
||||
stmt = stmt.where(MessageLog.is_admin_event.is_(False))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
+170
-21
@@ -5,7 +5,7 @@ from sqlalchemy.future import select
|
||||
from sqlalchemy import update, func, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Payment, User
|
||||
from db.models import Payment
|
||||
|
||||
|
||||
async def create_payment_record(session: AsyncSession,
|
||||
@@ -60,17 +60,18 @@ async def ensure_payment_with_provider_id(
|
||||
"""Idempotently create a payment record for a provider event.
|
||||
|
||||
If a payment with the same provider_payment_id already exists, returns it.
|
||||
Otherwise creates a new succeeded payment with provided data.
|
||||
Otherwise creates a new pending payment with provided data.
|
||||
"""
|
||||
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
pending_status = f"pending_{provider}" if provider else "pending"
|
||||
payment_payload: Dict[str, Any] = {
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"currency": currency,
|
||||
"status": "succeeded",
|
||||
"status": pending_status,
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider_payment_id": provider_payment_id,
|
||||
@@ -175,10 +176,149 @@ async def update_provider_payment_and_status(
|
||||
return payment
|
||||
|
||||
|
||||
async def mark_provider_payment_succeeded_once(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
provider_payment_id: str) -> bool:
|
||||
"""Atomically mark payment as succeeded only once.
|
||||
|
||||
Returns True only for the first successful transition to "succeeded".
|
||||
Returns False when payment is missing or already succeeded.
|
||||
"""
|
||||
stmt = (
|
||||
update(Payment)
|
||||
.where(
|
||||
Payment.payment_id == payment_db_id,
|
||||
Payment.status != "succeeded",
|
||||
)
|
||||
.values(
|
||||
status="succeeded",
|
||||
provider_payment_id=provider_payment_id,
|
||||
updated_at=func.now(),
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
updated = (result.rowcount or 0) > 0
|
||||
if updated:
|
||||
logging.info(
|
||||
"Payment record %s atomically marked as succeeded (provider id %s).",
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def mark_provider_payment_processing_once(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
provider_payment_id: str,
|
||||
expected_status_prefix: Optional[str] = None) -> bool:
|
||||
"""Atomically claim payment for processing exactly once.
|
||||
|
||||
Returns True only when status is changed from a non-terminal state to
|
||||
"processing". This prevents duplicate activation when concurrent
|
||||
webhooks arrive for the same payment.
|
||||
"""
|
||||
conditions = [
|
||||
Payment.payment_id == payment_db_id,
|
||||
Payment.status != "succeeded",
|
||||
Payment.status != "processing",
|
||||
]
|
||||
if expected_status_prefix:
|
||||
if expected_status_prefix == "pending":
|
||||
# YooKassa may keep authorized payments in waiting_for_capture
|
||||
# before reporting a final successful capture webhook.
|
||||
conditions.append(
|
||||
Payment.status.in_(("waiting_for_capture", "pending", "pending_yookassa"))
|
||||
)
|
||||
else:
|
||||
conditions.append(Payment.status.like(f"{expected_status_prefix}%"))
|
||||
|
||||
stmt = (
|
||||
update(Payment)
|
||||
.where(*conditions)
|
||||
.values(
|
||||
status="processing",
|
||||
provider_payment_id=provider_payment_id,
|
||||
updated_at=func.now(),
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
updated = (result.rowcount or 0) > 0
|
||||
if updated:
|
||||
logging.info(
|
||||
"Payment record %s atomically marked as processing (provider id %s).",
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def rollback_provider_payment_processing(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
rollback_status: str,
|
||||
provider_payment_id: Optional[str] = None) -> bool:
|
||||
"""Atomically rollback temporary processing status.
|
||||
|
||||
Returns True only if payment is currently in "processing" state.
|
||||
"""
|
||||
values = {
|
||||
"status": rollback_status,
|
||||
"updated_at": func.now(),
|
||||
}
|
||||
if provider_payment_id:
|
||||
values["provider_payment_id"] = provider_payment_id
|
||||
|
||||
stmt = (
|
||||
update(Payment)
|
||||
.where(
|
||||
Payment.payment_id == payment_db_id,
|
||||
Payment.status == "processing",
|
||||
)
|
||||
.values(**values)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
updated = (result.rowcount or 0) > 0
|
||||
if updated:
|
||||
logging.info(
|
||||
"Payment record %s rolled back from processing to %s.",
|
||||
payment_db_id,
|
||||
rollback_status,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def update_payment_discount_info(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
original_amount: Optional[float],
|
||||
discount_applied: Optional[float],
|
||||
promo_code_id: Optional[int]) -> Optional[Payment]:
|
||||
"""Update payment record with discount metadata."""
|
||||
payment = await get_payment_by_db_id(session, payment_db_id)
|
||||
if payment:
|
||||
payment.original_amount = original_amount
|
||||
payment.discount_applied = discount_applied
|
||||
payment.promo_code_id = promo_code_id
|
||||
payment.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(payment)
|
||||
logging.info(
|
||||
f"Payment record {payment.payment_id} updated with discount info: "
|
||||
f"original {original_amount}, discount {discount_applied}, promo {promo_code_id}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment record with DB ID {payment_db_id} not found for discount info update."
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
"""Get comprehensive financial statistics."""
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import and_, text
|
||||
from sqlalchemy import and_
|
||||
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
@@ -239,26 +379,35 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]:
|
||||
"""Get duration in months from the last successful tribute payment for a user."""
|
||||
stmt = select(Payment.subscription_duration_months).where(
|
||||
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
|
||||
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
|
||||
stmt = select(func.sum(Payment.amount)).where(
|
||||
and_(
|
||||
Payment.user_id == user_id,
|
||||
Payment.provider == 'tribute',
|
||||
Payment.status == 'succeeded'
|
||||
)
|
||||
).order_by(Payment.created_at.desc()).limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
total = result.scalar()
|
||||
return float(total or 0)
|
||||
|
||||
|
||||
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
|
||||
"""Get total revenue generated from referred users' payments.
|
||||
|
||||
This calculates the sum of all succeeded payments made by users
|
||||
where referred_by_id equals the referrer_id.
|
||||
"""
|
||||
from db.models import User
|
||||
|
||||
stmt = select(func.sum(Payment.amount)).join(
|
||||
User, Payment.user_id == User.user_id
|
||||
).where(
|
||||
and_(
|
||||
User.referred_by_id == referrer_id,
|
||||
Payment.status == 'succeeded'
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_last_tribute_payment(
|
||||
session: AsyncSession, user_id: int) -> Optional[Payment]:
|
||||
"""Return the most recent succeeded Tribute payment for the user."""
|
||||
stmt = (select(Payment).where(
|
||||
and_(Payment.user_id == user_id, Payment.provider == 'tribute',
|
||||
Payment.status == 'succeeded')).order_by(
|
||||
Payment.created_at.desc()).limit(1))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
total = result.scalar()
|
||||
return float(total or 0)
|
||||
|
||||
@@ -43,6 +43,34 @@ async def get_active_promo_code_by_code_str(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_active_bonus_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
"""Get active bonus_days-type promo code by code string"""
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == code_str.upper(),
|
||||
PromoCode.promo_type == "bonus_days",
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until
|
||||
> datetime.now(timezone.utc)))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_active_discount_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
"""Get active discount-type promo code by code string"""
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == code_str.upper(),
|
||||
PromoCode.promo_type == "discount",
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until
|
||||
> datetime.now(timezone.utc)))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_active_promo_codes(session: AsyncSession,
|
||||
limit: int = 20,
|
||||
offset: int = 0) -> List[PromoCode]:
|
||||
@@ -104,36 +132,78 @@ async def update_promo_code(session: AsyncSession, promo_id: int,
|
||||
|
||||
|
||||
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
|
||||
from db.dal import active_discount_dal
|
||||
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None
|
||||
# First, delete related activations due to foreign key constraint
|
||||
|
||||
# 1. Clear all active discounts referencing this promo code
|
||||
await active_discount_dal.clear_active_discounts_by_promo_code(session, promo_id)
|
||||
|
||||
# 2. Set promo_code_id to NULL in payments table to avoid FK violation
|
||||
stmt = update(Payment).where(Payment.promo_code_id == promo_id).values(promo_code_id=None)
|
||||
await session.execute(stmt)
|
||||
|
||||
# 3. Delete related activations
|
||||
activations = await get_promo_activations_by_code_id(session, promo_id)
|
||||
for activation in activations:
|
||||
await session.delete(activation)
|
||||
|
||||
|
||||
# 4. Delete the promo code itself
|
||||
await session.delete(promo)
|
||||
await session.flush()
|
||||
|
||||
logging.info(f"Promo code '{promo.code}' (ID: {promo_id}) deleted successfully")
|
||||
return promo
|
||||
|
||||
|
||||
async def increment_promo_code_usage(
|
||||
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
|
||||
session: AsyncSession,
|
||||
promo_code_id: int,
|
||||
allow_overflow: bool = False) -> Optional[PromoCode]:
|
||||
conditions = [PromoCode.promo_code_id == promo_code_id]
|
||||
if not allow_overflow:
|
||||
conditions.append(PromoCode.current_activations < PromoCode.max_activations)
|
||||
|
||||
stmt = (
|
||||
update(PromoCode)
|
||||
.where(*conditions)
|
||||
.values(current_activations=PromoCode.current_activations + 1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount and result.rowcount > 0:
|
||||
await session.flush()
|
||||
return await get_promo_code_by_id(session, promo_code_id)
|
||||
|
||||
promo = await get_promo_code_by_id(session, promo_code_id)
|
||||
if promo:
|
||||
if promo.current_activations < promo.max_activations:
|
||||
promo.current_activations += 1
|
||||
await session.flush()
|
||||
await session.refresh(promo)
|
||||
return promo
|
||||
if allow_overflow:
|
||||
logging.warning(
|
||||
f"Failed to increment promo usage for promo {promo.code} (ID: {promo_code_id})."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def decrement_promo_code_usage(
|
||||
session: AsyncSession, promo_code_id: int) -> bool:
|
||||
stmt = (
|
||||
update(PromoCode)
|
||||
.where(
|
||||
PromoCode.promo_code_id == promo_code_id,
|
||||
PromoCode.current_activations > 0,
|
||||
)
|
||||
.values(current_activations=PromoCode.current_activations - 1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
return bool(result.rowcount and result.rowcount > 0)
|
||||
|
||||
|
||||
async def get_user_activation_for_promo(
|
||||
session: AsyncSession, promo_code_id: int,
|
||||
user_id: int) -> Optional[PromoCodeActivation]:
|
||||
@@ -189,3 +259,22 @@ async def record_promo_activation(
|
||||
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}"
|
||||
)
|
||||
return new_activation
|
||||
|
||||
|
||||
async def set_activation_payment_id(
|
||||
session: AsyncSession,
|
||||
promo_code_id: int,
|
||||
user_id: int,
|
||||
payment_id: int) -> bool:
|
||||
stmt = (
|
||||
update(PromoCodeActivation)
|
||||
.where(
|
||||
PromoCodeActivation.promo_code_id == promo_code_id,
|
||||
PromoCodeActivation.user_id == user_id,
|
||||
PromoCodeActivation.payment_id == None,
|
||||
)
|
||||
.values(payment_id=payment_id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.flush()
|
||||
return bool(result.rowcount and result.rowcount > 0)
|
||||
|
||||
@@ -162,3 +162,19 @@ async def delete_user_payment_method_by_provider_id(
|
||||
await session.delete(method)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def user_has_saved_payment_method(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
provider: str = "yookassa",
|
||||
) -> bool:
|
||||
"""Return True if the user has at least one saved payment method."""
|
||||
try:
|
||||
methods = await list_user_payment_methods(session, user_id, provider)
|
||||
if methods:
|
||||
return True
|
||||
billing = await get_user_billing(session, user_id)
|
||||
return bool(billing and billing.yookassa_payment_method_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
+147
-2
@@ -1,13 +1,71 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import update, delete, func, and_
|
||||
from sqlalchemy import update, delete, func, and_, or_
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import User, Subscription
|
||||
from ..models import (
|
||||
User,
|
||||
Subscription,
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
MessageLog,
|
||||
UserBilling,
|
||||
UserPaymentMethod,
|
||||
AdAttribution,
|
||||
)
|
||||
|
||||
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
|
||||
REFERRAL_CODE_LENGTH = 9
|
||||
MAX_REFERRAL_CODE_ATTEMPTS = 25
|
||||
|
||||
|
||||
def _generate_referral_code_candidate() -> str:
|
||||
return "".join(
|
||||
secrets.choice(REFERRAL_CODE_ALPHABET) for _ in range(REFERRAL_CODE_LENGTH)
|
||||
)
|
||||
|
||||
|
||||
async def _referral_code_exists(session: AsyncSession, code: str) -> bool:
|
||||
stmt = select(User.user_id).where(User.referral_code == code)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def generate_unique_referral_code(session: AsyncSession) -> str:
|
||||
"""
|
||||
Generate a unique referral code consisting of uppercase alphanumeric characters.
|
||||
Retries until a free code is found or raises RuntimeError after exceeding attempts.
|
||||
"""
|
||||
for _ in range(MAX_REFERRAL_CODE_ATTEMPTS):
|
||||
candidate = _generate_referral_code_candidate()
|
||||
if not await _referral_code_exists(session, candidate):
|
||||
return candidate
|
||||
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
|
||||
|
||||
|
||||
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||
"""
|
||||
Ensure the provided user has a referral code, generating and persisting it if missing.
|
||||
Returns the existing or newly generated code.
|
||||
"""
|
||||
if user.referral_code:
|
||||
normalized = user.referral_code.strip().upper()
|
||||
if normalized != user.referral_code:
|
||||
user.referral_code = normalized
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return user.referral_code
|
||||
|
||||
user.referral_code = await generate_unique_referral_code(session)
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return user.referral_code
|
||||
|
||||
|
||||
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||
@@ -43,6 +101,11 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
if "registration_date" not in user_data:
|
||||
user_data["registration_date"] = datetime.now(timezone.utc)
|
||||
|
||||
if not user_data.get("referral_code"):
|
||||
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||
else:
|
||||
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
||||
|
||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||
stmt = (
|
||||
pg_insert(User)
|
||||
@@ -71,6 +134,15 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
return user, created
|
||||
|
||||
|
||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
normalized = referral_code.strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
stmt = select(User).where(User.referral_code == normalized)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_user(
|
||||
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
||||
) -> Optional[User]:
|
||||
@@ -102,6 +174,29 @@ async def get_banned_users(session: AsyncSession) -> List[User]:
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_all_users_paginated(
|
||||
session: AsyncSession, *, page: int = 0, page_size: int = 15
|
||||
) -> List[User]:
|
||||
"""Return a slice of users ordered by newest registration first."""
|
||||
safe_page = max(page, 0)
|
||||
safe_page_size = max(page_size, 1)
|
||||
|
||||
stmt = (
|
||||
select(User)
|
||||
.order_by(User.registration_date.desc())
|
||||
.offset(safe_page * safe_page_size)
|
||||
.limit(safe_page_size)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_all_users(session: AsyncSession) -> int:
|
||||
"""Count total number of users."""
|
||||
result = await session.execute(select(func.count(User.user_id)))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
||||
stmt = select(User.user_id).where(User.is_banned == False)
|
||||
result = await session.execute(stmt)
|
||||
@@ -227,3 +322,53 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||
"""Completely remove a user and all dependent records from the database.
|
||||
|
||||
This helper ensures we do not leave dangling foreign keys or orphaned data.
|
||||
"""
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# Ensure referral pointers do not block deletion
|
||||
await session.execute(
|
||||
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
|
||||
)
|
||||
|
||||
# Clean up dependent tables that do not cascade automatically
|
||||
await session.execute(
|
||||
delete(MessageLog).where(
|
||||
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
|
||||
)
|
||||
)
|
||||
|
||||
user_payment_ids_subquery = select(Payment.payment_id).where(Payment.user_id == user_id)
|
||||
|
||||
# Keep activations of other users intact if they reference this user's payments
|
||||
await session.execute(
|
||||
update(PromoCodeActivation)
|
||||
.where(
|
||||
PromoCodeActivation.payment_id.in_(user_payment_ids_subquery),
|
||||
PromoCodeActivation.user_id != user_id,
|
||||
)
|
||||
.values(payment_id=None)
|
||||
)
|
||||
await session.execute(
|
||||
delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||
)
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||
|
||||
await session.delete(user)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
+21
-10
@@ -1,20 +1,35 @@
|
||||
import logging
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from .models import Base
|
||||
from .migrator import run_database_migrations
|
||||
from .alembic_runner import run_alembic_migrations
|
||||
|
||||
async_engine = None
|
||||
|
||||
|
||||
def _mask_db_url(url: str) -> str:
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.username is None:
|
||||
return url
|
||||
username = parsed.username
|
||||
host = parsed.hostname or ""
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
netloc = f"{username}:***@{host}{port}"
|
||||
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
||||
except Exception:
|
||||
return "<masked>"
|
||||
|
||||
|
||||
def init_db_connection(settings: Settings) -> sessionmaker:
|
||||
global async_engine
|
||||
|
||||
if async_engine is None:
|
||||
masked_url = _mask_db_url(settings.DATABASE_URL)
|
||||
logging.info(
|
||||
f"Attempting to create SQLAlchemy engine with URL: {settings.DATABASE_URL}"
|
||||
f"Attempting to create SQLAlchemy engine with URL: {masked_url}"
|
||||
)
|
||||
async_engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
@@ -30,7 +45,7 @@ def init_db_connection(settings: Settings) -> sessionmaker:
|
||||
autoflush=False,
|
||||
)
|
||||
logging.info(
|
||||
f"SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL."
|
||||
"SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL."
|
||||
)
|
||||
return local_async_session_factory
|
||||
|
||||
@@ -61,12 +76,8 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
||||
"async_engine is not initialized. Call init_db_connection and get session_factory first."
|
||||
)
|
||||
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.run_sync(run_database_migrations)
|
||||
logging.info(
|
||||
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
||||
)
|
||||
await run_alembic_migrations(settings, async_engine)
|
||||
logging.info("PostgreSQL database migrations checked/applied via Alembic.")
|
||||
|
||||
async with session_factory() as session:
|
||||
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Set
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
id: str
|
||||
description: str
|
||||
upgrade: Callable[[Connection], None]
|
||||
|
||||
|
||||
def _ensure_migrations_table(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
statements: List[str] = []
|
||||
|
||||
if "channel_subscription_verified" not in columns:
|
||||
statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
|
||||
)
|
||||
if "channel_subscription_checked_at" not in columns:
|
||||
statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
|
||||
)
|
||||
if "channel_subscription_verified_for" not in columns:
|
||||
statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
|
||||
)
|
||||
|
||||
for stmt in statements:
|
||||
connection.execute(text(stmt))
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
description="Add columns to track required channel subscription verification",
|
||||
upgrade=_migration_0001_add_channel_subscription_fields,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def run_database_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Apply pending migrations sequentially. Already applied revisions are skipped.
|
||||
"""
|
||||
_ensure_migrations_table(connection)
|
||||
|
||||
applied_revisions: Set[str] = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
text("SELECT id FROM schema_migrations")
|
||||
)
|
||||
}
|
||||
|
||||
for migration in MIGRATIONS:
|
||||
if migration.id in applied_revisions:
|
||||
continue
|
||||
|
||||
logging.info(
|
||||
"Migrator: applying %s – %s", migration.id, migration.description
|
||||
)
|
||||
try:
|
||||
with connection.begin_nested():
|
||||
migration.upgrade(connection)
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (id) VALUES (:revision)"
|
||||
),
|
||||
{"revision": migration.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Migrator: failed to apply %s (%s)",
|
||||
migration.id,
|
||||
migration.description,
|
||||
exc_info=True,
|
||||
)
|
||||
raise exc
|
||||
else:
|
||||
logging.info("Migrator: migration %s applied successfully", migration.id)
|
||||
+40
-2
@@ -21,6 +21,7 @@ class User(Base):
|
||||
server_default=func.now())
|
||||
is_banned = Column(Boolean, default=False)
|
||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
||||
referred_by_id = Column(BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=True)
|
||||
@@ -99,7 +100,12 @@ class Payment(Base):
|
||||
provider_payment_id = Column(String, unique=True, nullable=True)
|
||||
provider = Column(String, nullable=False, default="yookassa", index=True)
|
||||
idempotence_key = Column(String, unique=True, nullable=True)
|
||||
amount = Column(Float, nullable=False)
|
||||
amount = Column(Float, nullable=False) # Final amount paid (after discount if any)
|
||||
|
||||
# Discount tracking fields
|
||||
original_amount = Column(Float, nullable=True) # Amount before discount
|
||||
discount_applied = Column(Float, nullable=True) # Discount amount (not percentage)
|
||||
|
||||
currency = Column(String, nullable=False)
|
||||
status = Column(String, nullable=False, index=True)
|
||||
description = Column(String, nullable=True)
|
||||
@@ -153,7 +159,17 @@ class PromoCode(Base):
|
||||
|
||||
promo_code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
code = Column(String, unique=True, nullable=False, index=True)
|
||||
bonus_days = Column(Integer, nullable=False)
|
||||
|
||||
# Type field to distinguish promo code types
|
||||
promo_type = Column(String, nullable=False, default="bonus_days", index=True)
|
||||
# Values: "bonus_days" or "discount"
|
||||
|
||||
# For bonus_days type: number of days to add to subscription
|
||||
bonus_days = Column(Integer, nullable=True)
|
||||
|
||||
# For discount type: percentage discount (1-100)
|
||||
discount_percentage = Column(Integer, nullable=True)
|
||||
|
||||
max_activations = Column(Integer, nullable=False)
|
||||
current_activations = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
@@ -190,6 +206,28 @@ class PromoCodeActivation(Base):
|
||||
name='uq_promo_user_activation'), )
|
||||
|
||||
|
||||
class ActiveDiscount(Base):
|
||||
"""Tracks pending discount promo code reservations awaiting payment."""
|
||||
__tablename__ = "active_discounts"
|
||||
|
||||
user_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.user_id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
promo_code_id = Column(
|
||||
Integer,
|
||||
ForeignKey("promo_codes.promo_code_id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
discount_percentage = Column(Integer, nullable=False)
|
||||
activated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
promo_code = relationship("PromoCode")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class MessageLog(Base):
|
||||
__tablename__ = "message_logs"
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
remnawave-tg-shop:
|
||||
image: kavore/remnawave-tg-shop:latest
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '127.0.0.1:8080:8080'
|
||||
networks:
|
||||
- remnawave-network
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- remnawave-tg-shop-db
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
hostname: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
networks:
|
||||
remnawave-network: null
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||
image: kavore/remnawave-tg-shop:latest
|
||||
# build: .
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
|
||||
+71
-8
@@ -25,8 +25,11 @@
|
||||
"error_displaying_menu": "Error displaying menu.",
|
||||
"main_menu_unknown_action": "Unknown action.",
|
||||
"select_subscription_period": "Select subscription period:",
|
||||
"select_traffic_package": "Select a traffic package:",
|
||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||
"buy_traffic_package_button": "{traffic_gb} GB - {price} {currency_symbol}",
|
||||
"choose_payment_method": "Choose payment method:",
|
||||
"choose_payment_method_traffic": "Choose how to pay for the traffic package:",
|
||||
"pay_button": "💳 Pay",
|
||||
"pay_with_yookassa_button": "💳 YooKassa",
|
||||
"yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:",
|
||||
@@ -37,9 +40,10 @@
|
||||
"back_to_autopay_method_choice_button": "⬅️ Back",
|
||||
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
||||
"pay_with_sbp_button": "📱 SBP",
|
||||
"pay_with_platega_button": "💳 Platega (SBP/Cards)",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"back_to_payment_methods_button": "⬅️ Back",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
"connect_button": "🔗 Connect",
|
||||
"cancel_button": "❌ Cancel",
|
||||
@@ -55,11 +59,15 @@
|
||||
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
||||
|
||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||
"payment_description_traffic": "Traffic package {traffic_gb} GB",
|
||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||
"payment_link_message_traffic": "To pay for a {traffic_gb} GB package, tap the button below:",
|
||||
"free_kassa_order_info": "Order #{order_id} from {date}",
|
||||
"payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.",
|
||||
"payment_invoice_sent_message_traffic": "Invoice for {traffic_gb} GB sent above. Complete the payment or pick another method below.",
|
||||
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
|
||||
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_successful_traffic_full": "✅ Payment successful!\nYour {traffic_gb} GB package is active.\nValidity: {end_date}\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
||||
"config_link_not_available": "not available, contact support",
|
||||
@@ -68,6 +76,11 @@
|
||||
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
||||
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
||||
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"discount_promo_code_applied_success": "✅ Promo code <code>{code}</code> activated!\n\n💰 A {discount}% discount will be applied to your next purchase.\n\nSelect a plan for payment.",
|
||||
"discount_promo_already_active": "❌ You already have an active discount promo code (<code>{code}</code>, -{discount_pct}%). Use it first or wait until the payment is complete.",
|
||||
"discount_promo_expired_need_reactivate": "⏰ The discount hold period has ended{code_part}.\n\nPlease enter the promo code again to apply the discount.",
|
||||
"promo_code_not_found_or_not_discount": "❌ Promo code <code>{code}</code> not found or is invalid.",
|
||||
"active_discount_notice": "🎁 Active discount: <code>{code}</code> (-{discount_pct}%)\n💵 Price: <s>{original_price}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Savings: {discount_amount}{currency_symbol}",
|
||||
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
|
||||
"promo_input_cancelled_short": "Promo code entry cancelled.",
|
||||
"trial_feature_disabled": "Free trial is currently unavailable.",
|
||||
@@ -79,6 +92,7 @@
|
||||
"no_button": "No",
|
||||
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
|
||||
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
|
||||
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
|
||||
"referral_share_message_button": "📩 Message for friend",
|
||||
"referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
|
||||
"friend_placeholder": "friend",
|
||||
@@ -101,7 +115,10 @@
|
||||
"admin_promo_marketing_section": "🎁 Promos",
|
||||
"admin_system_functions_section": "⚙️ System",
|
||||
"admin_ban_management_section": "🚫 Bans",
|
||||
"admin_users_management_button": "👤 Management",
|
||||
"admin_users_search_button": "🔍 Search",
|
||||
"admin_users_management_button": "👥 User List",
|
||||
"admin_users_list_header": "👥 <b>User List</b>\n\nPage {current}/{total} ({total_users} users)",
|
||||
"admin_user_back_to_list_button": "⬅️ Back to List",
|
||||
"back_to_user_management_button": "⬅️ To Users",
|
||||
"back_to_admin_panel_button": "⬅️ To Admin",
|
||||
"admin_stats_header": "📊 Bot Statistics",
|
||||
@@ -127,6 +144,7 @@
|
||||
"admin_csv_provider": "Provider",
|
||||
"admin_csv_status": "Status",
|
||||
"admin_csv_description": "Description",
|
||||
"admin_csv_units": "Months/GB",
|
||||
"admin_csv_months": "Months",
|
||||
"admin_csv_created_at": "Created At",
|
||||
"admin_csv_provider_payment_id": "Provider Payment ID",
|
||||
@@ -156,6 +174,7 @@
|
||||
"admin_promo_invalid_max_activations": "Max activations must be a positive number.",
|
||||
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
|
||||
"admin_promo_created_success": "✅ Promo code <code>{code}</code> created successfully!\nBonus: {bonus_days} days\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
|
||||
"admin_promo_created_success_discount": "✅ Promo code <code>{code}</code> created successfully!\nDiscount: {discount_percentage}%\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
|
||||
"admin_promo_set_validity_days": "⏰ Set validity (days)",
|
||||
"admin_back_to_panel": "⬅️ Back to panel",
|
||||
"admin_promo_unlimited": "♾️ Unlimited",
|
||||
@@ -190,11 +209,13 @@
|
||||
"csv_no": "No",
|
||||
"admin_promo_edit_select_field": "Select a field to edit:",
|
||||
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
|
||||
"admin_promo_prompt_discount_percentage": "Enter the new discount percentage (1-100):",
|
||||
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
|
||||
"admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):",
|
||||
"admin_promo_edit_success": "Promo code updated successfully.",
|
||||
"admin_promo_invalid_input": "Invalid input, please try again.",
|
||||
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
|
||||
"admin_promo_edit_discount_percentage": "💰 Discount %",
|
||||
"admin_promo_edit_max_activations": "🔢 Max Activations",
|
||||
"admin_promo_edit_validity": "⏰ Validity",
|
||||
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
|
||||
@@ -207,6 +228,8 @@
|
||||
"admin_user_card_title": "User Card",
|
||||
"user_card_ban_button": "🚫 Ban",
|
||||
"user_card_unban_button": "✅ Unban",
|
||||
"user_card_open_profile_button": "👤 Open profile",
|
||||
"user_card_open_referrer_profile_button": "👤 Referrer profile",
|
||||
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
||||
"admin_logs_menu_title": "Logs Menu:",
|
||||
"admin_view_all_logs_button": "📜 All Message Logs",
|
||||
@@ -233,8 +256,6 @@
|
||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
|
||||
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
|
||||
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
||||
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
|
||||
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
|
||||
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
|
||||
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
||||
"admin_user_subscription_info": "Subscription Information:",
|
||||
@@ -244,6 +265,16 @@
|
||||
"admin_user_send_message_button": "✉️ Send Message",
|
||||
"admin_user_view_logs_button": "📜 User Actions",
|
||||
"admin_user_refresh_button": "🔄 Refresh",
|
||||
"admin_user_delete_button": "❌ Delete User",
|
||||
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
|
||||
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
|
||||
"admin_user_delete_cancelled": "Deletion cancelled.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.",
|
||||
"admin_user_delete_already_removed": "ℹ️ The user is already removed.",
|
||||
"admin_user_delete_panel_error": "❌ Failed to remove the user from the panel. Operation aborted.",
|
||||
"admin_user_delete_success": "✅ User {user_id} was removed from the bot and the panel.",
|
||||
"admin_user_delete_error": "❌ Unable to delete the user. Please try again later.",
|
||||
"admin_user_search_new_button": "🔍 Find Another",
|
||||
"admin_user_view_all_logs_button": "📋 All Actions",
|
||||
"admin_user_back_to_card_button": "🔙 Back to Card",
|
||||
@@ -278,10 +309,14 @@
|
||||
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
||||
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
||||
"inline_admin_system_stats_title": "🖥 System Statistics",
|
||||
"log_referral_suffix": " (referral from {referrer_id})",
|
||||
"log_referral_suffix": " (referral from {referrer_link})",
|
||||
"log_open_profile_link": "👤 Open profile",
|
||||
"log_open_referrer_profile_button": "👤 Referrer profile",
|
||||
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||
"log_promo_discount_activation": "💰 <b>Discount Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n💵 Discount: <b>{discount_percentage}%</b>\n🕐 Time: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
|
||||
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
|
||||
@@ -309,10 +344,16 @@
|
||||
"admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000",
|
||||
"admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):",
|
||||
"admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...",
|
||||
"admin_promo_step0_type": "Choose promo code type:",
|
||||
"admin_promo_type_bonus_days": "🎁 Bonus Days (subscription extension)",
|
||||
"admin_promo_type_discount": "💰 Purchase Discount (%)",
|
||||
"admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):",
|
||||
"admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):",
|
||||
"admin_promo_step2_discount_percentage": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Discount Percentage\n\nCode: <b>{code}</b>\n\nEnter the discount percentage for the promo code (1-100):",
|
||||
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
|
||||
"admin_promo_step3_max_activations_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\n\nEnter the maximum number of activations (1-10000):",
|
||||
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
|
||||
"admin_promo_step4_validity_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
|
||||
"admin_promo_code_already_exists": "❌ A promo code with this code already exists",
|
||||
"admin_promo_unlimited_validity": "♾️ Unlimited",
|
||||
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
|
||||
@@ -337,6 +378,8 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "No active subscription",
|
||||
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
|
||||
"admin_user_total_paid_label": "💰 <b>Total Paid:</b>",
|
||||
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
||||
@@ -345,7 +388,9 @@
|
||||
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
|
||||
"admin_promo_management_empty": "📭 No promo codes available",
|
||||
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
|
||||
"admin_promo_card_type": "📌 Type: {type}",
|
||||
"admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>",
|
||||
"admin_promo_card_discount_percentage": "💰 Discount: <b>{percentage}%</b>",
|
||||
"admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>",
|
||||
"admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>",
|
||||
"admin_promo_card_status": "📊 Status: <b>{status}</b>",
|
||||
@@ -398,7 +443,11 @@
|
||||
"admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}",
|
||||
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
|
||||
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
||||
"admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>",
|
||||
"admin_payment_months_label": "📅 Period: <b>{months} mo.</b>",
|
||||
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
||||
"my_traffic_details": "🔐 <b>My Traffic</b>\n\n⏰ Status: <b>{status}</b>\n📅 Valid until: <b>{end_date}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</b>",
|
||||
"traffic_no_expiry": "no limit",
|
||||
"autorenew_enable_button": "🔄 Enable auto-renew",
|
||||
"autorenew_disable_button": "🛑 Disable auto-renew",
|
||||
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
||||
@@ -421,9 +470,8 @@
|
||||
"payment_method_tx_history_title": "📜 Transactions history",
|
||||
"payment_method_no_history": "No transactions history.",
|
||||
"subscription_purchase_title": "Subscription purchase for {months} mo.",
|
||||
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
||||
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
||||
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
||||
"traffic_purchase_title": "Traffic purchase {traffic_gb} GB",
|
||||
"autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.",
|
||||
"subscription_not_active": "You don't have an active subscription.",
|
||||
"error_service_unavailable": "Service unavailable. Please try again later.",
|
||||
"error_payment_gateway": "Payment service error. Please try again later.",
|
||||
@@ -455,5 +503,20 @@
|
||||
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
||||
"admin_ads_deleted_success": "Campaign deleted.",
|
||||
"admin_ads_not_found": "Campaign not found.",
|
||||
"admin_bulk_promo_creation_cancelled": "Bulk promo creation cancelled.",
|
||||
"admin_direct_empty_message": "Cannot send an empty message.",
|
||||
"admin_logs_csv_export_failed": "❌ CSV export failed: {error}",
|
||||
"admin_logs_csv_no_data": "No data to export.",
|
||||
"admin_promo_creation_cancelled": "Promo creation cancelled.",
|
||||
"admin_promo_invalid_number": "❌ Invalid number. Enter a number greater than 0.",
|
||||
"admin_sync_errors": "\n🚫 Synchronization errors: {count}",
|
||||
"error_applying_promo_discount": "❌ Error applying promo discount. Please try again later.",
|
||||
"error_generating_referral_link": "Error generating referral link. Please try again later.",
|
||||
"error_occurred_processing_request": "An error occurred while processing your request. Please try again.",
|
||||
"inline_panel_stats_error": "❌ Failed to load panel statistics.",
|
||||
"inline_system_error": "Error loading statistics",
|
||||
"no_bonus_placeholder": "none",
|
||||
"payment_successful_with_promo_full": "✅ Payment successful!\nYour {months}-month subscription has been extended by {bonus_days} bonus days via promo code and is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"system_or_unknown_user": "System/unknown user",
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
|
||||
}
|
||||
|
||||
+73
-9
@@ -9,7 +9,7 @@
|
||||
"menu_activate_trial_button": "🆓 Пробный период",
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||
"no_subscription_options_available": "Выдача подписки не натроена администратором бота",
|
||||
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
|
||||
"menu_referral_inline": "🎁 Рефералы",
|
||||
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
|
||||
"menu_apply_promo_button": "🎟 Промокод",
|
||||
@@ -25,8 +25,11 @@
|
||||
"error_displaying_menu": "Ошибка отображения меню.",
|
||||
"main_menu_unknown_action": "Неизвестное действие.",
|
||||
"select_subscription_period": "Выберите срок подписки:",
|
||||
"select_traffic_package": "Выберите пакет трафика:",
|
||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||
"buy_traffic_package_button": "{traffic_gb} ГБ - {price} {currency_symbol}",
|
||||
"choose_payment_method": "Выберите способ оплаты:",
|
||||
"choose_payment_method_traffic": "Выберите способ оплаты пакета трафика:",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
|
||||
@@ -37,9 +40,10 @@
|
||||
"back_to_autopay_method_choice_button": "⬅️ Назад",
|
||||
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||
"pay_with_sbp_button": "📱 СБП",
|
||||
"pay_with_platega_button": "💳 Platega (СБП/карты)",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"back_to_payment_methods_button": "⬅️ Назад",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
"connect_button": "🔗 Подключиться",
|
||||
|
||||
@@ -55,11 +59,15 @@
|
||||
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_description_traffic": "Пакет трафика {traffic_gb} ГБ",
|
||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||
"payment_link_message_traffic": "Для оплаты пакета {traffic_gb} ГБ нажмите кнопку ниже:",
|
||||
"free_kassa_order_info": "Заказ №{order_id} от {date}",
|
||||
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||
"payment_invoice_sent_message_traffic": "Счет на пакет {traffic_gb} ГБ отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
||||
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_successful_traffic_full": "✅ Оплата прошла успешно!\nВаш пакет {traffic_gb} ГБ активирован.\nДата действия: {end_date}\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
||||
@@ -68,7 +76,13 @@
|
||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"discount_promo_code_applied_success": "✅ Промокод <code>{code}</code> активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.",
|
||||
"discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку (<code>{code}</code>, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.",
|
||||
"discount_promo_expired_need_reactivate": "⏰ Время действия скидки истекло{code_part}.\n\nВведите промокод снова, чтобы применить скидку.",
|
||||
"promo_code_not_found_or_not_discount": "❌ Промокод <code>{code}</code> не найден или недействителен.",
|
||||
"active_discount_notice": "🎁 Активна скидка: <code>{code}</code> (-{discount_pct}%)\n💵 Цена: <s>{original_price}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Экономия: {discount_amount}{currency_symbol}",
|
||||
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
||||
"error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.",
|
||||
"promo_input_cancelled_short": "Ввод промокода отменен.",
|
||||
"trial_feature_disabled": "Пробный период в данный момент недоступен.",
|
||||
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
|
||||
@@ -79,6 +93,7 @@
|
||||
"no_button": "Нет",
|
||||
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
|
||||
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
|
||||
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
|
||||
"referral_share_message_button": "📩 Сообщение для друга",
|
||||
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
"friend_placeholder": "друг",
|
||||
@@ -101,7 +116,10 @@
|
||||
"admin_promo_marketing_section": "🎁 Промокоды",
|
||||
"admin_system_functions_section": "⚙️ Система",
|
||||
"admin_ban_management_section": "🚫 Блокировки",
|
||||
"admin_users_management_button": "👤 Управление",
|
||||
"admin_users_search_button": "🔍 Поиск",
|
||||
"admin_users_management_button": "👥 Список пользователей",
|
||||
"admin_users_list_header": "👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||
"admin_user_back_to_list_button": "⬅️ К списку",
|
||||
"back_to_user_management_button": "⬅️ К пользователям",
|
||||
"back_to_admin_panel_button": "⬅️ В админку",
|
||||
"admin_stats_header": "📊 Статистика Бота",
|
||||
@@ -127,6 +145,7 @@
|
||||
"admin_csv_provider": "Платежная система",
|
||||
"admin_csv_status": "Статус",
|
||||
"admin_csv_description": "Описание",
|
||||
"admin_csv_units": "Месяцы/ГБ",
|
||||
"admin_csv_months": "Месяцев",
|
||||
"admin_csv_created_at": "Дата создания",
|
||||
"admin_csv_provider_payment_id": "ID платежа в системе",
|
||||
@@ -156,6 +175,7 @@
|
||||
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
||||
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
|
||||
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
|
||||
"admin_promo_created_success_discount": "✅ Промокод <code>{code}</code> успешно создан!\nСкидка: {discount_percentage}%\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
|
||||
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
@@ -164,7 +184,6 @@
|
||||
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
||||
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
||||
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
||||
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
|
||||
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
|
||||
"admin_back_to_panel": "⬅️ В панель",
|
||||
@@ -200,11 +219,13 @@
|
||||
"csv_no": "Нет",
|
||||
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
|
||||
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
|
||||
"admin_promo_prompt_discount_percentage": "Введите новый процент скидки (от 1 до 100):",
|
||||
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
|
||||
"admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):",
|
||||
"admin_promo_edit_success": "Промокод успешно обновлен.",
|
||||
"admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.",
|
||||
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
|
||||
"admin_promo_edit_discount_percentage": "💰 Процент скидки",
|
||||
"admin_promo_edit_max_activations": "🔢 Макс. активации",
|
||||
"admin_promo_edit_validity": "⏰ Срок действия",
|
||||
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
||||
@@ -217,6 +238,8 @@
|
||||
"admin_user_card_title": "Карточка пользователя",
|
||||
"user_card_ban_button": "🚫 Заблокировать",
|
||||
"user_card_unban_button": "✅ Разблокировать",
|
||||
"user_card_open_profile_button": "👤 Открыть профиль",
|
||||
"user_card_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||
"admin_logs_menu_title": "Меню логов:",
|
||||
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
||||
@@ -235,7 +258,6 @@
|
||||
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
|
||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||
"tribute_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
|
||||
"admin_user_subscription_info": "Информация о подписке:",
|
||||
"admin_user_reset_trial_button": "🔄 Сбросить триал",
|
||||
@@ -244,6 +266,16 @@
|
||||
"admin_user_send_message_button": "✉️ Сообщение",
|
||||
"admin_user_view_logs_button": "📜 Логи",
|
||||
"admin_user_refresh_button": "🔄 Обновить",
|
||||
"admin_user_delete_button": "❌ Удалить пользователя",
|
||||
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
|
||||
"admin_user_delete_cancelled": "Удаление отменено.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.",
|
||||
"admin_user_delete_already_removed": "ℹ️ Пользователь уже удалён.",
|
||||
"admin_user_delete_panel_error": "❌ Не удалось удалить пользователя на панели. Операция прервана.",
|
||||
"admin_user_delete_success": "✅ Пользователь {user_id} удалён из бота и панели.",
|
||||
"admin_user_delete_error": "❌ Не удалось удалить пользователя. Попробуйте позже.",
|
||||
"admin_user_search_new_button": "🔍 Новый поиск",
|
||||
"admin_user_view_all_logs_button": "📋 Все логи",
|
||||
"admin_user_back_to_card_button": "🔙 К карточке",
|
||||
@@ -278,10 +310,14 @@
|
||||
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
||||
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
||||
"log_referral_suffix": " (реферал от {referrer_id})",
|
||||
"log_referral_suffix": " (реферал от {referrer_link})",
|
||||
"log_open_profile_link": "👤 Открыть профиль",
|
||||
"log_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||
"log_promo_discount_activation": "💰 <b>Активирован промокод на скидку</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n💵 Скидка: <b>{discount_percentage}%</b>\n🕐 Время: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
|
||||
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
|
||||
@@ -309,10 +345,17 @@
|
||||
"admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000",
|
||||
"admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):",
|
||||
"admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...",
|
||||
"admin_promo_step0_type": "Выберите тип промокода:",
|
||||
"admin_promo_type_bonus_days": "🎁 Бонусные дни (продление подписки)",
|
||||
"admin_promo_type_discount": "💰 Скидка на покупку (%)",
|
||||
"admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):",
|
||||
"admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
|
||||
"admin_promo_step2_discount_percentage": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Процент скидки\n\nКод: <b>{code}</b>\n\nВведите процент скидки для промокода <code>{code}</code> (от 1 до 100):",
|
||||
"admin_promo_invalid_discount_percentage": "❌ Процент скидки должен быть от 1 до 100.",
|
||||
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||
"admin_promo_step3_max_activations_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
||||
"admin_promo_step4_validity_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
||||
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
|
||||
"admin_promo_unlimited_validity": "♾️ Неограниченно",
|
||||
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
|
||||
@@ -337,6 +380,8 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "Нет активной подписки",
|
||||
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
|
||||
"admin_user_total_paid_label": "💰 <b>Всего оплачено:</b>",
|
||||
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
||||
@@ -345,7 +390,9 @@
|
||||
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
|
||||
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
|
||||
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
|
||||
"admin_promo_card_type": "📌 Тип: {type}",
|
||||
"admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>",
|
||||
"admin_promo_card_discount_percentage": "💰 Скидка: <b>{percentage}%</b>",
|
||||
"admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>",
|
||||
"admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>",
|
||||
"admin_promo_card_status": "📊 Статус: <b>{status}</b>",
|
||||
@@ -398,7 +445,11 @@
|
||||
"admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}",
|
||||
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
|
||||
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
||||
"admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>",
|
||||
"admin_payment_months_label": "📅 Период: <b>{months} мес.</b>",
|
||||
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
||||
"my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</b>",
|
||||
"traffic_no_expiry": "без ограничения",
|
||||
"autorenew_enable_button": "🔄 Включить автопродление",
|
||||
"autorenew_disable_button": "🛑 Отключить автопродление",
|
||||
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
||||
@@ -421,9 +472,8 @@
|
||||
"payment_method_tx_history_title": "📜 История операций",
|
||||
"payment_method_no_history": "История операций отсутствует.",
|
||||
"subscription_purchase_title": "Покупка подписки на {months} мес.",
|
||||
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
||||
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
||||
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
||||
"traffic_purchase_title": "Покупка {traffic_gb} ГБ",
|
||||
"autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».",
|
||||
"subscription_not_active": "У вас нет активной подписки.",
|
||||
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
||||
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
||||
@@ -455,5 +505,19 @@
|
||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||
"admin_ads_deleted_success": "Кампания удалена.",
|
||||
"admin_ads_not_found": "Кампания не найдена.",
|
||||
"admin_bulk_promo_creation_cancelled": "Массовое создание промокодов отменено.",
|
||||
"admin_direct_empty_message": "Нельзя отправить пустое сообщение.",
|
||||
"admin_logs_csv_export_failed": "❌ Ошибка экспорта CSV: {error}",
|
||||
"admin_logs_csv_no_data": "Нет данных для экспорта.",
|
||||
"admin_promo_creation_cancelled": "Создание промокода отменено.",
|
||||
"admin_promo_invalid_number": "❌ Некорректное число. Введите число больше 0.",
|
||||
"admin_sync_errors": "\n🚫 Ошибок синхронизации: {count}",
|
||||
"error_generating_referral_link": "Ошибка генерации реферальной ссылки. Попробуйте позже.",
|
||||
"error_occurred_processing_request": "Произошла ошибка при обработке запроса. Попробуйте снова.",
|
||||
"inline_panel_stats_error": "❌ Не удалось получить статистику панели.",
|
||||
"inline_system_error": "Ошибка загрузки статистики",
|
||||
"no_bonus_placeholder": "нет",
|
||||
"payment_successful_with_promo_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. продлена на {bonus_days} бонусных дней по промокоду и активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"system_or_unknown_user": "Система/неизвестный пользователь",
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -9,6 +10,21 @@ from config.settings import get_settings, Settings
|
||||
from db.database_setup import init_db, init_db_connection
|
||||
|
||||
|
||||
def _resolve_log_level(value: str) -> int:
|
||||
if not value:
|
||||
return logging.INFO
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return logging.INFO
|
||||
if normalized.isdigit():
|
||||
return int(normalized)
|
||||
level = getattr(logging, normalized.upper(), None)
|
||||
if isinstance(level, int):
|
||||
return level
|
||||
return logging.INFO
|
||||
|
||||
|
||||
async def main():
|
||||
load_dotenv()
|
||||
settings = get_settings()
|
||||
@@ -25,8 +41,9 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_dotenv()
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")),
|
||||
stream=sys.stdout,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
try:
|
||||
|
||||
+10
-10
@@ -1,11 +1,11 @@
|
||||
aiogram==3.21.0
|
||||
python-dotenv==1.0.1
|
||||
aiohttp==3.12.14
|
||||
pydantic==2.7.1
|
||||
yookassa==3.5.0
|
||||
pycountry==23.12.11
|
||||
pydantic_settings
|
||||
sqlalchemy[asyncio]==2.0.29
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
aiogram==3.24.0
|
||||
python-dotenv==1.2.1
|
||||
aiohttp==3.13.3
|
||||
pydantic==2.12.5
|
||||
yookassa==3.9.0
|
||||
httpx>=0.27.0
|
||||
pydantic_settings==2.12.0
|
||||
sqlalchemy[asyncio]==2.0.45
|
||||
asyncpg==0.31.0
|
||||
alembic==1.18.3
|
||||
aiocryptopay==0.4.8
|
||||
|
||||
Reference in New Issue
Block a user