chore: run lint and prettifier

This commit is contained in:
3252a8
2026-05-12 21:54:12 +03:00
parent f31540afdb
commit 11187487b4
174 changed files with 12383 additions and 6688 deletions
+79 -43
View File
@@ -1,25 +1,25 @@
import hashlib
import logging
import json
import hashlib
import hmac
import json
import logging
from typing import Optional
from aiocryptopay import AioCryptoPay, Networks
from aiocryptopay.models.update import Update
from aiogram import Bot
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from aiocryptopay import AioCryptoPay, Networks
from aiocryptopay.models.update import Update
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.middlewares.i18n import JsonI18n
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.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
logger = logging.getLogger(__name__)
@@ -87,11 +87,15 @@ class CryptoPayService:
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"subscription_duration_months": int(months)
if sale_base == "subscription"
else None,
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
},
)
await session.commit()
@@ -102,19 +106,27 @@ class CryptoPayService:
exc_info=True,
)
return None
payload = json.dumps({
"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_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
})
payload = json.dumps(
{
"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_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
}
)
try:
invoice = await self.client.create_invoice(
amount=amount,
currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE,
fiat=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat" else None,
asset=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto" else None,
fiat=self.settings.CRYPTOPAY_ASSET
if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat"
else None,
asset=self.settings.CRYPTOPAY_ASSET
if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto"
else None,
description=description,
payload=payload,
)
@@ -154,7 +166,9 @@ class CryptoPayService:
user_id = int(meta["user_id"])
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")
sale_mode = meta.get("sale_mode") or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
except Exception:
@@ -184,7 +198,9 @@ class CryptoPayService:
payment_db_id,
provider="cryptopay",
sale_mode=sale_mode,
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=traffic_gb
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
if sale_base == "subscription":
@@ -203,7 +219,11 @@ class CryptoPayService:
db_user = await user_dal.get_user_by_id(session, user_id)
# Use DB language for user-facing messages
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
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)
raw_config_link = activation.get("subscription_url") if activation else None
@@ -216,32 +236,46 @@ class CryptoPayService:
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
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)
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)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
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=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_text)
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
text = _(
"payment_successful_with_referral_bonus_full",
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_text,
)
else:
text = _("payment_successful_full",
months=int(months),
end_date=final_end.strftime('%Y-%m-%d') if final_end else "",
config_link=config_link_text)
text = _(
"payment_successful_full",
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,
@@ -275,7 +309,9 @@ class CryptoPayService:
amount=float(invoice.amount),
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
months=int(months) if sale_base == "subscription" else 0,
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=traffic_gb
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="crypto_pay",
username=user.username if user else None,
traffic_is_premium=sale_base == "premium_topup",
+5 -3
View File
@@ -7,7 +7,7 @@ import secrets
import smtplib
import ssl
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from email.utils import formataddr
from typing import Optional
@@ -15,10 +15,10 @@ from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bot.services.email_templates import EmailContent, render_login_code
from config.settings import Settings
from db.dal import security_dal
from db.models import EmailVerificationCode
from bot.services.email_templates import EmailContent, render_login_code
logger = logging.getLogger(__name__)
@@ -130,7 +130,9 @@ class EmailAuthService:
existing_query = parsed.query
new_query = urlencode(params)
merged_query = f"{existing_query}&{new_query}" if existing_query else new_query
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, merged_query, parsed.fragment))
return urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path, merged_query, parsed.fragment)
)
async def request_code(
self,
+23 -17
View File
@@ -18,7 +18,6 @@ from urllib.parse import urlsplit
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
from config.settings import Settings
_BG = "#05070a"
_CARD_BG = "#0e1116"
_BORDER = "#1a1f27"
@@ -152,10 +151,10 @@ def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
for index, (label, value) in enumerate(rows):
border = "" if index == last else f"border-bottom:1px solid {_BORDER};"
cells.append(
f'<tr>'
f"<tr>"
f'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>'
f'<td align="right" style="padding:11px 0;{border}font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};">{html.escape(value)}</td>'
f'</tr>'
f"<td align=\"right\" style=\"padding:11px 0;{border}font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};\">{html.escape(value)}</td>"
f"</tr>"
)
return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
@@ -175,9 +174,9 @@ def _cta_button_html(*, label: str, url: str, accent: str) -> str:
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">'
f'<a href="{safe_url}" target="_blank" rel="noopener" '
f'style="display:block;width:100%;box-sizing:border-box;padding:15px 22px;'
f'font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;'
f"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"
f'font-size:15px;font-weight:700;color:#05070a;text-decoration:none;letter-spacing:0.02em;text-align:center;">{safe_label}</a>'
f'</td></tr></table>'
f"</td></tr></table>"
)
@@ -226,16 +225,14 @@ def render_login_code(
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)]
if safe_magic_link:
text_lines.append(
_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)
)
text_lines.append(_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link))
code_block = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">'
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">'
f'<div style="font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">'
f'{html.escape(code)}'
f'</div></td></tr></table>'
f"<div style=\"font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};\">"
f"{html.escape(code)}"
f"</div></td></tr></table>"
)
magic_block = ""
@@ -246,11 +243,11 @@ def render_login_code(
magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint")
divider_html = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">'
f'<tr>'
f"<tr>"
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'</tr></table>'
f"</tr></table>"
)
magic_block = (
divider_html
@@ -347,7 +344,12 @@ def render_payment_success(
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {"traffic", "traffic_package", "topup", "premium_topup"}
is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {
"traffic",
"traffic_package",
"topup",
"premium_topup",
}
amount_text = _format_amount(amount, currency)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or ""
@@ -363,7 +365,9 @@ def render_payment_success(
if is_traffic:
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
period_value = _t_text(i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label)
period_value = _t_text(
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
)
text = _t_text(
i18n,
lang,
@@ -401,7 +405,9 @@ def render_payment_success(
text_lines = [text]
if safe_dashboard_url:
text_lines.append(_t_text(i18n, lang, "email_payment_success_text_dashboard", url=safe_dashboard_url))
text_lines.append(
_t_text(i18n, lang, "email_payment_success_text_dashboard", url=safe_dashboard_url)
)
body_parts = [_info_rows_html(rows)]
if safe_dashboard_url:
+87 -35
View File
@@ -1,28 +1,28 @@
import asyncio
from datetime import datetime
import asyncio
import hashlib
import hmac
import json
import logging
import time
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any, Tuple
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import parse_qsl
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot
from aiohttp import ClientSession, ClientTimeout, web
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.middlewares.i18n import JsonI18n
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.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
class FreeKassaService:
@@ -58,9 +58,13 @@ class FreeKassaService:
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
if not self.configured:
logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.")
logging.warning(
"FreeKassaService initialized but not fully configured. Payments disabled."
)
if settings.FREEKASSA_ENABLED and not self.server_ip:
logging.warning("FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider.")
logging.warning(
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider."
)
@staticmethod
def _format_amount(amount: float) -> str:
@@ -124,8 +128,14 @@ class FreeKassaService:
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("FreeKassa create_order: failed to decode JSON: %s", response_text)
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
logging.error(
"FreeKassa create_order: failed to decode JSON: %s", response_text
)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if response.status != 200 or response_data.get("type") != "success":
logging.error(
@@ -163,7 +173,9 @@ class FreeKassaService:
]
items.sort(key=lambda pair: pair[0])
message = "|".join(str(value) for _, value in items)
return hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.new(
self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
async def close(self) -> None:
if self._session and not self._session.closed:
@@ -210,7 +222,9 @@ class FreeKassaService:
else:
payload_dict = {
str(key): value
for key, value in parse_qsl(raw_body.decode("utf-8"), keep_blank_values=True)
for key, value in parse_qsl(
raw_body.decode("utf-8"), keep_blank_values=True
)
}
except Exception:
payload_dict = {}
@@ -255,14 +269,21 @@ class FreeKassaService:
# Optional amount verification
try:
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:
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(
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
f"(expected {expected_amount}, got {amount_decimal})"
)
except Exception as e:
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
logging.warning(
f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}"
)
activation = None
referral_bonus = None
@@ -275,7 +296,9 @@ class FreeKassaService:
)
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription")
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
activation = await self.subscription_service.activate_subscription(
@@ -286,7 +309,9 @@ class FreeKassaService:
payment.payment_id,
provider="freekassa",
sale_mode=sale_mode,
traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
@@ -306,15 +331,23 @@ class FreeKassaService:
return web.Response(status=500, text="processing_error")
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
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_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.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription")
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
applied_days = 0
@@ -332,25 +365,40 @@ class FreeKassaService:
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
if sale_mode.split("@", 1)[0].split("|", 1)[0] in {"traffic", "traffic_package", "topup", "premium_topup"}:
text = _("payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=end_date_str if final_end else "",
config_link=config_link_text)
if sale_mode.split("@", 1)[0].split("|", 1)[0] in {
"traffic",
"traffic_package",
"topup",
"premium_topup",
}:
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)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
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)
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
text = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else end_date_str,
base_end_date=activation["end_date"].strftime("%Y-%m-%d")
if activation and activation.get("end_date")
else end_date_str,
bonus_days=applied_days,
final_end_date=end_date_str,
inviter_name=inviter_name_display,
@@ -388,7 +436,9 @@ class FreeKassaService:
disable_web_page_preview=True,
)
except Exception:
logging.exception("FreeKassa notification: failed to send message to user %s.", payment.user_id)
logging.exception(
"FreeKassa notification: failed to send message to user %s.", payment.user_id
)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
@@ -397,7 +447,9 @@ class FreeKassaService:
amount=float(payment.amount),
currency=self.default_currency,
months=int(months) if sale_base == "subscription" else 0,
traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="freekassa",
username=db_user.username if db_user else None,
traffic_is_premium=sale_base == "premium_topup",
+5
View File
@@ -18,12 +18,14 @@ 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"
@@ -31,6 +33,7 @@ class IncomeType(str, Enum):
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
@@ -38,11 +41,13 @@ class LknpdApiError(Exception):
class LknpdAuthError(LknpdApiError):
"""Authentication error (401)."""
pass
class LknpdValidationError(LknpdApiError):
"""Validation error (400)."""
pass
+1 -1
View File
@@ -3,7 +3,7 @@ import logging
from datetime import datetime
from typing import Optional
from .lknpd_client import LknpdClient, PaymentType, LknpdApiError
from .lknpd_client import LknpdApiError, LknpdClient, PaymentType
class LknpdService:
+165 -134
View File
@@ -1,29 +1,28 @@
import logging
import asyncio
from datetime import datetime, timezone
from typing import Any, Callable, Dict, Optional
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.text_decorations import html_decoration as hd
from aiogram.exceptions import TelegramBadRequest
from datetime import datetime, timezone
from typing import Optional, Union, Dict, Any, Callable
from config.settings import Settings
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.utils.message_queue import get_queue_manager
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,
)
from bot.utils.text_sanitizer import (
display_name_or_fallback,
username_for_display,
)
from config.settings import Settings
class NotificationService:
"""Enhanced notification service for sending messages to admins and log channels"""
def __init__(self, bot: Bot, settings: Settings, i18n: Optional[JsonI18n] = None):
self.bot = bot
self.settings = settings
@@ -53,23 +52,27 @@ class NotificationService:
"""
buttons = []
if user_id and user_id > 0:
buttons.append([
InlineKeyboardButton(
text=translate("log_open_profile_link"),
url=f"tg://user?id={user_id}",
)
])
buttons.append(
[
InlineKeyboardButton(
text=translate("log_open_profile_link"),
url=f"tg://user?id={user_id}",
)
]
)
if referrer_id and referrer_id > 0:
buttons.append([
InlineKeyboardButton(
text=translate("log_open_referrer_profile_button"),
url=f"tg://user?id={referrer_id}",
)
])
buttons.append(
[
InlineKeyboardButton(
text=translate("log_open_referrer_profile_button"),
url=f"tg://user?id={referrer_id}",
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None
async def _send_to_log_channel(
self,
message: str,
@@ -79,7 +82,7 @@ class NotificationService:
"""Send message to configured log channel/group using message queue"""
if not self.settings.LOG_CHAT_ID:
return
queue_manager = get_queue_manager()
if not queue_manager:
logging.warning("Message queue manager not available, falling back to direct send")
@@ -121,36 +124,36 @@ class NotificationService:
f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}"
)
except Exception:
logging.exception("Failed to send notification to log channel %s.", self.settings.LOG_CHAT_ID)
logging.exception(
"Failed to send notification to log channel %s.", self.settings.LOG_CHAT_ID
)
return
try:
# Use thread_id if provided, otherwise use from settings
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
kwargs = {
"text": message,
"parse_mode": "HTML",
"disable_web_page_preview": True
}
kwargs = {"text": message, "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:
kwargs["message_thread_id"] = final_thread_id
# Queue message for sending (groups are rate limited to 15/minute)
await queue_manager.send_message(self.settings.LOG_CHAT_ID, **kwargs)
except Exception:
logging.exception("Failed to queue notification to log channel %s.", self.settings.LOG_CHAT_ID)
logging.exception(
"Failed to queue notification to log channel %s.", self.settings.LOG_CHAT_ID
)
async def _send_to_admins(self, message: str):
"""Send message to all admin users using message queue"""
if not self.settings.ADMIN_IDS:
return
queue_manager = get_queue_manager()
if not queue_manager:
logging.warning("Message queue manager not available, falling back to direct send")
@@ -160,39 +163,40 @@ class NotificationService:
chat_id=admin_id,
text=message,
parse_mode="HTML",
disable_web_page_preview=True
disable_web_page_preview=True,
)
except Exception:
logging.exception("Failed to send notification to admin %s.", admin_id)
return
for admin_id in self.settings.ADMIN_IDS:
try:
await queue_manager.send_message(
chat_id=admin_id,
text=message,
parse_mode="HTML",
disable_web_page_preview=True
chat_id=admin_id, text=message, parse_mode="HTML", disable_web_page_preview=True
)
except Exception:
logging.exception("Failed to queue notification to admin %s.", admin_id)
async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None,
first_name: Optional[str] = None,
referred_by_id: Optional[int] = None):
async def notify_new_user_registration(
self,
user_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
referred_by_id: Optional[int] = None,
):
"""Send notification about new user registration"""
if not self.settings.LOG_NEW_USERS:
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,
first_name=first_name,
)
referral_text = ""
if referred_by_id:
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
@@ -200,13 +204,13 @@ class NotificationService:
"log_referral_suffix",
referrer_link=referrer_link,
)
message = _(
"log_new_user_registration",
user_id=user_id,
user_display=user_display,
referral_text=referral_text,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
# Send to log channel
@@ -246,12 +250,16 @@ class NotificationService:
# so we only attach the referrer button when a real referrer is present.
reply_markup: Optional[InlineKeyboardMarkup] = None
if referred_by_id and referred_by_id > 0:
reply_markup = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(
text=_("log_open_referrer_profile_button"),
url=f"tg://user?id={referred_by_id}",
)
]])
reply_markup = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=_("log_open_referrer_profile_button"),
url=f"tg://user?id={referred_by_id}",
)
]
]
)
await self._send_to_log_channel(message, reply_markup=reply_markup)
@@ -342,25 +350,31 @@ class NotificationService:
except Exception:
return str(tariff_key)
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
months: int, payment_provider: str,
username: Optional[str] = None,
traffic_gb: Optional[float] = None,
*,
traffic_is_premium: bool = False,
tariff_key: Optional[str] = None):
async def notify_payment_received(
self,
user_id: int,
amount: float,
currency: str,
months: int,
payment_provider: str,
username: Optional[str] = None,
traffic_gb: Optional[float] = None,
*,
traffic_is_premium: bool = False,
tariff_key: Optional[str] = None,
):
"""Send notification about successful payment"""
if not self.settings.LOG_PAYMENTS:
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,
)
provider_emoji = {
"yookassa": "💳",
"freekassa": "💳",
@@ -373,14 +387,16 @@ class NotificationService:
if traffic_gb is not None:
traffic_label = self._format_traffic_gb_admin(float(traffic_gb))
traffic_kind = _(
"log_payment_traffic_kind_premium" if traffic_is_premium else "log_payment_traffic_kind_regular",
"log_payment_traffic_kind_premium"
if traffic_is_premium
else "log_payment_traffic_kind_regular",
)
traffic_summary = _(
"log_payment_traffic_purchase_line", gb=traffic_label, kind=traffic_kind
)
traffic_summary = _("log_payment_traffic_purchase_line", gb=traffic_label, kind=traffic_kind)
tariff_name = self._tariff_display_for_log(tariff_key)
tariff_line = (
_("log_payment_tariff_line", name=hd.quote(tariff_name))
if tariff_name
else ""
_("log_payment_tariff_line", name=hd.quote(tariff_name)) if tariff_name else ""
)
message = _(
"log_payment_received_traffic",
@@ -391,7 +407,7 @@ class NotificationService:
traffic_summary=traffic_summary,
tariff_line=tariff_line,
payment_provider=payment_provider,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
else:
message = _(
@@ -402,81 +418,86 @@ class NotificationService:
currency=currency,
months=months,
payment_provider=payment_provider,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
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_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
username: Optional[str] = None):
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",
user_display=user_display,
promo_code=promo_code,
bonus_days=bonus_days,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
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):
"""Send notification about trial activation"""
if not self.settings.LOG_TRIAL_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_trial_activation",
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
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,
username: Optional[str] = None):
"""Send notification about panel synchronization"""
if not getattr(self.settings, 'LOG_PANEL_SYNC', True):
async def notify_trial_activation(
self, user_id: int, end_date: datetime, username: Optional[str] = None
):
"""Send notification about trial activation"""
if not self.settings.LOG_TRIAL_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_trial_activation",
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
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,
username: Optional[str] = None,
):
"""Send notification about panel synchronization"""
if not getattr(self.settings, "LOG_PANEL_SYNC", True):
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
# Status emoji based on sync result
status_emoji = {
"completed": "",
"completed_with_errors": "⚠️",
"failed": ""
}.get(status, "🔄")
status_emoji = {"completed": "", "completed_with_errors": "⚠️", "failed": ""}.get(
status, "🔄"
)
message = _(
"log_panel_sync",
status_emoji=status_emoji,
@@ -484,22 +505,25 @@ class NotificationService:
users_processed=users_processed,
subs_synced=subs_synced,
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"),
details=details
details=details,
)
# Send to log channel
await self._send_to_log_channel(message)
async def notify_suspicious_promo_attempt(
self, user_id: int, suspicious_input: str,
username: Optional[str] = None, first_name: Optional[str] = None):
self,
user_id: int,
suspicious_input: str,
username: Optional[str] = None,
first_name: Optional[str] = None,
):
"""Send notification about a suspicious promo code attempt."""
if not self.settings.LOG_SUSPICIOUS_ACTIVITY:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(
admin_lang, k, **kw) if self.i18n else k
_ = 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,
@@ -512,18 +536,25 @@ class NotificationService:
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"))
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"),
)
# 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 send_custom_notification(self, message: str, to_admins: bool = False,
to_log_channel: bool = True, thread_id: Optional[int] = None):
async def send_custom_notification(
self,
message: str,
to_admins: bool = False,
to_log_channel: bool = True,
thread_id: Optional[int] = None,
):
"""Send custom notification message"""
if to_log_channel:
await self._send_to_log_channel(message, thread_id)
if to_admins:
await self._send_to_admins(message)
# Removed legacy helper functions that duplicated NotificationService API
+156 -205
View File
@@ -1,12 +1,12 @@
import aiohttp
import logging
import json
import re
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta, timezone
import asyncio
import json
import logging
import re
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from urllib.parse import urlencode
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
@@ -15,7 +15,6 @@ from db.models import PanelSyncStatus
class PanelApiService:
def __init__(self, settings: Settings):
self.settings = settings
self.base_url = settings.PANEL_API_URL
@@ -59,19 +58,12 @@ class PanelApiService:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
async def _request(self,
method: str,
endpoint: str,
log_full_response: bool = False,
**kwargs) -> Optional[Dict[str, Any]]:
async def _request(
self, method: str, endpoint: str, log_full_response: bool = False, **kwargs
) -> Optional[Dict[str, Any]]:
if not self.base_url:
logging.error(
"Panel API URL (PANEL_API_URL) not configured in settings.")
return {
"error": True,
"status_code": 0,
"message": "Panel API URL not configured."
}
logging.error("Panel API URL (PANEL_API_URL) not configured in settings.")
return {"error": True, "status_code": 0, "message": "Panel API URL not configured."}
aiohttp_session = await self._get_session()
headers = await self._prepare_headers()
@@ -86,21 +78,22 @@ class PanelApiService:
except Exception:
pass
json_payload_for_log = kwargs.get('json') if method.upper() in [
"POST", "PATCH", "PUT"
] else None
json_payload_for_log = (
kwargs.get("json") if method.upper() in ["POST", "PATCH", "PUT"] else None
)
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)
log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}"
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]}..."
try:
async with aiohttp_session.request(method.upper(),
url_for_request,
headers=headers,
**kwargs) as response:
async with aiohttp_session.request(
method.upper(), url_for_request, headers=headers, **kwargs
) as response:
response_status = response.status
response_text = await response.text()
@@ -109,9 +102,9 @@ class PanelApiService:
if log_full_response or not (200 <= response_status < 300):
try:
parsed_json_for_log = json.loads(response_text)
pretty_response_text = json.dumps(parsed_json_for_log,
indent=2,
ensure_ascii=False)
pretty_response_text = json.dumps(
parsed_json_for_log, indent=2, ensure_ascii=False
)
logging.info(
f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}"
)
@@ -126,15 +119,14 @@ class PanelApiService:
if 200 <= response_status < 300:
try:
if 'application/json' in response.headers.get(
'Content-Type', '').lower():
if "application/json" in response.headers.get("Content-Type", "").lower():
data = json.loads(response_text)
return data
else:
return {
"status": "success",
"code": response_status,
"data_text": response_text
"data_text": response_text,
}
except json.JSONDecodeError as e_json_ok:
logging.error(
@@ -144,72 +136,46 @@ class PanelApiService:
"status": "success_parse_error",
"code": response_status,
"data_text": response_text,
"parse_error": str(e_json_ok)
"parse_error": str(e_json_ok),
}
else:
error_details = {
"message":
f"Request failed with status {response_status}",
"raw_response_text": response_text
"message": f"Request failed with status {response_status}",
"raw_response_text": response_text,
}
try:
if 'application/json' in response.headers.get(
'Content-Type', '').lower():
if "application/json" in response.headers.get("Content-Type", "").lower():
error_json_data = json.loads(response_text)
error_details.update(error_json_data)
except json.JSONDecodeError:
pass
return {
"error": True,
"status_code": response_status,
"details": error_details
}
return {"error": True, "status_code": response_status, "details": error_details}
except aiohttp.ClientConnectorError as e:
logging.error(
f"Panel API ClientConnectorError to {url_for_request}: {e}")
return {
"error": True,
"status_code": -1,
"message": f"Connection error: {str(e)}"
}
logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}")
return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"}
except aiohttp.ClientError as e:
logging.exception("Panel API ClientError to %s.", url_for_request)
return {
"error": True,
"status_code": -2,
"message": f"Client error: {str(e)}"
}
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
except asyncio.TimeoutError:
logging.error(f"Panel API request to {url_for_request} timed out.")
return {
"error": True,
"status_code": -3,
"message": "Request timed out"
}
return {"error": True, "status_code": -3, "message": "Request timed out"}
except Exception as e:
logging.error(
f"Unexpected Panel API request error to {url_for_request}: {e}",
exc_info=True)
return {
"error": True,
"status_code": -4,
"message": f"Unexpected error: {str(e)}"
}
f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True
)
return {"error": True, "status_code": -4, "message": f"Unexpected error: {str(e)}"}
async def get_all_panel_users(
self,
page_size: int = 100,
log_responses: bool = False) -> Optional[List[Dict[str, Any]]]:
self, page_size: int = 100, log_responses: bool = False
) -> Optional[List[Dict[str, Any]]]:
all_users = []
start_offset = 0
while True:
params = {"size": page_size, "start": start_offset}
response_data = await self._request(
"GET",
"/users",
params=params,
log_full_response=log_responses)
"GET", "/users", params=params, log_full_response=log_responses
)
if not response_data or response_data.get("error"):
logging.error(
@@ -217,24 +183,22 @@ class PanelApiService:
)
return None
users_batch = response_data.get("response", {}).get("users", [])
if not users_batch: break
if not users_batch:
break
all_users.extend(users_batch)
if len(users_batch) < page_size: break
if len(users_batch) < page_size:
break
start_offset += page_size
await asyncio.sleep(0.1)
logging.info(f"Fetched {len(all_users)} users from panel API.")
return all_users
async def get_user_by_uuid(
self,
user_uuid: str,
log_response: bool = True) -> Optional[Dict[str, Any]]:
self, user_uuid: str, log_response: bool = True
) -> Optional[Dict[str, Any]]:
endpoint = f"/users/{user_uuid}"
full_response = await self._request("GET",
endpoint,
log_full_response=log_response)
if full_response and not full_response.get(
"error") and "response" in full_response:
full_response = await self._request("GET", endpoint, log_full_response=log_response)
if full_response and not full_response.get("error") and "response" in full_response:
return full_response.get("response")
return None
@@ -262,11 +226,12 @@ class PanelApiService:
return None
async def get_users_by_filter(
self,
telegram_id: Optional[int] = None,
username: Optional[str] = None,
email: Optional[str] = None,
log_response: bool = True) -> Optional[List[Dict[str, Any]]]:
self,
telegram_id: Optional[int] = None,
username: Optional[str] = None,
email: Optional[str] = None,
log_response: bool = True,
) -> Optional[List[Dict[str, Any]]]:
response_data = None
filter_used_log = "No filter specified"
@@ -274,55 +239,53 @@ class PanelApiService:
if telegram_id is not None:
filter_used_log = f"telegramId={telegram_id}"
endpoint = f"/users/by-telegram-id/{telegram_id}"
response_data = await self._request("GET",
endpoint,
log_full_response=log_response)
response_data = await self._request("GET", endpoint, log_full_response=log_response)
if response_data and not response_data.get(
"error") and "response" in response_data and isinstance(
response_data["response"], list):
if (
response_data
and not response_data.get("error")
and "response" in response_data
and isinstance(response_data["response"], list)
):
return response_data["response"]
elif response_data and response_data.get("errorCode") == "A062":
logging.info(
f"Panel API: Users not found for {filter_used_log}")
logging.info(f"Panel API: Users not found for {filter_used_log}")
return []
elif username is not None:
filter_used_log = f"username={username}"
endpoint = f"/users/by-username/{username}"
response_data = await self._request("GET",
endpoint,
log_full_response=log_response)
response_data = await self._request("GET", endpoint, log_full_response=log_response)
if response_data and not response_data.get(
"error") and "response" in response_data and isinstance(
response_data["response"], dict):
if (
response_data
and not response_data.get("error")
and "response" in response_data
and isinstance(response_data["response"], dict)
):
return [response_data["response"]]
elif response_data and response_data.get("errorCode") == "A062":
logging.info(
f"Panel API: User not found for {filter_used_log}")
logging.info(f"Panel API: User not found for {filter_used_log}")
return []
elif email is not None:
filter_used_log = f"email={email}"
endpoint = f"/users/by-email/{email}"
response_data = await self._request("GET",
endpoint,
log_full_response=log_response)
response_data = await self._request("GET", endpoint, log_full_response=log_response)
if response_data and not response_data.get(
"error") and "response" in response_data and isinstance(
response_data["response"], list):
if (
response_data
and not response_data.get("error")
and "response" in response_data
and isinstance(response_data["response"], list)
):
return response_data["response"]
elif response_data and response_data.get("errorCode") == "A062":
logging.info(
f"Panel API: Users not found for {filter_used_log}")
logging.info(f"Panel API: Users not found for {filter_used_log}")
return []
if not telegram_id and not username and not email:
logging.warning(
"get_users_by_filter called without any specific filter criteria."
)
logging.warning("get_users_by_filter called without any specific filter criteria.")
return []
logging.error(
@@ -331,20 +294,21 @@ class PanelApiService:
return None
async def create_panel_user(
self,
username_on_panel: str,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
default_expire_days: int = 1,
default_traffic_limit_bytes: int = 0,
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]]:
self,
username_on_panel: str,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
default_expire_days: int = 1,
default_traffic_limit_bytes: int = 0,
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]]:
username_is_valid = (
3 <= len(username_on_panel) <= 36
@@ -357,13 +321,12 @@ class PanelApiService:
"error": True,
"status_code": 400,
"message": msg,
"errorCode": "VALIDATION_ERROR_USERNAME"
"errorCode": "VALIDATION_ERROR_USERNAME",
}
now = datetime.now(timezone.utc)
expire_at_dt = now + timedelta(days=default_expire_days)
expire_at_iso = expire_at_dt.isoformat(
timespec='milliseconds').replace('+00:00', 'Z')
expire_at_iso = expire_at_dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
payload: Dict[str, Any] = {
"username": username_on_panel,
@@ -388,18 +351,21 @@ class PanelApiService:
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
if tag: payload["tag"] = tag
if telegram_id is not None:
payload["telegramId"] = telegram_id
if email:
payload["email"] = email
if description:
payload["description"] = description
if tag:
payload["tag"] = tag
response = await self._request("POST",
"/users",
json=payload,
log_full_response=log_response)
response = await self._request(
"POST", "/users", json=payload, log_full_response=log_response
)
if response and not response.get("error") and "response" in response:
logging.info(
f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response',{}).get('uuid')})."
f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response', {}).get('uuid')})."
)
return response
@@ -409,19 +375,15 @@ class PanelApiService:
return response
async def update_user_details_on_panel(
self,
user_uuid: str,
update_payload: Dict[str, Any],
log_response: bool = True) -> Optional[Dict[str, Any]]:
if 'uuid' not in update_payload:
update_payload['uuid'] = user_uuid
self, user_uuid: str, update_payload: Dict[str, Any], log_response: bool = True
) -> Optional[Dict[str, Any]]:
if "uuid" not in update_payload:
update_payload["uuid"] = user_uuid
full_response = await self._request("PATCH",
"/users",
json=update_payload,
log_full_response=log_response)
if full_response and not full_response.get(
"error") and "response" in full_response:
full_response = await self._request(
"PATCH", "/users", json=update_payload, log_full_response=log_response
)
if full_response and not full_response.get("error") and "response" in full_response:
logging.info(f"User {user_uuid} details updated on panel.")
return full_response.get("response")
@@ -430,18 +392,14 @@ class PanelApiService:
)
return None
async def update_user_status_on_panel(self,
user_uuid: str,
enable: bool,
log_response: bool = True) -> bool:
async def update_user_status_on_panel(
self, user_uuid: str, enable: bool, log_response: bool = True
) -> bool:
action = "enable" if enable else "disable"
endpoint = f"/users/{user_uuid}/actions/{action}"
response_data = await self._request("POST",
endpoint,
log_full_response=log_response)
response_data = await self._request("POST", endpoint, log_full_response=log_response)
if response_data and not response_data.get(
"error") and "response" in response_data:
if response_data and not response_data.get("error") and "response" in response_data:
actual_status = response_data.get("response", {}).get("status")
expected_status = "ACTIVE" if enable else "DISABLED"
if actual_status == expected_status:
@@ -460,14 +418,10 @@ class PanelApiService:
)
return False
async def delete_user_from_panel(self,
user_uuid: str,
log_response: bool = True) -> bool:
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
)
response_data = await self._request("DELETE", endpoint, log_full_response=log_response)
if not response_data:
logging.error(
@@ -483,21 +437,17 @@ class PanelApiService:
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}"
)
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,
client_type: Optional[str] = None) -> Optional[str]:
self, short_uuid_or_sub_uuid: str, client_type: Optional[str] = None
) -> Optional[str]:
if not self.settings.PANEL_API_URL:
logging.error(
"PANEL_API_URL not set, cannot generate subscription link.")
logging.error("PANEL_API_URL not set, cannot generate subscription link.")
return None
base_sub_url = f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
if client_type:
@@ -509,17 +459,12 @@ class PanelApiService:
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
logging.error(
f"Failed to get user devices for user {user_uuid}. Response: {response_data}"
)
logging.error(f"Failed to get user devices for user {user_uuid}. Response: {response_data}")
return None
async def disconnect_device(self, user_uuid: str, hwid: str) -> bool:
endpoint = f"/hwid/devices/delete"
payload = {
"userUuid": user_uuid,
"hwid": hwid
}
endpoint = "/hwid/devices/delete"
payload = {"userUuid": user_uuid, "hwid": hwid}
response_data = await self._request("POST", endpoint, json=payload, log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
return True
@@ -528,18 +473,19 @@ class PanelApiService:
)
return False
async def update_bot_db_sync_status(self,
session: AsyncSession,
status: str,
details: str,
users_processed: int = 0,
subs_synced: int = 0):
await panel_sync_dal.update_panel_sync_status(session, status, details,
users_processed,
subs_synced)
async def update_bot_db_sync_status(
self,
session: AsyncSession,
status: str,
details: str,
users_processed: int = 0,
subs_synced: int = 0,
):
await panel_sync_dal.update_panel_sync_status(
session, status, details, users_processed, subs_synced
)
async def get_bot_db_last_sync_status(
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
async def get_bot_db_last_sync_status(self, session: AsyncSession) -> Optional[PanelSyncStatus]:
return await panel_sync_dal.get_panel_sync_status(session)
async def get_system_stats(self) -> Optional[Dict[str, Any]]:
@@ -551,7 +497,9 @@ class PanelApiService:
async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]:
"""Get bandwidth statistics"""
response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False)
response_data = await self._request(
"GET", "/system/stats/bandwidth", log_full_response=False
)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
return None
@@ -587,7 +535,9 @@ class PanelApiService:
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
logging.error("Failed to get bandwidth stats for user %s. Response: %s", user_uuid, response_data)
logging.error(
"Failed to get bandwidth stats for user %s. Response: %s", user_uuid, response_data
)
return None
async def get_node_users_bandwidth_stats(
@@ -713,7 +663,9 @@ class PanelApiService:
logging.error("Failed to add users to squad %s. Response: %s", squad_uuid, response_data)
return False
async def remove_users_from_internal_squad(self, squad_uuid: str, user_uuids: List[str]) -> bool:
async def remove_users_from_internal_squad(
self, squad_uuid: str, user_uuids: List[str]
) -> bool:
endpoint = f"/internal-squads/{squad_uuid}/bulk-actions/remove-users"
response_data = await self._request(
"DELETE",
@@ -723,7 +675,9 @@ class PanelApiService:
)
if response_data and not response_data.get("error"):
return True
logging.error("Failed to remove users from squad %s. Response: %s", squad_uuid, response_data)
logging.error(
"Failed to remove users from squad %s. Response: %s", squad_uuid, response_data
)
return False
async def get_nodes_online_lookups(self) -> Dict[str, Dict[str, int]]:
@@ -795,10 +749,7 @@ class PanelApiService:
"""
payload = {"linkToEncrypt": link_to_encrypt}
response_data = await self._request(
"POST",
"/system/tools/happ/encrypt",
json=payload,
log_full_response=False
"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")
+51 -19
View File
@@ -1,19 +1,25 @@
import hashlib
import hmac
import json
import logging
import hmac
import hashlib
from aiohttp import web
from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from aiohttp import web
from sqlalchemy.orm import sessionmaker
from typing import Optional
from bot.keyboards.inline.user_keyboards import (
get_autorenew_cancel_keyboard,
get_subscribe_only_markup,
)
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from .panel_api_service import PanelApiService
from db.dal import user_dal
from .email_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring
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 .panel_api_service import PanelApiService
EVENT_MAP = {
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
@@ -21,8 +27,16 @@ EVENT_MAP = {
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
}
class PanelWebhookService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n, async_session_factory: sessionmaker, panel_service: PanelApiService):
def __init__(
self,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
panel_service: PanelApiService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
@@ -64,7 +78,11 @@ class PanelWebhookService:
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
internal_user_id = db_user.user_id if db_user else user_id
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
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}"
user_email = (db_user.email or "").strip() if db_user else ""
@@ -79,10 +97,15 @@ class PanelWebhookService:
if subscription_service:
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, internal_user_id)
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
sub = await subscription_dal.get_active_subscription_by_user_id(
session, internal_user_id
)
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
try:
ok = await subscription_service.charge_subscription_renewal(session, sub)
ok = await subscription_service.charge_subscription_renewal(
session, sub
)
# If initiation succeeded, suppress the 24h reminder by returning early
if ok:
await session.commit()
@@ -99,15 +122,18 @@ class PanelWebhookService:
if days_left == 2:
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, internal_user_id)
sub = await subscription_dal.get_active_subscription_by_user_id(
session, internal_user_id
)
logging.info(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id,
bool(sub),
getattr(sub, 'auto_renew_enabled', None) if sub else None,
getattr(sub, 'provider', None) if sub else None,
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 == 'yookassa':
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,
@@ -142,7 +168,10 @@ class PanelWebhookService:
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired_24_hours_ago" and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE:
elif (
event_name == "user.expired_24_hours_ago"
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
):
await self._send_message(
user_id,
lang,
@@ -176,7 +205,9 @@ class PanelWebhookService:
except Exception:
logging.exception("Failed to send subscription-expiring email to %s", recipient)
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
async def handle_webhook(
self, raw_body: bytes, signature_header: Optional[str]
) -> web.Response:
if not self.settings.PANEL_WEBHOOK_SECRET:
return web.Response(status=401, text="unauthorized")
@@ -215,6 +246,7 @@ class PanelWebhookService:
await self.handle_event(event_name, user_data)
return web.Response(status=200, text="ok")
async def panel_webhook_route(request: web.Request):
service: PanelWebhookService = request.app["panel_webhook_service"]
raw = await request.read()
+100 -41
View File
@@ -1,22 +1,22 @@
import hmac
import hmac
import json
import logging
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any, Tuple
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Optional, Tuple
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot
from aiohttp import ClientSession, ClientTimeout, web
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.middlewares.i18n import JsonI18n
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.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
class PlategaService:
@@ -54,11 +54,11 @@ class PlategaService:
"X-Secret": self.secret or "",
"Content-Type": "application/json",
}
self.configured: bool = bool(
settings.PLATEGA_ENABLED and self.merchant_id and self.secret
)
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.")
logging.warning(
"PlategaService initialized but not fully configured. Payments disabled."
)
else:
logging.info(
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)",
@@ -114,7 +114,12 @@ class PlategaService:
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
"Content-Type": self._auth_headers.get("Content-Type"),
}
logging.info("Platega create_transaction request: url=%s headers=%s body=%s", url, safe_headers, clean_body)
logging.info(
"Platega create_transaction request: url=%s headers=%s body=%s",
url,
safe_headers,
clean_body,
)
try:
async with session.post(url, json=clean_body, headers=self._auth_headers) as response:
@@ -122,7 +127,9 @@ class PlategaService:
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)
logging.error(
"Platega create_transaction: invalid JSON response: %s", response_text
)
return False, {
"status": response.status,
"message": "invalid_json",
@@ -173,21 +180,29 @@ class PlategaService:
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)
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.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription")
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
if status == "CONFIRMED":
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)
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.warning(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
@@ -196,7 +211,11 @@ class PlategaService:
incoming_amount,
)
except Exception as exc:
logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
logging.warning(
"Platega webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
@@ -209,46 +228,66 @@ class PlategaService:
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_months) if sale_base == "subscription" else int(float(payment_months)),
int(payment_months)
if sale_base == "subscription"
else int(float(payment_months)),
float(payment.amount),
payment.payment_id,
provider="platega",
sale_mode=sale_mode,
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=float(payment_months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
if sale_base == "subscription":
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,
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:
await session.rollback()
logging.exception("Platega webhook: failed to process payment %s.", transaction_id)
logging.exception(
"Platega webhook: failed to process payment %s.", transaction_id
)
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
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_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
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}"
traffic_label = (
str(int(payment_months))
if float(payment_months).is_integer()
else f"{payment_months:g}"
)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
text = _(
@@ -262,16 +301,26 @@ class PlategaService:
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
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)
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 "",
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,
@@ -319,7 +368,9 @@ class PlategaService:
amount=float(payment.amount),
currency=currency,
months=int(payment_months) if sale_base == "subscription" else 0,
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=float(payment_months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="platega",
username=db_user.username if db_user else None,
traffic_is_premium=sale_base == "premium_topup",
@@ -341,11 +392,17 @@ class PlategaService:
await session.commit()
except Exception:
await session.rollback()
logging.exception("Platega webhook: failed to cancel payment %s.", transaction_id)
logging.exception(
"Platega webhook: failed to cancel payment %s.", transaction_id
)
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
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"))
@@ -353,7 +410,9 @@ class PlategaService:
pass
return web.Response(text="ok_canceled")
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
logging.warning(
"Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id
)
return web.Response(status=202, text="status_ignored")
+30 -25
View File
@@ -1,26 +1,27 @@
import logging
from html import escape as html_escape
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict
from html import escape as html_escape
from typing import Tuple
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import security_dal
from db.dal import promo_code_dal, user_dal
from db.models import PromoCode, User
from .subscription_service import SubscriptionService
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import promo_code_dal, security_dal, user_dal
from .notification_service import NotificationService
from .subscription_service import SubscriptionService
class PromoCodeService:
def __init__(self, settings: Settings,
subscription_service: SubscriptionService, bot: Bot,
i18n: JsonI18n):
def __init__(
self,
settings: Settings,
subscription_service: SubscriptionService,
bot: Bot,
i18n: JsonI18n,
):
self.settings = settings
self.subscription_service = subscription_service
self.bot = bot
@@ -53,7 +54,8 @@ class PromoCodeService:
)
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
session, code_input_upper)
session, code_input_upper
)
if not promo_data:
throttle_result = await security_dal.record_throttle_failure(
@@ -67,15 +69,16 @@ class PromoCodeService:
if throttle_result.locked:
return False, _(
"promo_code_too_many_attempts",
seconds=throttle_result.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
seconds=throttle_result.retry_after
or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
)
return False, _("promo_code_not_found", code=code_display)
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id)
session, promo_data.promo_code_id, user_id
)
if existing_activation:
return False, _("promo_code_already_used_by_user",
code=code_display)
return False, _("promo_code_already_used_by_user", code=code_display)
bonus_days = promo_data.bonus_days
@@ -83,13 +86,16 @@ class PromoCodeService:
session=session,
user_id=user_id,
bonus_days=bonus_days,
reason=f"promo code {code_input_upper}")
reason=f"promo code {code_input_upper}",
)
if new_end_date:
activation_recorded = await promo_code_dal.record_promo_activation(
session, promo_data.promo_code_id, user_id, payment_id=None)
session, promo_data.promo_code_id, user_id, payment_id=None
)
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session, promo_data.promo_code_id)
session, promo_data.promo_code_id
)
if activation_recorded and promo_incremented:
await security_dal.clear_throttle_state(
@@ -105,14 +111,13 @@ class PromoCodeService:
user_id=user_id,
promo_code=code_input_upper,
bonus_days=bonus_days,
username=user.username if user else None
username=user.username if user else None,
)
except Exception as e:
logging.error(f"Failed to send promo activation notification: {e}")
return True, new_end_date
else:
logging.error(
f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}"
)
+132 -133
View File
@@ -1,52 +1,51 @@
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from aiogram import Bot
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional, Dict, Any
from aiogram import Bot
from datetime import datetime, timezone, timedelta
from config.settings import Settings
from db.dal import user_dal
from db.dal import payment_dal
from db.models import User
from db.dal import subscription_dal
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
from .subscription_service import SubscriptionService
class ReferralService:
def __init__(self, settings: Settings,
subscription_service: SubscriptionService, bot: Bot,
i18n: JsonI18n):
def __init__(
self,
settings: Settings,
subscription_service: SubscriptionService,
bot: Bot,
i18n: JsonI18n,
):
self.settings = settings
self.subscription_service = subscription_service
self.bot = bot
self.i18n = i18n
async def apply_referral_bonuses_for_payment(
self,
session: AsyncSession,
referee_user_id: int,
purchased_subscription_months: int,
current_payment_db_id: Optional[int] = None,
skip_if_active_before_payment: bool = True) -> Dict[str, Any]:
self,
session: AsyncSession,
referee_user_id: int,
purchased_subscription_months: int,
current_payment_db_id: Optional[int] = None,
skip_if_active_before_payment: bool = True,
) -> Dict[str, Any]:
referee_final_end_date: Optional[datetime] = None
referee_bonus_applied_days: Optional[int] = None
inviter_bonus_successfully_applied = False
try:
referee_user_model = await user_dal.get_user_by_id(
session, referee_user_id)
referee_user_model = await user_dal.get_user_by_id(session, referee_user_id)
if not referee_user_model or referee_user_model.referred_by_id is None:
logging.debug(
f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses."
)
return {
"referee_bonus_applied_days": None,
"referee_new_end_date": None
}
return {"referee_bonus_applied_days": None, "referee_new_end_date": None}
# If configured to apply referral bonuses only once per invited user,
# check if the referee already has succeeded payments.
@@ -59,55 +58,63 @@ class ReferralService:
)
if succeeded_count and succeeded_count > 0:
logging.info(
f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments.")
return {
"referee_bonus_applied_days": None,
"referee_new_end_date": None
}
f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments."
)
return {"referee_bonus_applied_days": None, "referee_new_end_date": None}
except Exception as e_cnt:
logging.error(f"Failed counting succeeded payments for user {referee_user_id}: {e_cnt}")
logging.error(
f"Failed counting succeeded payments for user {referee_user_id}: {e_cnt}"
)
# Additionally, do not award referral bonuses if the user was active at payment time
# (has an active subscription now). This avoids giving bonuses to already active users.
if skip_if_active_before_payment:
try:
if await self.subscription_service.has_active_subscription(session, referee_user_id):
if await self.subscription_service.has_active_subscription(
session, referee_user_id
):
logging.info(
f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription.")
return {
"referee_bonus_applied_days": None,
"referee_new_end_date": None
}
f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription."
)
return {"referee_bonus_applied_days": None, "referee_new_end_date": None}
except Exception as e_sub:
logging.error(f"Failed to check active subscription for {referee_user_id}: {e_sub}")
logging.error(
f"Failed to check active subscription for {referee_user_id}: {e_sub}"
)
inviter_user_id = referee_user_model.referred_by_id
inviter_user_model = await user_dal.get_user_by_id(
session, inviter_user_id)
inviter_user_model = await user_dal.get_user_by_id(session, inviter_user_id)
referee_name_for_msg = referee_user_model.first_name or f"User {referee_user_id}"
default_lang_for_placeholder = self.settings.DEFAULT_LANGUAGE
inviter_name_for_referee_msg = (
inviter_user_model.first_name if inviter_user_model
and inviter_user_model.first_name else self.i18n.gettext(
default_lang_for_placeholder, "friend_placeholder"))
inviter_user_model.first_name
if inviter_user_model and inviter_user_model.first_name
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
)
inviter_bonus_days = self.settings.referral_bonus_inviter.get(
purchased_subscription_months)
purchased_subscription_months
)
referee_bonus_days = self.settings.referral_bonus_referee.get(
purchased_subscription_months)
purchased_subscription_months
)
if inviter_bonus_days and inviter_bonus_days > 0:
if not inviter_user_model:
logging.warning(
f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus."
)
else:
inviter_panel_uuid, inviter_panel_sub_link_id, _, _ = await self.subscription_service._get_or_create_panel_user_link_details(
session, inviter_user_id, inviter_user_model)
(
inviter_panel_uuid,
inviter_panel_sub_link_id,
_,
_,
) = await self.subscription_service._get_or_create_panel_user_link_details(
session, inviter_user_id, inviter_user_model
)
if not inviter_panel_uuid:
logging.warning(
@@ -115,11 +122,13 @@ class ReferralService:
)
else:
new_end_date_inviter = await self.subscription_service.extend_active_subscription_days(
session=session,
user_id=inviter_user_id,
bonus_days=inviter_bonus_days,
reason=f"referral bonus from {referee_name_for_msg}"
new_end_date_inviter = (
await self.subscription_service.extend_active_subscription_days(
session=session,
user_id=inviter_user_id,
bonus_days=inviter_bonus_days,
reason=f"referral bonus from {referee_name_for_msg}",
)
)
if new_end_date_inviter:
@@ -129,29 +138,30 @@ class ReferralService:
)
try:
inviter_lang = inviter_user_model.language_code or default_lang_for_placeholder
_i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw)
inviter_lang = (
inviter_user_model.language_code or default_lang_for_placeholder
)
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
await self.bot.send_message(
inviter_user_id,
_i("referral_bonus_inviter_notification_extended",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=new_end_date_inviter.
strftime('%Y-%m-%d')))
_i(
"referral_bonus_inviter_notification_extended",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
),
)
except Exception as e_notify_inviter:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}"
)
else:
logging.info(
f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days."
)
bonus_start_date = datetime.now(timezone.utc)
bonus_end_date = bonus_start_date + timedelta(
days=inviter_bonus_days)
bonus_end_date = bonus_start_date + timedelta(days=inviter_bonus_days)
if not inviter_panel_sub_link_id:
logging.error(
@@ -159,60 +169,56 @@ class ReferralService:
)
else:
bonus_sub_payload = {
"user_id":
inviter_user_id,
"panel_user_uuid":
inviter_panel_uuid,
"panel_subscription_uuid":
inviter_panel_sub_link_id,
"start_date":
bonus_start_date,
"end_date":
bonus_end_date,
"duration_months":
0,
"is_active":
True,
"status_from_panel":
"ACTIVE_BONUS",
"traffic_limit_bytes":
self.settings.user_traffic_limit_bytes,
"auto_renew_enabled":
False,
"user_id": inviter_user_id,
"panel_user_uuid": inviter_panel_uuid,
"panel_subscription_uuid": inviter_panel_sub_link_id,
"start_date": bonus_start_date,
"end_date": bonus_end_date,
"duration_months": 0,
"is_active": True,
"status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"auto_renew_enabled": False,
}
try:
await subscription_dal.deactivate_other_active_subscriptions(
session, inviter_panel_uuid,
inviter_panel_sub_link_id)
session, inviter_panel_uuid, inviter_panel_sub_link_id
)
bonus_sub = await subscription_dal.upsert_subscription(
session, bonus_sub_payload)
session, bonus_sub_payload
)
panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel(
inviter_panel_uuid, {
"expireAt":
bonus_end_date.isoformat(
timespec='milliseconds').
replace('+00:00', 'Z'),
"status":
"ACTIVE",
})
inviter_panel_uuid,
{
"expireAt": bonus_end_date.isoformat(
timespec="milliseconds"
).replace("+00:00", "Z"),
"status": "ACTIVE",
},
)
if panel_update_success:
inviter_bonus_successfully_applied = True
logging.info(
f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}."
)
inviter_lang = inviter_user_model.language_code or default_lang_for_placeholder
inviter_lang = (
inviter_user_model.language_code
or default_lang_for_placeholder
)
_i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw)
inviter_lang, k, **kw
)
await self.bot.send_message(
inviter_user_id,
_i("referral_bonus_inviter_notification_new_sub",
days=inviter_bonus_days,
referee_name=
referee_name_for_msg,
new_end_date=bonus_end_date.
strftime('%Y-%m-%d')))
_i(
"referral_bonus_inviter_notification_new_sub",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
),
)
else:
logging.warning(
f"Failed to update panel for new bonus subscription for inviter {inviter_user_id}. Local bonus sub created (ID: {bonus_sub.subscription_id}) but may not be active on panel."
@@ -221,16 +227,17 @@ class ReferralService:
except Exception as e_create_bonus_sub:
logging.error(
f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}",
exc_info=True)
exc_info=True,
)
if referee_bonus_days and referee_bonus_days > 0:
new_end_date_referee = await self.subscription_service.extend_active_subscription_days(
session=session,
user_id=referee_user_id,
bonus_days=referee_bonus_days,
reason=
f"referee bonus (invited by {inviter_name_for_referee_msg})"
new_end_date_referee = (
await self.subscription_service.extend_active_subscription_days(
session=session,
user_id=referee_user_id,
bonus_days=referee_bonus_days,
reason=f"referee bonus (invited by {inviter_name_for_referee_msg})",
)
)
if new_end_date_referee:
referee_final_end_date = new_end_date_referee
@@ -239,7 +246,6 @@ class ReferralService:
f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}."
)
else:
logging.warning(
f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)."
)
@@ -247,19 +253,19 @@ class ReferralService:
return {
"referee_bonus_applied_days": referee_bonus_applied_days,
"referee_new_end_date": referee_final_end_date,
"inviter_bonus_applied_flag":
inviter_bonus_successfully_applied
"inviter_bonus_applied_flag": inviter_bonus_successfully_applied,
}
except Exception as e:
logging.error(
f"Error in apply_referral_bonuses_for_payment for referee {referee_user_id}: {e}",
exc_info=True)
exc_info=True,
)
raise
async def generate_referral_link(self, session: AsyncSession,
bot_username: str,
inviter_user_id: int) -> Optional[str]:
async def generate_referral_link(
self, session: AsyncSession, bot_username: str, inviter_user_id: int
) -> Optional[str]:
try:
user = await user_dal.get_user_by_id(session, inviter_user_id)
if not user:
@@ -289,16 +295,15 @@ class ReferralService:
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
"""Get referral statistics for a user"""
from db.dal import user_dal, payment_dal
try:
# Count total invited users (referrals)
invited_count_result = await session.execute(
text("SELECT COUNT(*) FROM users WHERE referred_by_id = :user_id"),
{"user_id": user_id}
{"user_id": user_id},
)
invited_count = invited_count_result.scalar() or 0
# Count users who made successful payments (purchased subscription)
purchased_count_result = await session.execute(
text("""
@@ -308,17 +313,11 @@ class ReferralService:
WHERE u.referred_by_id = :user_id
AND p.status = 'succeeded'
"""),
{"user_id": user_id}
{"user_id": user_id},
)
purchased_count = purchased_count_result.scalar() or 0
return {
"invited_count": invited_count,
"purchased_count": purchased_count
}
return {"invited_count": invited_count, "purchased_count": purchased_count}
except Exception as e:
logging.error(f"Error getting referral stats for user {user_id}: {e}")
return {
"invited_count": 0,
"purchased_count": 0
}
return {"invited_count": 0, "purchased_count": 0}
+1 -4
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_settings_manifest import (
@@ -74,9 +73,7 @@ def apply_overrides(settings: Settings, overrides: Dict[str, Any]) -> int:
return applied
async def load_overrides_from_db(
settings: Settings, async_session_factory: sessionmaker
) -> int:
async def load_overrides_from_db(settings: Settings, async_session_factory: sessionmaker) -> int:
"""Fetch overrides from the DB and apply them to the in-memory settings."""
try:
+117 -45
View File
@@ -1,24 +1,24 @@
import json
import hashlib
import hmac
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 decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Optional, Tuple
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot
from aiohttp import ClientSession, ClientTimeout, web
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.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
class SeverPayService:
@@ -40,7 +40,9 @@ class SeverPayService:
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.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}"
@@ -51,7 +53,9 @@ class SeverPayService:
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.")
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:
@@ -69,7 +73,9 @@ class SeverPayService:
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()
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] = {
@@ -129,8 +135,14 @@ class SeverPayService:
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}
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(
@@ -184,14 +196,22 @@ class SeverPayService:
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)
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)
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)
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription")
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
if status == "success":
try:
@@ -205,46 +225,68 @@ class SeverPayService:
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_months) if sale_base == "subscription" else int(float(payment_months)),
int(payment_months)
if sale_base == "subscription"
else int(float(payment_months)),
float(payment.amount),
payment.payment_id,
provider="severpay",
sale_mode=sale_mode,
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=float(payment_months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
if sale_base == "subscription":
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,
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:
await session.rollback()
logging.exception("SeverPay webhook: failed to process payment %s.", provider_payment_id)
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
logging.exception(
"SeverPay webhook: failed to process payment %s.", provider_payment_id
)
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
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_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
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}"
traffic_label = (
str(int(payment_months))
if float(payment_months).is_integer()
else f"{payment_months:g}"
)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
text = _(
@@ -258,16 +300,26 @@ class SeverPayService:
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
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)
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 "",
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,
@@ -306,7 +358,9 @@ class SeverPayService:
disable_web_page_preview=True,
)
except Exception:
logging.exception("SeverPay webhook: failed to notify user %s.", payment.user_id)
logging.exception(
"SeverPay webhook: failed to notify user %s.", payment.user_id
)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
@@ -315,7 +369,9 @@ class SeverPayService:
amount=float(payment.amount),
currency=payment.currency,
months=int(payment_months) if sale_base == "subscription" else 0,
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=float(payment_months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="severpay",
username=db_user.username if db_user else None,
traffic_is_premium=sale_base == "premium_topup",
@@ -337,11 +393,20 @@ class SeverPayService:
await session.commit()
except Exception:
await session.rollback()
logging.exception("SeverPay webhook: failed to mark payment %s as failed.", provider_payment_id)
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
logging.exception(
"SeverPay webhook: failed to mark payment %s as failed.",
provider_payment_id,
)
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
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"))
@@ -360,10 +425,17 @@ class SeverPayService:
await session.commit()
except Exception:
await session.rollback()
logging.exception("SeverPay webhook: failed to update pending status for %s.", provider_payment_id)
logging.exception(
"SeverPay webhook: failed to update pending status for %s.",
provider_payment_id,
)
return web.json_response({"status": True})
logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id)
logging.warning(
"SeverPay webhook: unhandled status '%s' for payment %s",
status,
provider_payment_id,
)
return web.json_response({"status": True})
+72 -41
View File
@@ -1,33 +1,46 @@
import logging
import logging
from typing import Optional
from aiogram import Bot, types
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
from .subscription_service import SubscriptionService
from .referral_service import ReferralService
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
from .referral_service import ReferralService
from .subscription_service import SubscriptionService
class StarsService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
subscription_service: SubscriptionService,
referral_service: ReferralService):
def __init__(
self,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
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, sale_mode: str = "subscription") -> Optional[int]:
async def create_invoice(
self,
session: AsyncSession,
user_id: int,
months: int,
stars_price: int,
description: str,
sale_mode: str = "subscription",
) -> Optional[int]:
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_record_data = {
"user_id": user_id,
@@ -39,16 +52,18 @@ class StarsService:
"provider": "telegram_stars",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
}
try:
db_payment_record = await payment_dal.create_payment_record(
session, payment_record_data)
session, payment_record_data
)
await session.commit()
except Exception as e_db:
await session.rollback()
logging.error(f"Failed to create stars payment record: {e_db}",
exc_info=True)
logging.error(f"Failed to create stars payment record: {e_db}", exc_info=True)
return None
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
@@ -65,22 +80,26 @@ class StarsService:
)
return db_payment_record.payment_id
except Exception as e_inv:
logging.error(f"Failed to send Telegram Stars invoice: {e_inv}",
exc_info=True)
logging.error(f"Failed to send Telegram Stars invoice: {e_inv}", exc_info=True)
return None
async def process_successful_payment(self, session: AsyncSession,
message: types.Message,
payment_db_id: int,
months: int,
stars_amount: int,
i18n_data: dict,
sale_mode: str = "subscription") -> None:
async def process_successful_payment(
self,
session: AsyncSession,
message: types.Message,
payment_db_id: int,
months: int,
stars_amount: int,
i18n_data: dict,
sale_mode: str = "subscription",
) -> None:
try:
payment_record = await payment_dal.update_provider_payment_and_status(
session, payment_db_id,
session,
payment_db_id,
message.successful_payment.provider_payment_charge_id,
"succeeded")
"succeeded",
)
target_user_id = (
int(payment_record.user_id)
if payment_record and payment_record.user_id is not None
@@ -90,8 +109,8 @@ class StarsService:
except Exception as e_upd:
await session.rollback()
logging.error(
f"Failed to update stars payment record {payment_db_id}: {e_upd}",
exc_info=True)
f"Failed to update stars payment record {payment_db_id}: {e_upd}", exc_info=True
)
return
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
@@ -103,11 +122,14 @@ class StarsService:
payment_db_id,
provider="telegram_stars",
sale_mode=sale_mode,
traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=months
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
if not activation_details or not activation_details.get("end_date"):
logging.error(
f"Failed to activate subscription after stars payment for user {target_user_id}")
f"Failed to activate subscription after stars payment for user {target_user_id}"
)
return
referral_bonus = None
@@ -128,19 +150,25 @@ class StarsService:
# Always use user's language from DB for user-facing messages
db_user = await user_dal.get_user_by_id(session, target_user_id)
current_lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
current_lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
i18n: JsonI18n = i18n_data.get("i18n_instance")
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
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_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 sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
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'),
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
elif applied_days:
@@ -149,7 +177,9 @@ class StarsService:
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
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:
@@ -157,9 +187,9 @@ class StarsService:
success_msg = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
base_end_date=activation_details["end_date"].strftime("%Y-%m-%d"),
bonus_days=applied_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
final_end_date=final_end.strftime("%Y-%m-%d"),
inviter_name=inviter_name_display,
config_link=config_link_text,
)
@@ -167,7 +197,7 @@ class StarsService:
success_msg = _(
"payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
@@ -187,8 +217,7 @@ class StarsService:
disable_web_page_preview=True,
)
except Exception as e_send:
logging.error(
f"Failed to send stars payment success message: {e_send}")
logging.error(f"Failed to send stars payment success message: {e_send}")
# Send notification about payment
try:
@@ -201,7 +230,9 @@ class StarsService:
months=int(months) if sale_base == "subscription" else 0,
payment_provider="stars",
username=user.username if user else None,
traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
traffic_is_premium=sale_base == "premium_topup",
tariff_key=getattr(payment_record, "tariff_key", None),
)
+281 -166
View File
@@ -1,25 +1,32 @@
import logging
import math
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List, Tuple
from typing import Any, Dict, List, Optional, Tuple
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, tariff_dal
from config.tariffs_config import Tariff
from bot.utils.date_utils import add_months, month_start
from bot.utils.config_link import prepare_config_links
from db.models import User, Subscription
from bot.utils.date_utils import add_months, month_start
from config.settings import Settings
from .panel_api_service import PanelApiService
from config.tariffs_config import Tariff
from db.dal import (
payment_dal,
promo_code_dal,
subscription_dal,
tariff_dal,
user_billing_dal,
user_dal,
)
from db.models import Subscription, User
from .email_auth_service import EmailAuthService
from .email_templates import render_payment_success
from .panel_api_service import PanelApiService
class SubscriptionService:
def __init__(
self,
settings: Settings,
@@ -63,13 +70,17 @@ class SubscriptionService:
config = self._tariffs_config()
return config.default if config else None
def _resolve_tariff(self, tariff_key: Optional[str], billing_model: Optional[str] = None) -> Optional[Tariff]:
def _resolve_tariff(
self, tariff_key: Optional[str], billing_model: Optional[str] = None
) -> Optional[Tariff]:
config = self._tariffs_config()
if not config:
return None
tariff = config.require(tariff_key or config.default_tariff)
if billing_model and tariff.billing_model != billing_model:
raise ValueError(f"Tariff {tariff.key} is {tariff.billing_model}, expected {billing_model}")
raise ValueError(
f"Tariff {tariff.key} is {tariff.billing_model}, expected {billing_model}"
)
return tariff
def _panel_squads_for_tariff(
@@ -105,7 +116,9 @@ class SubscriptionService:
traffic_used_bytes=traffic_used_bytes,
)
def _premium_limit_for_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int:
def _premium_limit_for_tariff(
self, tariff: Optional[Tariff], topup_balance_bytes: int = 0
) -> int:
if not tariff:
return 0
return int(tariff.premium_monthly_bytes + max(0, topup_balance_bytes))
@@ -141,7 +154,7 @@ class SubscriptionService:
)
if regular_unlimited_override:
used = max(0, int(traffic_used_bytes or 0))
return max(floor, used + 512 * (1024 ** 3), 1024 ** 5)
return max(floor, used + 512 * (1024**3), 1024**5)
return floor
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
@@ -188,7 +201,9 @@ class SubscriptionService:
squad_uuid = str(squad.get("uuid") or squad.get("id") or "")
if not squad_uuid:
continue
squad_name_map[squad_uuid] = str(squad.get("name") or squad.get("title") or squad_uuid)
squad_name_map[squad_uuid] = str(
squad.get("name") or squad.get("title") or squad_uuid
)
squad_inbound_map[squad_uuid] = _extract_inbound_uuids(squad)
except Exception:
logging.debug("Failed to load internal squad names for premium display", exc_info=True)
@@ -200,7 +215,9 @@ class SubscriptionService:
try:
detail = await self.panel_service.get_internal_squad(squad_uuid_str)
except Exception:
logging.debug("Failed to load internal squad detail for %s", squad_uuid_str, exc_info=True)
logging.debug(
"Failed to load internal squad detail for %s", squad_uuid_str, exc_info=True
)
detail = None
if isinstance(detail, dict):
squad_inbound_map[squad_uuid_str] = _extract_inbound_uuids(detail)
@@ -264,16 +281,33 @@ class SubscriptionService:
continue
try:
nodes = await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or []
nodes = (
await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or []
)
except Exception:
logging.debug("Failed to load accessible nodes for premium squad %s", squad_uuid, exc_info=True)
logging.debug(
"Failed to load accessible nodes for premium squad %s",
squad_uuid,
exc_info=True,
)
nodes = []
for node in nodes:
if not isinstance(node, dict):
continue
node_uuid = str(node.get("uuid") or node.get("nodeUuid") or node.get("node_uuid") or "")
node_uuid = str(
node.get("uuid") or node.get("nodeUuid") or node.get("node_uuid") or ""
)
node_name = ""
for key in ("nodeName", "name", "nodeRemark", "remark", "label", "title", "address", "host"):
for key in (
"nodeName",
"name",
"nodeRemark",
"remark",
"label",
"title",
"address",
"host",
):
value = node.get(key)
if value is None:
continue
@@ -363,6 +397,7 @@ class SubscriptionService:
if not active_sub or not active_sub.end_date:
return False
from datetime import datetime, timezone
return active_sub.is_active and active_sub.end_date > datetime.now(timezone.utc)
except Exception:
return False
@@ -380,9 +415,7 @@ class SubscriptionService:
strategy = traffic_stats.get("trafficLimitStrategy")
return used, limit, strategy
def _extract_lifetime_used_traffic(
self, panel_user_data: Dict[str, Any]
) -> Optional[int]:
def _extract_lifetime_used_traffic(self, panel_user_data: Dict[str, Any]) -> Optional[int]:
traffic_stats = panel_user_data.get("userTraffic") or {}
lifetime = traffic_stats.get("lifetimeUsedTrafficBytes")
if lifetime is None:
@@ -415,9 +448,7 @@ class SubscriptionService:
return int(db_user.user_id)
return None
async def _panel_username_for_user(
self, session: AsyncSession, db_user: User
) -> str:
async def _panel_username_for_user(self, session: AsyncSession, db_user: User) -> str:
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id and int(db_user.user_id) == telegram_id:
return f"tg_{telegram_id}"
@@ -457,9 +488,7 @@ class SubscriptionService:
return None, None, None, False
current_local_panel_uuid = db_user.panel_user_uuid
panel_username_on_panel_standard = await self._panel_username_for_user(
session, db_user
)
panel_username_on_panel_standard = await self._panel_username_for_user(session, db_user)
telegram_id_for_panel = self._telegram_id_for_panel(db_user)
panel_user_obj_from_api = None
@@ -498,7 +527,6 @@ class SubscriptionService:
if not panel_user_obj_from_api:
if current_local_panel_uuid:
logging.info(
f"User {user_id} (local panel_uuid: {current_local_panel_uuid}) not found on panel by TG ID. Fetching by panel_uuid."
)
@@ -534,7 +562,6 @@ class SubscriptionService:
return None, None, None, False
else:
logging.info(
f"No panel user by TG ID & no local panel_uuid for TG user {user_id}. Creating new panel user '{panel_username_on_panel_standard}'."
)
@@ -560,10 +587,8 @@ class SubscriptionService:
logging.warning(
f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username."
)
fetched_by_username_list = (
await self.panel_service.get_users_by_filter(
username=panel_username_on_panel_standard
)
fetched_by_username_list = await self.panel_service.get_users_by_filter(
username=panel_username_on_panel_standard
)
if fetched_by_username_list and len(fetched_by_username_list) == 1:
panel_user_obj_from_api = fetched_by_username_list[0]
@@ -616,7 +641,6 @@ class SubscriptionService:
needs_local_panel_uuid_update = True
if needs_local_panel_uuid_update:
conflicting_user_record = await user_dal.get_user_by_panel_uuid(
session, actual_panel_uuid_from_api
)
@@ -629,10 +653,7 @@ class SubscriptionService:
return None, None, None, False
else:
update_data_for_local_user = {
"panel_user_uuid": actual_panel_uuid_from_api
}
update_data_for_local_user = {"panel_user_uuid": actual_panel_uuid_from_api}
# Do not overwrite Telegram username with panel username.
# Only update the local linkage to panel UUID here.
@@ -641,7 +662,6 @@ class SubscriptionService:
panel_user_created_or_linked_now = True
current_local_panel_uuid = actual_panel_uuid_from_api
else:
pass
panel_telegram_id_int = None
@@ -708,9 +728,12 @@ class SubscriptionService:
"message_key": "trial_already_had_subscription_or_trial",
}
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = (
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
)
(
panel_user_uuid,
panel_sub_link_id,
panel_short_uuid,
panel_user_created_now,
) = 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(f"Failed to get panel link details for trial user {user_id}.")
@@ -819,12 +842,17 @@ class SubscriptionService:
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)
)
(
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)
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 {}
@@ -890,7 +918,9 @@ class SubscriptionService:
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)
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(
@@ -982,7 +1012,9 @@ class SubscriptionService:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id, db_user.panel_user_uuid)
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
@@ -1031,7 +1063,9 @@ class SubscriptionService:
include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)),
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
await self.panel_service.update_user_details_on_panel(db_user.panel_user_uuid, panel_payload)
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
await tariff_dal.create_traffic_topup(
session,
subscription_id=sub.subscription_id,
@@ -1058,7 +1092,9 @@ class SubscriptionService:
) -> Optional[Dict[str, Any]]:
tariff = self._resolve_tariff(tariff_key)
if not tariff or not tariff.premium_squad_uuids:
logging.error("Premium top-up requires a tariff with premium squads for user %s", user_id)
logging.error(
"Premium top-up requires a tariff with premium squads for user %s", user_id
)
return None
await self._record_payment_context(
@@ -1071,7 +1107,9 @@ class SubscriptionService:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id, db_user.panel_user_uuid)
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
@@ -1116,7 +1154,9 @@ class SubscriptionService:
include_premium=not premium_is_limited,
),
}
await self.panel_service.update_user_details_on_panel(db_user.panel_user_uuid, panel_payload)
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
await tariff_dal.create_traffic_topup(
session,
subscription_id=sub.subscription_id,
@@ -1166,7 +1206,9 @@ class SubscriptionService:
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
purchase_bytes = self.gb_to_bytes(gb_value)
baseline_bytes = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0)
baseline_bytes = int(
sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0
)
new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
@@ -1215,9 +1257,7 @@ class SubscriptionService:
db_user.panel_user_uuid, panel_payload
)
except Exception:
logging.exception(
"admin_grant_topup: failed to push panel update for user %s", user_id
)
logging.exception("admin_grant_topup: failed to push panel update for user %s", user_id)
await tariff_dal.create_traffic_topup(
session,
subscription_id=sub.subscription_id,
@@ -1288,9 +1328,7 @@ class SubscriptionService:
db_user.panel_user_uuid, panel_payload
)
except Exception:
logging.exception(
"sync_main_traffic_limit_to_panel failed for user %s", user_id
)
logging.exception("sync_main_traffic_limit_to_panel failed for user %s", user_id)
async def admin_grant_premium_topup(
self,
@@ -1453,7 +1491,9 @@ class SubscriptionService:
else self._base_hwid_limit_for_tariff(tariff)
)
if base_hwid_limit == 0:
logging.info("Skipping HWID top-up for user %s because current limit is unlimited", user_id)
logging.info(
"Skipping HWID top-up for user %s because current limit is unlimited", user_id
)
return {
"subscription_id": sub.subscription_id,
"hwid_device_limit": 0,
@@ -1515,18 +1555,35 @@ class SubscriptionService:
"tariff_key": tariff.key if tariff else sub.tariff_key,
}
def calculate_tariff_switch_options(self, sub: Subscription, target_tariff: Tariff) -> Dict[str, Any]:
current_tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else self._default_tariff()
def calculate_tariff_switch_options(
self, sub: Subscription, target_tariff: Tariff
) -> Dict[str, Any]:
current_tariff = (
self._resolve_tariff(sub.tariff_key) if sub.tariff_key else self._default_tariff()
)
now = datetime.now(timezone.utc)
remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0
effective = float(sub.effective_monthly_price_rub or 0)
current_model = current_tariff.billing_model if current_tariff else "period"
if current_model == "period" and target_tariff.billing_model == "period":
target_monthly = target_tariff.period_price(1, "rub") or target_tariff.min_period_price_rub() or effective or 1
target_monthly = (
target_tariff.period_price(1, "rub")
or target_tariff.min_period_price_rub()
or effective
or 1
)
remaining_value = remaining_days * (effective / 30) if effective else 0
days_after = math.floor((remaining_value / float(target_monthly)) * 30) if target_monthly else remaining_days
paid_diff = max(0, math.ceil((float(target_monthly) - effective) * remaining_days / 30)) if effective else 0
days_after = (
math.floor((remaining_value / float(target_monthly)) * 30)
if target_monthly
else remaining_days
)
paid_diff = (
max(0, math.ceil((float(target_monthly) - effective) * remaining_days / 30))
if effective
else 0
)
return {
"mode": "period_to_period",
"remaining_days": remaining_days,
@@ -1562,7 +1619,9 @@ class SubscriptionService:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id, db_user.panel_user_uuid)
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
before_tariff_key = sub.tariff_key
@@ -1603,7 +1662,9 @@ class SubscriptionService:
traffic_used_bytes=used_sub,
)
update_data["period_start_at"] = None
update_data["effective_monthly_price_rub"] = target.period_price(1, "rub") or target.min_period_price_rub()
update_data["effective_monthly_price_rub"] = (
target.period_price(1, "rub") or target.min_period_price_rub()
)
if mode == "recalc_days" and options.get("recalc_days") is not None:
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
else:
@@ -1613,7 +1674,12 @@ class SubscriptionService:
new_balance = old_topup + converted_bytes
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
panel_user = await self.panel_service.get_user_by_uuid(db_user.panel_user_uuid, log_response=False) or {}
panel_user = (
await self.panel_service.get_user_by_uuid(
db_user.panel_user_uuid, log_response=False
)
or {}
)
current_used, _, _ = self._extract_panel_traffic_details(panel_user)
cur_used_int = int(current_used or 0)
update_data.update(
@@ -1636,7 +1702,9 @@ class SubscriptionService:
}
)
updated = await subscription_dal.update_subscription(session, sub.subscription_id, update_data)
updated = await subscription_dal.update_subscription(
session, sub.subscription_id, update_data
)
if not updated:
return None
panel_payload = self._build_panel_update_payload(
@@ -1652,7 +1720,9 @@ class SubscriptionService:
include_premium=not bool(updated.premium_is_limited),
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
await self.panel_service.update_user_details_on_panel(db_user.panel_user_uuid, panel_payload)
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
if converted_bytes:
await tariff_dal.create_traffic_topup(
session,
@@ -1670,7 +1740,9 @@ class SubscriptionService:
"mode": mode,
"payment_id": None,
"days_before": options.get("remaining_days"),
"days_after": (updated.end_date - now).days if updated.end_date and target.billing_model == "period" else None,
"days_after": (updated.end_date - now).days
if updated.end_date and target.billing_model == "period"
else None,
"converted_bytes": converted_bytes,
"eff_price_before": sub.effective_monthly_price_rub,
"eff_price_after": updated.effective_monthly_price_rub,
@@ -1794,7 +1866,9 @@ class SubscriptionService:
"mode": "paid_diff",
"payment_id": payment_db_id,
"days_before": None,
"days_after": (sub.end_date - datetime.now(timezone.utc)).days if sub.end_date else None,
"days_after": (sub.end_date - datetime.now(timezone.utc)).days
if sub.end_date
else None,
"converted_bytes": None,
"eff_price_before": None,
"eff_price_after": sub.effective_monthly_price_rub,
@@ -1815,19 +1889,18 @@ class SubscriptionService:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(
f"User {user_id} not found in DB for paid subscription activation."
)
logging.error(f"User {user_id} not found in DB for paid subscription activation.")
return None
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = (
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
)
(
panel_user_uuid,
panel_sub_link_id,
panel_short_uuid,
panel_user_created_now,
) = 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(
f"Failed to ensure panel user for TG {user_id} during paid subscription."
)
logging.error(f"Failed to ensure panel user for TG {user_id} during paid subscription.")
return None
try:
@@ -1901,11 +1974,17 @@ class SubscriptionService:
topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0)
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
premium_topup_balance_bytes = int(getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0)
premium_topup_used_bytes = int(getattr(current_active_sub, "premium_topup_used_bytes", 0) or 0)
premium_topup_balance_bytes = int(
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
)
premium_topup_used_bytes = int(
getattr(current_active_sub, "premium_topup_used_bytes", 0) or 0
)
premium_used_bytes = int(getattr(current_active_sub, "premium_used_bytes", 0) or 0)
premium_period_start_at = getattr(current_active_sub, "premium_period_start_at", None)
tier_baseline_bytes = tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes
tier_baseline_bytes = (
tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes
)
premium_baseline_bytes = tariff.premium_monthly_bytes if tariff else 0
premium_limit_bytes = self._premium_effective_limit_bytes(
premium_baseline_bytes,
@@ -1924,7 +2003,9 @@ class SubscriptionService:
)
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
premium_is_limited = bool(premium_limit_bytes > 0 and premium_used_bytes >= premium_limit_bytes)
premium_is_limited = bool(
premium_limit_bytes > 0 and premium_used_bytes >= premium_limit_bytes
)
sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
@@ -1956,9 +2037,7 @@ class SubscriptionService:
"extra_hwid_devices": extra_hwid_devices,
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(
session, sub_payload
)
new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload)
except Exception as e_upsert_sub:
logging.error(
f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}",
@@ -2029,9 +2108,7 @@ class SubscriptionService:
user = await user_dal.get_user_by_id(session, user_id)
if not user:
logging.warning(
f"Cannot extend subscription for user {user_id}: user not found."
)
logging.warning(f"Cannot extend subscription for user {user_id}: user not found.")
return None
panel_uuid, panel_sub_uuid, _, _ = await self._get_or_create_panel_user_link_details(
@@ -2081,9 +2158,7 @@ class SubscriptionService:
else:
current_end_date = active_sub.end_date
now_utc = datetime.now(timezone.utc)
start_point_for_bonus = (
current_end_date if current_end_date > now_utc else now_utc
)
start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc
new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days)
updated_sub_model = await subscription_dal.update_subscription_end_date(
@@ -2112,11 +2187,9 @@ class SubscriptionService:
include_default_squads=False,
)
panel_update_success = (
await self.panel_service.update_user_details_on_panel(
panel_uuid,
panel_update_payload,
)
panel_update_success = await self.panel_service.update_user_details_on_panel(
panel_uuid,
panel_update_payload,
)
if not panel_update_success:
logging.warning(
@@ -2128,9 +2201,7 @@ class SubscriptionService:
)
return new_end_date_obj
else:
logging.error(
f"Failed to update subscription end date locally for user {user_id}."
)
logging.error(f"Failed to update subscription end date locally for user {user_id}.")
return None
async def get_active_subscription_details(
@@ -2172,7 +2243,9 @@ 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_traffic_limit, _ = self._extract_panel_traffic_details(panel_user_data)
panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details(
panel_user_data
)
panel_sub_uuid_from_panel = panel_user_data.get(
"subscriptionUuid"
) or panel_user_data.get("shortUuid")
@@ -2180,12 +2253,10 @@ class SubscriptionService:
if local_active_sub.status_from_panel != panel_status:
update_payload_local["status_from_panel"] = panel_status
if panel_expire_at_str:
panel_expire_dt = datetime.fromisoformat(
panel_expire_at_str.replace("Z", "+00:00")
)
if local_active_sub.end_date.replace(
panel_expire_dt = datetime.fromisoformat(panel_expire_at_str.replace("Z", "+00:00"))
if local_active_sub.end_date.replace(microsecond=0) != panel_expire_dt.replace(
microsecond=0
) != panel_expire_dt.replace(microsecond=0):
):
update_payload_local["end_date"] = panel_expire_dt
update_payload_local["last_notification_sent"] = None
if (
@@ -2200,17 +2271,12 @@ class SubscriptionService:
update_payload_local["traffic_limit_bytes"] = panel_traffic_limit
if (
panel_sub_uuid_from_panel
and local_active_sub.panel_subscription_uuid
!= panel_sub_uuid_from_panel
and local_active_sub.panel_subscription_uuid != panel_sub_uuid_from_panel
):
update_payload_local["panel_subscription_uuid"] = (
panel_sub_uuid_from_panel
)
update_payload_local["panel_subscription_uuid"] = panel_sub_uuid_from_panel
is_active_based_on_panel = panel_status == "ACTIVE" and (
panel_expire_dt > datetime.now(timezone.utc)
if panel_expire_dt
else False
panel_expire_dt > datetime.now(timezone.utc) if panel_expire_dt else False
)
if local_active_sub.is_active != is_active_based_on_panel:
update_payload_local["is_active"] = is_active_based_on_panel
@@ -2225,9 +2291,13 @@ class SubscriptionService:
if panel_user_data.get("expireAt")
else None
)
panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = self._extract_panel_traffic_details(panel_user_data)
panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = (
self._extract_panel_traffic_details(panel_user_data)
)
config_link_raw = panel_user_data.get("subscriptionUrl")
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
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:
if local_active_sub and local_active_sub.hwid_device_limit is not None:
@@ -2243,20 +2313,48 @@ class SubscriptionService:
tariff = self._resolve_tariff(local_active_sub.tariff_key)
except Exception:
tariff = None
billing_model_display = tariff.billing_model if tariff else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period")
billing_model_display = (
tariff.billing_model
if tariff
else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period")
)
traffic_limit_strategy = panel_traffic_strategy
premium_access = await self.premium_access_for_tariff(tariff) if tariff else {
"squad_uuids": [],
"squad_labels": [],
"node_labels": [],
}
premium_baseline = int(local_active_sub.premium_baseline_bytes or 0) if local_active_sub else 0
premium_topup_balance = int(local_active_sub.premium_topup_balance_bytes or 0) if local_active_sub else 0
premium_topup_used = int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0) if local_active_sub else 0
premium_bonus_bytes = int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0) if local_active_sub else 0
premium_unlimited_override = bool(getattr(local_active_sub, "premium_unlimited_override", False)) if local_active_sub else False
regular_bonus_bytes = int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0) if local_active_sub else 0
regular_unlimited_override = bool(getattr(local_active_sub, "regular_unlimited_override", False)) if local_active_sub else False
premium_access = (
await self.premium_access_for_tariff(tariff)
if tariff
else {
"squad_uuids": [],
"squad_labels": [],
"node_labels": [],
}
)
premium_baseline = (
int(local_active_sub.premium_baseline_bytes or 0) if local_active_sub else 0
)
premium_topup_balance = (
int(local_active_sub.premium_topup_balance_bytes or 0) if local_active_sub else 0
)
premium_topup_used = (
int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0)
if local_active_sub
else 0
)
premium_bonus_bytes = (
int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0) if local_active_sub else 0
)
premium_unlimited_override = (
bool(getattr(local_active_sub, "premium_unlimited_override", False))
if local_active_sub
else False
)
regular_bonus_bytes = (
int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0) if local_active_sub else 0
)
regular_unlimited_override = (
bool(getattr(local_active_sub, "regular_unlimited_override", False))
if local_active_sub
else False
)
return {
"user_id": panel_user_data.get("uuid"),
@@ -2268,11 +2366,23 @@ class SubscriptionService:
"traffic_used_bytes": panel_traffic_used,
"traffic_limit_strategy": traffic_limit_strategy,
"tariff_key": local_active_sub.tariff_key if local_active_sub else None,
"tariff_name": tariff.name(db_user.language_code or self.settings.DEFAULT_LANGUAGE) if tariff else None,
"tariff_description": tariff.description(db_user.language_code or self.settings.DEFAULT_LANGUAGE) if tariff else None,
"premium_title": tariff.premium_name(db_user.language_code or self.settings.DEFAULT_LANGUAGE) if tariff else None,
"tariff_name": tariff.name(db_user.language_code or self.settings.DEFAULT_LANGUAGE)
if tariff
else None,
"tariff_description": tariff.description(
db_user.language_code or self.settings.DEFAULT_LANGUAGE
)
if tariff
else None,
"premium_title": tariff.premium_name(
db_user.language_code or self.settings.DEFAULT_LANGUAGE
)
if tariff
else None,
"billing_model": billing_model_display,
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes if local_active_sub else None,
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes
if local_active_sub
else None,
"topup_balance_bytes": local_active_sub.topup_balance_bytes if local_active_sub else 0,
"regular_bonus_bytes": regular_bonus_bytes,
"regular_unlimited_override": regular_unlimited_override,
@@ -2288,14 +2398,22 @@ class SubscriptionService:
premium_topup_used,
premium_bonus_bytes,
),
"premium_is_limited": bool(local_active_sub.premium_is_limited) if local_active_sub else False,
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None) if local_active_sub else None,
"premium_is_limited": bool(local_active_sub.premium_is_limited)
if local_active_sub
else False,
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None)
if local_active_sub
else None,
"premium_squad_labels": premium_access.get("squad_labels") or [],
"premium_node_labels": premium_access.get("node_labels") or [],
"period_start_at": local_active_sub.period_start_at if local_active_sub else None,
"is_throttled": bool(local_active_sub.is_throttled) if local_active_sub else False,
"base_hwid_device_limit": local_active_sub.hwid_device_limit if local_active_sub else None,
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0) if local_active_sub else 0,
"base_hwid_device_limit": local_active_sub.hwid_device_limit
if local_active_sub
else None,
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0)
if local_active_sub
else 0,
"user_bot_username": db_user.username,
"is_panel_data": True,
"max_devices": hwid_limit,
@@ -2304,26 +2422,19 @@ class SubscriptionService:
async def get_subscriptions_ending_soon(
self, session: AsyncSession, days_threshold: int
) -> List[Dict[str, Any]]:
subs_models_with_users = (
await subscription_dal.get_subscriptions_near_expiration(
session, days_threshold
)
subs_models_with_users = await subscription_dal.get_subscriptions_near_expiration(
session, days_threshold
)
results = []
for sub_model in subs_models_with_users:
if (
sub_model.user
and sub_model.end_date
and not sub_model.skip_notifications
):
days_left = (
sub_model.end_date - datetime.now(timezone.utc)
).total_seconds() / (24 * 3600)
if sub_model.user and sub_model.end_date and not sub_model.skip_notifications:
days_left = (sub_model.end_date - datetime.now(timezone.utc)).total_seconds() / (
24 * 3600
)
results.append(
{
"user_id": sub_model.user_id,
"first_name": sub_model.user.first_name
or f"User {sub_model.user_id}",
"first_name": sub_model.user.first_name or f"User {sub_model.user_id}",
"language_code": sub_model.user.language_code
or self.settings.DEFAULT_LANGUAGE,
"end_date_str": sub_model.end_date.strftime("%Y-%m-%d"),
@@ -2348,10 +2459,13 @@ class SubscriptionService:
if not self.settings.yookassa_autopayments_active:
return True
if sub.provider != "yookassa":
logging.info("Auto-renew skipped: provider %s does not support auto-renew", sub.provider)
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
default_pm = await get_user_default_payment_method(session, sub.user_id)
if not default_pm:
logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}")
@@ -2359,10 +2473,11 @@ class SubscriptionService:
try:
from .yookassa_service import YooKassaService # local import to avoid cycles
yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined]
except Exception:
yk = None # type: ignore
if not yk or not getattr(yk, 'configured', False):
if not yk or not getattr(yk, "configured", False):
logging.warning("YooKassa unavailable for auto-renew")
return False
@@ -2443,17 +2558,13 @@ class SubscriptionService:
email_service = EmailAuthService(self.settings)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception(
"Failed to send payment success email to user %s", db_user.user_id
)
logging.exception("Failed to send payment success email to user %s", db_user.user_id)
async def update_last_notification_sent(
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
):
sub_to_update = (
await subscription_dal.find_subscription_for_notification_update(
session, user_id, subscription_end_date
)
sub_to_update = await subscription_dal.find_subscription_for_notification_update(
session, user_id, subscription_end_date
)
if sub_to_update:
await subscription_dal.update_subscription_notification_time(
@@ -2484,12 +2595,16 @@ class SubscriptionService:
if include_uuid and panel_user_uuid:
payload["uuid"] = panel_user_uuid
if expire_at is not None:
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
)
if status is not None:
payload["status"] = status
if traffic_limit_bytes is not None:
payload["trafficLimitBytes"] = traffic_limit_bytes
payload["trafficLimitStrategy"] = traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY
payload["trafficLimitStrategy"] = (
traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY
)
if hwid_device_limit is not None:
try:
hwid_limit_int = int(hwid_device_limit)
+49 -23
View File
@@ -14,8 +14,8 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.date_utils import month_start
from config.settings import Settings
from bot.utils.mini_app_url import subscription_mini_app_topup_url
from config.settings import Settings
from db.dal import subscription_dal, tariff_dal, user_dal
from db.models import Subscription
@@ -68,7 +68,9 @@ class TariffTrafficWorker:
def _traffic_topup_markup(self, user_lang: str, kind: str) -> Optional[InlineKeyboardMarkup]:
if not self.bot:
return None
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) if self.i18n else (lambda key, **_: key)
_ = lambda k, **kw: (
self.i18n.gettext(user_lang, k, **kw) if self.i18n else (lambda key, **_: key)
)
normalized = "premium" if str(kind or "").lower() == "premium" else "regular"
url = subscription_mini_app_topup_url(self.settings, normalized)
if normalized == "premium":
@@ -120,8 +122,13 @@ class TariffTrafficWorker:
tariff = self.settings.tariffs_config.require(sub.tariff_key)
except Exception:
continue
panel_data = await self.panel_service.get_user_by_uuid(sub.panel_user_uuid, log_response=False) or {}
used, limit, panel_strategy = self.subscription_service._extract_panel_traffic_details(panel_data)
panel_data = (
await self.panel_service.get_user_by_uuid(sub.panel_user_uuid, log_response=False)
or {}
)
used, limit, panel_strategy = self.subscription_service._extract_panel_traffic_details(
panel_data
)
panel_status = str(panel_data.get("status") or "").upper()
panel_username = panel_data.get("username") if isinstance(panel_data, dict) else None
if used is not None and used != sub.traffic_used_bytes:
@@ -139,10 +146,14 @@ class TariffTrafficWorker:
tariff,
used,
limit,
warning_period_start=warning_period_start if tariff.billing_model == "period" else None,
warning_period_start=warning_period_start
if tariff.billing_model == "period"
else None,
)
await self._sync_premium_squad_limit(session, sub, tariff, now, panel_username=panel_username)
await self._sync_premium_squad_limit(
session, sub, tariff, now, panel_username=panel_username
)
async def _ensure_period_reset_strategy(
self,
@@ -179,7 +190,9 @@ class TariffTrafficWorker:
tariff,
include_premium=not bool(getattr(sub, "premium_is_limited", False)),
)
await self.panel_service.update_user_details_on_panel(sub.panel_user_uuid, payload, log_response=False)
await self.panel_service.update_user_details_on_panel(
sub.panel_user_uuid, payload, log_response=False
)
async def _maybe_warn_or_throttle(
self,
@@ -270,14 +283,17 @@ class TariffTrafficWorker:
panel_username: Optional[str] = None,
) -> None:
if not getattr(tariff, "premium_squad_uuids", None):
if any(
int(value or 0) > 0
for value in (
sub.premium_baseline_bytes,
sub.premium_topup_balance_bytes,
sub.premium_used_bytes,
if (
any(
int(value or 0) > 0
for value in (
sub.premium_baseline_bytes,
sub.premium_topup_balance_bytes,
sub.premium_used_bytes,
)
)
) or sub.premium_is_limited:
or sub.premium_is_limited
):
sub.premium_baseline_bytes = 0
sub.premium_topup_balance_bytes = 0
sub.premium_used_bytes = 0
@@ -288,11 +304,15 @@ class TariffTrafficWorker:
same_period = bool(getattr(sub, "premium_period_start_at", None) == premium_period_start)
premium_baseline = int(tariff.premium_monthly_bytes or 0)
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) if same_period else 0
premium_topup_used = (
int(getattr(sub, "premium_topup_used_bytes", 0) or 0) if same_period else 0
)
# Admin-side overrides for free gifted premium traffic.
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
premium_limit = premium_baseline + premium_topup_balance + premium_topup_used + premium_bonus
premium_limit = (
premium_baseline + premium_topup_balance + premium_topup_used + premium_bonus
)
if premium_limit <= 0 and not premium_unlimited_override:
return
@@ -323,7 +343,9 @@ class TariffTrafficWorker:
if consume_from_topup > 0:
premium_topup_balance -= consume_from_topup
premium_topup_used += consume_from_topup
premium_limit = premium_baseline + premium_topup_balance + premium_topup_used + premium_bonus
premium_limit = (
premium_baseline + premium_topup_balance + premium_topup_used + premium_bonus
)
if premium_unlimited_override:
should_limit = False
@@ -448,7 +470,9 @@ class TariffTrafficWorker:
parse_mode="HTML",
)
except Exception:
logging.exception("Failed to send premium traffic depleted warning to user %s", sub.user_id)
logging.exception(
"Failed to send premium traffic depleted warning to user %s", sub.user_id
)
return
for level in levels:
@@ -519,7 +543,9 @@ class TariffTrafficWorker:
nodes: list[str] = []
for squad_uuid in tariff.premium_squad_uuids or []:
accessible = await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or []
accessible = (
await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or []
)
for node in accessible:
if not isinstance(node, dict):
continue
@@ -564,9 +590,7 @@ class TariffTrafficWorker:
or entry.get("user_uuid")
)
entry_username = (
user_obj.get("username")
or entry.get("username")
or entry.get("userUsername")
user_obj.get("username") or entry.get("username") or entry.get("userUsername")
)
# Remnawave's /bandwidth-stats/nodes/{uuid}/users response
# currently exposes only {color, username, total}; match by
@@ -609,7 +633,9 @@ class TariffTrafficWorker:
if int(sub.traffic_limit_bytes or 0) <= int(sub.traffic_used_bytes or 0):
continue
for squad_uuid in tariff.squad_uuids:
await self.panel_service.add_users_to_internal_squad(squad_uuid, [sub.panel_user_uuid])
await self.panel_service.add_users_to_internal_squad(
squad_uuid, [sub.panel_user_uuid]
)
await subscription_dal.update_subscription(
session,
sub.subscription_id,
+100 -114
View File
@@ -1,40 +1,44 @@
import uuid
import logging
import asyncio
from typing import Optional, Dict, Any, List
import logging
import uuid
from typing import Any, Dict, List, Optional
from yookassa import Configuration, Payment as YooKassaPayment
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
from yookassa import Configuration
from yookassa import Payment as YooKassaPayment
from yookassa.domain.common.confirmation_type import ConfirmationType
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
from config.settings import Settings
class YooKassaService:
def __init__(self,
shop_id: Optional[str],
secret_key: Optional[str],
configured_return_url: Optional[str],
bot_username_for_default_return: Optional[str] = None,
settings_obj: Optional[Settings] = None):
def __init__(
self,
shop_id: Optional[str],
secret_key: Optional[str],
configured_return_url: Optional[str],
bot_username_for_default_return: Optional[str] = None,
settings_obj: Optional[Settings] = None,
):
self.settings = settings_obj
if self.settings and not self.settings.YOOKASSA_ENABLED:
logging.warning("YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED.")
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.")
"Payment functionality will be DISABLED."
)
self.configured = False
else:
try:
Configuration.configure(shop_id, secret_key)
self.configured = True
logging.info(
f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
logging.info(f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
except Exception:
logging.exception("Failed to configure YooKassa SDK.")
self.configured = False
@@ -52,22 +56,21 @@ class YooKassaService:
f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. "
f"Using placeholder: {self.return_url}. Payments may not complete correctly."
)
logging.info(
f"YooKassa Service effective return_url for payments: {self.return_url}"
)
logging.info(f"YooKassa Service effective return_url for payments: {self.return_url}")
async def create_payment(
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
save_payment_method: bool = False,
payment_method_id: Optional[str] = None,
capture: bool = True,
bind_only: bool = False) -> Optional[Dict[str, Any]]:
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
save_payment_method: bool = False,
payment_method_id: Optional[str] = None,
capture: bool = True,
bind_only: bool = False,
) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error("YooKassa is not configured. Cannot create payment.")
return None
@@ -77,10 +80,8 @@ class YooKassaService:
"YooKassaService: Settings object not available. Cannot create payment with receipt details."
)
return {
"error":
True,
"internal_message":
"Service settings (Settings object) not initialized."
"error": True,
"internal_message": "Service settings (Settings object) not initialized.",
}
customer_contact_for_receipt = {}
@@ -89,25 +90,19 @@ class YooKassaService:
elif receipt_phone:
customer_contact_for_receipt["phone"] = receipt_phone
elif self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt[
"email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
customer_contact_for_receipt["email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logging.error(
"CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set."
)
return {
"error":
True,
"internal_message":
"YooKassa receipt customer contact (email/phone) missing and no default email configured."
"error": True,
"internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.",
}
try:
builder = PaymentRequestBuilder()
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_amount({"value": str(round(amount, 2)), "currency": currency.upper()})
# For binding cards only, do not capture and set minimal amount
if bind_only:
capture = False
@@ -115,10 +110,9 @@ class YooKassaService:
builder.set_capture(capture)
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_confirmation(
{"type": ConfirmationType.REDIRECT, "return_url": self.return_url}
)
builder.set_description(description)
builder.set_metadata(metadata)
if save_payment_method:
@@ -128,26 +122,28 @@ class YooKassaService:
# Use a previously saved payment method for merchant-initiated payments
builder.set_payment_method_id(payment_method_id)
receipt_items_list: List[Dict[str, Any]] = [{
"description":
description[:128],
"quantity":
"1.00",
"amount": {
"value": str(round(amount, 2)),
"currency": currency.upper()
},
"vat_code":
str(self.settings.YOOKASSA_VAT_CODE),
"payment_mode":
getattr(self.settings, 'yk_receipt_payment_mode', self.settings.YOOKASSA_PAYMENT_MODE),
"payment_subject":
getattr(self.settings, 'yk_receipt_payment_subject', self.settings.YOOKASSA_PAYMENT_SUBJECT)
}]
receipt_items_list: List[Dict[str, Any]] = [
{
"description": description[:128],
"quantity": "1.00",
"amount": {"value": str(round(amount, 2)), "currency": currency.upper()},
"vat_code": str(self.settings.YOOKASSA_VAT_CODE),
"payment_mode": getattr(
self.settings,
"yk_receipt_payment_mode",
self.settings.YOOKASSA_PAYMENT_MODE,
),
"payment_subject": getattr(
self.settings,
"yk_receipt_payment_subject",
self.settings.YOOKASSA_PAYMENT_SUBJECT,
),
}
]
receipt_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list
"items": receipt_items_list,
}
builder.set_receipt(receipt_data_dict)
@@ -171,49 +167,34 @@ class YooKassaService:
)
return {
"id":
response.id,
"confirmation_url":
response.confirmation.confirmation_url
if response.confirmation else None,
"status":
response.status,
"metadata":
response.metadata,
"amount_value":
float(response.amount.value),
"amount_currency":
response.amount.currency,
"idempotence_key_used":
idempotence_key,
"paid":
response.paid,
"refundable":
response.refundable,
"created_at":
response.created_at.isoformat() if hasattr(
response.created_at, 'isoformat') else str(
response.created_at),
"description_from_yk":
response.description,
"test_mode":
response.test if hasattr(response, 'test') else None,
"payment_method": getattr(response, 'payment_method', None),
"id": response.id,
"confirmation_url": response.confirmation.confirmation_url
if response.confirmation
else None,
"status": response.status,
"metadata": response.metadata,
"amount_value": float(response.amount.value),
"amount_currency": response.amount.currency,
"idempotence_key_used": idempotence_key,
"paid": response.paid,
"refundable": response.refundable,
"created_at": response.created_at.isoformat()
if hasattr(response.created_at, "isoformat")
else str(response.created_at),
"description_from_yk": response.description,
"test_mode": response.test if hasattr(response, "test") else None,
"payment_method": getattr(response, "payment_method", None),
}
except Exception:
logging.exception("YooKassa payment creation failed.")
return None
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
async def get_payment_info(self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error(
"YooKassa is not configured. Cannot get payment info.")
logging.error("YooKassa is not configured. Cannot get payment info.")
return None
try:
logging.info(
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
)
logging.info(f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}")
payment_info_yk = await asyncio.to_thread(
YooKassaPayment.find_one,
@@ -224,18 +205,20 @@ class YooKassaService:
logging.info(
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
)
pm = getattr(payment_info_yk, 'payment_method', None)
pm = getattr(payment_info_yk, "payment_method", None)
pm_payload: Dict[str, Any] = {}
if pm:
# Collect common fields, including id and hints for last4
pm_id = getattr(pm, 'id', None)
pm_type = getattr(pm, 'type', None)
pm_title = getattr(pm, 'title', None)
account_number = getattr(pm, 'account_number', None) or getattr(pm, 'account', None)
card_obj = getattr(pm, 'card', None)
pm_id = getattr(pm, "id", None)
pm_type = getattr(pm, "type", None)
pm_title = getattr(pm, "title", None)
account_number = getattr(pm, "account_number", None) or getattr(
pm, "account", None
)
card_obj = getattr(pm, "card", None)
last4_val = None
if card_obj and hasattr(card_obj, 'last4'):
last4_val = getattr(card_obj, 'last4')
if card_obj and hasattr(card_obj, "last4"):
last4_val = getattr(card_obj, "last4")
elif isinstance(account_number, str) and len(account_number) >= 4:
last4_val = account_number[-4:]
pm_payload = {
@@ -253,11 +236,15 @@ class YooKassaService:
"metadata": payment_info_yk.metadata,
"description": payment_info_yk.description,
"refundable": payment_info_yk.refundable,
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
"captured_at": payment_info_yk.captured_at.isoformat() if getattr(payment_info_yk, 'captured_at', None) and hasattr(payment_info_yk.captured_at, 'isoformat') else None,
"created_at": payment_info_yk.created_at.isoformat()
if hasattr(payment_info_yk.created_at, "isoformat")
else str(payment_info_yk.created_at),
"captured_at": payment_info_yk.captured_at.isoformat()
if getattr(payment_info_yk, "captured_at", None)
and hasattr(payment_info_yk.captured_at, "isoformat")
else None,
"payment_method": pm_payload,
"test_mode": getattr(payment_info_yk, 'test', None),
"test_mode": getattr(payment_info_yk, "test", None),
}
else:
logging.warning(
@@ -265,8 +252,7 @@ class YooKassaService:
)
return None
except Exception:
logging.exception(
"YooKassa get payment info for %s failed.", payment_id_in_yookassa)
logging.exception("YooKassa get payment info for %s failed.", payment_id_in_yookassa)
return None
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool: