chore: run lint and prettifier
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;"> </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;"> </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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user