refactor: split backend domains and add API behavior coverage

This commit is contained in:
3252a8
2026-05-13 18:38:10 +03:00
parent a96007d48a
commit 7401649841
103 changed files with 10558 additions and 9832 deletions
+1 -1
View File
@@ -562,7 +562,7 @@ class EmailAuthService:
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
logger.log(
log_level,
"SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s",
"SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s", # noqa: E501
attempt_number,
len(attempts),
smtp_host,
+21 -21
View File
@@ -140,7 +140,7 @@ def _layout(
</table>
</body>
</html>
"""
""" # noqa: E501
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
@@ -152,13 +152,13 @@ def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
border = "" if index == last else f"border-bottom:1px solid {_BORDER};"
cells.append(
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'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>' # noqa: E501
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>" # noqa: E501
f"</tr>"
)
return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">'
f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">' # noqa: E501
+ "".join(cells)
+ "</table>"
)
@@ -167,14 +167,14 @@ def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
def _cta_button_html(*, label: str, url: str, accent: str) -> str:
safe_label = html.escape(label)
safe_url = html.escape(url, quote=True)
# Accent green is light, so contrast text is dark; works for the default and similar light accents.
# Accent green is light, so contrast text is dark; works for the default and similar light accents. # noqa: E501
return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;margin:22px 0 18px 0;">'
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">'
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">' # noqa: E501
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;" # noqa: E501
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>"
)
@@ -228,9 +228,9 @@ def render_login_code(
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'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">' # noqa: E501
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">' # noqa: E501
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};\">" # noqa: E501
f"{html.escape(code)}"
f"</div></td></tr></table>"
)
@@ -242,24 +242,24 @@ def render_login_code(
magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro")
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'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">' # noqa: E501
f"<tr>"
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>' # noqa: E501
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>' # noqa: E501
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>' # noqa: E501
f"</tr></table>"
)
magic_block = (
divider_html
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>'
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>' # noqa: E501
+ _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent)
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>'
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>' # noqa: E501
)
body_html = (
code_block
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>'
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>'
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>' # noqa: E501
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>' # noqa: E501
+ magic_block
)
@@ -312,7 +312,7 @@ def render_account_merged(
]
body_html = (
_info_rows_html(rows)
+ f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
+ f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>' # noqa: E501
)
rendered = _layout(
@@ -413,7 +413,7 @@ def render_payment_success(
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>'
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>' # noqa: E501
)
rendered = _layout(
@@ -476,7 +476,7 @@ def render_subscription_expiring(
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>' # noqa: E501
)
rendered = _layout(
+1 -1
View File
@@ -63,7 +63,7 @@ class FreeKassaService:
)
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."
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
)
@staticmethod
+17 -17
View File
@@ -106,15 +106,15 @@ class PanelApiService:
parsed_json_for_log, indent=2, ensure_ascii=False
)
logging.info(
f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}"
f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}" # noqa: E501
)
except json.JSONDecodeError:
logging.info(
f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}"
f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}" # noqa: E501
)
else:
logging.debug(
f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}"
f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}" # noqa: E501
)
if 200 <= response_status < 300:
@@ -130,7 +130,7 @@ class PanelApiService:
}
except json.JSONDecodeError as e_json_ok:
logging.error(
f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above."
f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above." # noqa: E501
)
return {
"status": "success_parse_error",
@@ -179,7 +179,7 @@ class PanelApiService:
if not response_data or response_data.get("error"):
logging.error(
f"Failed to fetch panel users batch (start: {start_offset}). Response: {response_data}"
f"Failed to fetch panel users batch (start: {start_offset}). Response: {response_data}" # noqa: E501
)
return None
users_batch = response_data.get("response", {}).get("users", [])
@@ -289,7 +289,7 @@ class PanelApiService:
return []
logging.error(
f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}"
f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}" # noqa: E501
)
return None
@@ -345,7 +345,7 @@ class PanelApiService:
payload["hwidDeviceLimit"] = hwid_limit_int
except (TypeError, ValueError):
logging.warning(
f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'."
f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'." # noqa: E501
)
if specific_squad_uuids:
payload["activeInternalSquads"] = specific_squad_uuids
@@ -365,17 +365,17 @@ class PanelApiService:
)
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')})." # noqa: E501
)
return response
logging.error(
f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}"
f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}" # noqa: E501
)
return response
async def update_user_details_on_panel(
self, user_uuid: str, update_payload: Dict[str, Any], log_response: bool = True
self, user_uuid: str, update_payload: Dict[str, Any], log_response: bool = False
) -> Optional[Dict[str, Any]]:
if "uuid" not in update_payload:
update_payload["uuid"] = user_uuid
@@ -384,11 +384,11 @@ class PanelApiService:
"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.")
logging.debug("User %s details updated on panel.", user_uuid)
return full_response.get("response")
logging.error(
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}"
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}" # noqa: E501
)
return None
@@ -404,17 +404,17 @@ class PanelApiService:
expected_status = "ACTIVE" if enable else "DISABLED"
if actual_status == expected_status:
logging.info(
f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})."
f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})." # noqa: E501
)
return True
else:
logging.warning(
f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'."
f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'." # noqa: E501
)
return False
logging.error(
f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}"
f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}" # noqa: E501
)
return False
@@ -434,7 +434,7 @@ class PanelApiService:
error_code = details.get("errorCode") or response_data.get("errorCode")
if error_code in {"A062", "A040"}:
logging.info(
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted." # noqa: E501
)
return True
logging.error(f"Failed to delete user {user_uuid} on panel. Response: {response_data}")
@@ -469,7 +469,7 @@ class PanelApiService:
if response_data and not response_data.get("error") and "response" in response_data:
return True
logging.error(
f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}"
f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}" # noqa: E501
)
return False
+1 -1
View File
@@ -106,7 +106,7 @@ class PanelWebhookService:
ok = await subscription_service.charge_subscription_renewal(
session, sub
)
# If initiation succeeded, suppress the 24h reminder by returning early
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
if ok:
await session.commit()
return
+2 -2
View File
@@ -61,7 +61,7 @@ class PlategaService:
)
else:
logging.info(
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)",
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", # noqa: E501
"ON" if settings.PLATEGA_SBP_ENABLED else "OFF",
self.sbp_method,
"ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF",
@@ -205,7 +205,7 @@ class PlategaService:
)
if incoming_amount != expected_amount:
logging.warning(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)", # noqa: E501
payment.payment_id,
expected_amount,
incoming_amount,
+1 -1
View File
@@ -119,7 +119,7 @@ class PromoCodeService:
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}"
f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}" # noqa: E501
)
return False, _("error_applying_promo_bonus")
else:
+15 -15
View File
@@ -43,7 +43,7 @@ class ReferralService:
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."
f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses." # noqa: E501
)
return {"referee_bonus_applied_days": None, "referee_new_end_date": None}
@@ -58,7 +58,7 @@ 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."
f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments." # noqa: E501
)
return {"referee_bonus_applied_days": None, "referee_new_end_date": None}
except Exception as e_cnt:
@@ -74,7 +74,7 @@ class ReferralService:
session, referee_user_id
):
logging.info(
f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription."
f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription." # noqa: E501
)
return {"referee_bonus_applied_days": None, "referee_new_end_date": None}
except Exception as e_sub:
@@ -104,7 +104,7 @@ class ReferralService:
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."
f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus." # noqa: E501
)
else:
(
@@ -118,7 +118,7 @@ class ReferralService:
if not inviter_panel_uuid:
logging.warning(
f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel."
f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel." # noqa: E501
)
else:
@@ -134,7 +134,7 @@ class ReferralService:
if new_end_date_inviter:
inviter_bonus_successfully_applied = True
logging.info(
f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}."
f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}." # noqa: E501
)
try:
@@ -153,11 +153,11 @@ class ReferralService:
)
except Exception as e_notify_inviter:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}"
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
)
else:
logging.info(
f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days."
f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days." # noqa: E501
)
bonus_start_date = datetime.now(timezone.utc)
@@ -165,7 +165,7 @@ class ReferralService:
if not inviter_panel_sub_link_id:
logging.error(
f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch."
f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch." # noqa: E501
)
else:
bonus_sub_payload = {
@@ -188,7 +188,7 @@ class ReferralService:
session, bonus_sub_payload
)
panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel(
panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel( # noqa: E501
inviter_panel_uuid,
{
"expireAt": bonus_end_date.isoformat(
@@ -200,7 +200,7 @@ class ReferralService:
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}."
f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}." # noqa: E501
)
inviter_lang = (
@@ -221,12 +221,12 @@ class ReferralService:
)
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."
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." # noqa: E501
)
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}",
f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}", # noqa: E501
exc_info=True,
)
@@ -243,11 +243,11 @@ class ReferralService:
referee_final_end_date = new_end_date_referee
referee_bonus_applied_days = referee_bonus_days
logging.info(
f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}."
f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}." # noqa: E501
)
else:
logging.warning(
f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)."
f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)." # noqa: E501
)
return {
+1 -1
View File
@@ -74,7 +74,7 @@ class StarsService:
title=description,
description=description,
payload=payload,
provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. # noqa: E501
currency="XTR",
prices=prices,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"""Domain mixins for SubscriptionService."""
@@ -0,0 +1,29 @@
# ruff: noqa: F401,F403,F405,I001
import logging
import math
from datetime import datetime, timedelta, timezone
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 bot.utils.config_link import prepare_config_links
from bot.utils.date_utils import add_months, month_start
from config.settings import Settings
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 bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_payment_success
from bot.services.panel_api_service import PanelApiService
__all__ = [name for name in globals() if not name.startswith("__")]
@@ -0,0 +1,34 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .devices import HwidDeviceMixin
from .lifecycle import SubscriptionLifecycleMixin
from .panel_identity import PanelIdentityMixin
from .payments import PaymentContextMixin
from .renewal import RenewalMixin
from .tariffs import TariffMixin
from .traffic import TrafficMixin
from .trial import TrialSubscriptionMixin
class SubscriptionService(
TrialSubscriptionMixin,
TrafficMixin,
HwidDeviceMixin,
SubscriptionLifecycleMixin,
RenewalMixin,
PaymentContextMixin,
PanelIdentityMixin,
TariffMixin,
):
def __init__(
self,
settings: Settings,
panel_service: PanelApiService,
bot: Optional[Bot] = None,
i18n: Optional[JsonI18n] = None,
):
self.settings = settings
self.panel_service = panel_service
self.bot = bot
self.i18n = i18n
self._premium_access_cache: Dict[Tuple[str, ...], Dict[str, Any]] = {}
@@ -0,0 +1,117 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class HwidDeviceMixin:
async def activate_hwid_device_topup(
self,
session: AsyncSession,
user_id: int,
device_count: int,
payment_amount: float,
payment_db_id: int,
provider: str = "yookassa",
tariff_key: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
try:
purchased_devices = int(device_count)
except (TypeError, ValueError):
purchased_devices = 0
if purchased_devices <= 0:
logging.error("HWID device top-up requires positive device count for user %s", user_id)
return None
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
)
if not sub:
return None
tariff = None
if self._tariffs_config():
tariff = self._resolve_tariff(tariff_key or sub.tariff_key)
packages = (
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
if tariff.hwid_device_packages
else []
)
if packages and not any(pkg.count == purchased_devices for pkg in packages):
logging.error(
"HWID device package %s is not available for tariff %s",
purchased_devices,
tariff.key,
)
return None
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
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
)
return {
"subscription_id": sub.subscription_id,
"hwid_device_limit": 0,
"extra_hwid_devices": int(sub.extra_hwid_devices or 0),
"purchased_hwid_devices": 0,
}
new_extra_devices = int(sub.extra_hwid_devices or 0) + purchased_devices
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, new_extra_devices)
await self._record_payment_context(
session,
payment_db_id,
sale_mode="hwid_devices",
tariff_key=tariff.key if tariff else sub.tariff_key,
purchased_hwid_devices=purchased_devices,
)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": new_extra_devices,
"tariff_key": tariff.key if tariff else sub.tariff_key,
},
)
if not updated_sub:
return None
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=updated_sub.end_date,
status="ACTIVE",
hwid_device_limit=effective_hwid_limit,
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel = await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid,
panel_payload,
)
if not updated_panel or updated_panel.get("error"):
logging.warning(
"Panel user HWID limit update failed for user %s. Response: %s",
user_id,
updated_panel,
)
return None
await tariff_dal.create_hwid_device_purchase(
session,
subscription_id=updated_sub.subscription_id,
payment_id=payment_db_id,
purchased_devices=purchased_devices,
)
return {
"subscription_id": updated_sub.subscription_id,
"hwid_device_limit": effective_hwid_limit,
"extra_hwid_devices": new_extra_devices,
"purchased_hwid_devices": purchased_devices,
"tariff_key": tariff.key if tariff else sub.tariff_key,
}
@@ -0,0 +1,838 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class SubscriptionLifecycleMixin:
async def switch_tariff_without_payment(
self,
session: AsyncSession,
user_id: int,
target_tariff_key: str,
mode: str,
) -> Optional[Dict[str, Any]]:
config = self._tariffs_config()
if not config:
return None
target = config.require(target_tariff_key)
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
)
if not sub:
return None
before_tariff_key = sub.tariff_key
options = self.calculate_tariff_switch_options(sub, target)
now = datetime.now(timezone.utc)
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
premium_baseline = target.premium_monthly_bytes
premium_limit = self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
)
premium_used = int(sub.premium_used_bytes or 0)
update_data: Dict[str, Any] = {
"tariff_key": target.key,
"is_throttled": False,
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_is_limited": bool(premium_limit > 0 and premium_used >= premium_limit),
}
converted_bytes = None
base_hwid_limit = self._base_hwid_limit_for_tariff(target)
extra_hwid_devices = int(sub.extra_hwid_devices or 0)
update_data["hwid_device_limit"] = base_hwid_limit
if target.billing_model == "period":
update_data["tier_baseline_bytes"] = target.monthly_bytes
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
used_sub = int(sub.traffic_used_bytes or 0)
update_data["traffic_limit_bytes"] = self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=target.monthly_bytes,
topup_balance_bytes=int(sub.topup_balance_bytes or 0),
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
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()
)
if mode == "recalc_days" and options.get("recalc_days") is not None:
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
else:
converted_gb = float(options.get("converted_gb", 0))
converted_bytes = self.gb_to_bytes(converted_gb)
old_topup = int(sub.topup_balance_bytes or 0)
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 {}
)
current_used, _, _ = self._extract_panel_traffic_details(panel_user)
cur_used_int = int(current_used or 0)
update_data.update(
{
"end_date": self._far_future(),
"period_start_at": None,
"tier_baseline_bytes": 0,
"topup_balance_bytes": new_balance,
"traffic_limit_bytes": self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=0,
topup_balance_bytes=new_balance,
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
traffic_used_bytes=cur_used_int,
),
"traffic_used_bytes": current_used,
"effective_monthly_price_rub": None,
"auto_renew_enabled": False,
"skip_notifications": True,
}
)
updated = await subscription_dal.update_subscription(
session, sub.subscription_id, update_data
)
if not updated:
return None
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=updated.end_date,
status="ACTIVE",
traffic_limit_bytes=updated.traffic_limit_bytes,
traffic_limit_strategy="NO_RESET" if target.billing_model == "traffic" else "MONTH",
hwid_device_limit=self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices),
)
panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
target,
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
)
if converted_bytes:
await tariff_dal.create_traffic_topup(
session,
subscription_id=updated.subscription_id,
payment_id=None,
purchased_bytes=converted_bytes,
kind="conversion",
)
await tariff_dal.create_tariff_change(
session,
{
"subscription_id": updated.subscription_id,
"from_tariff_key": before_tariff_key,
"to_tariff_key": target.key,
"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,
"converted_bytes": converted_bytes,
"eff_price_before": sub.effective_monthly_price_rub,
"eff_price_after": updated.effective_monthly_price_rub,
},
)
return {"subscription_id": updated.subscription_id, "tariff_key": target.key}
async def activate_subscription(
self,
session: AsyncSession,
user_id: int,
months: int,
payment_amount: float,
payment_db_id: int,
promo_code_id_from_payment: Optional[int] = None,
provider: str = "yookassa",
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
tariff_key: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
sale_mode_base, sale_mode_tariff_key = self._parse_sale_mode_context(sale_mode, tariff_key)
tariff_key = sale_mode_tariff_key
if sale_mode_base in {"traffic", "traffic_package"} or (
getattr(self.settings, "traffic_sale_mode", False) and not self._tariffs_config()
):
target_gb = traffic_gb if traffic_gb is not None else float(months)
return await self._activate_traffic_package(
session=session,
user_id=user_id,
traffic_gb=target_gb,
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
tariff_key=tariff_key,
sale_mode="traffic_package" if self._tariffs_config() else "traffic",
)
if sale_mode_base == "topup":
if not tariff_key:
active_user = await user_dal.get_user_by_id(session, user_id)
active_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session, user_id, active_user.panel_user_uuid
)
if active_user and active_user.panel_user_uuid
else None
)
tariff_key = active_sub.tariff_key if active_sub else None
if not tariff_key:
logging.error("Top-up activation requires tariff_key for user %s", user_id)
return None
return await self.activate_topup(
session=session,
user_id=user_id,
tariff_key=tariff_key,
traffic_gb=traffic_gb if traffic_gb is not None else float(months),
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
)
if sale_mode_base == "premium_topup":
if not tariff_key:
active_user = await user_dal.get_user_by_id(session, user_id)
active_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session, user_id, active_user.panel_user_uuid
)
if active_user and active_user.panel_user_uuid
else None
)
tariff_key = active_sub.tariff_key if active_sub else None
if not tariff_key:
logging.error("Premium top-up activation requires tariff_key for user %s", user_id)
return None
return await self.activate_premium_topup(
session=session,
user_id=user_id,
tariff_key=tariff_key,
traffic_gb=traffic_gb if traffic_gb is not None else float(months),
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
)
if sale_mode_base in {"hwid_device", "hwid_devices"}:
target_devices = int(traffic_gb if traffic_gb is not None else months)
return await self.activate_hwid_device_topup(
session=session,
user_id=user_id,
device_count=target_devices,
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
tariff_key=tariff_key,
)
if sale_mode_base == "tariff_upgrade":
if not tariff_key:
logging.error("Tariff upgrade activation requires tariff_key for user %s", user_id)
return None
await self._record_payment_context(
session,
payment_db_id,
sale_mode="tariff_upgrade",
tariff_key=tariff_key,
purchased_gb=None,
)
result = await self.switch_tariff_without_payment(
session,
user_id,
tariff_key,
"paid_diff",
)
if result:
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub:
await tariff_dal.create_tariff_change(
session,
{
"subscription_id": sub.subscription_id,
"from_tariff_key": None,
"to_tariff_key": tariff_key,
"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,
"converted_bytes": None,
"eff_price_before": None,
"eff_price_after": sub.effective_monthly_price_rub,
},
)
result["end_date"] = sub.end_date
result["is_active"] = sub.is_active
return result
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
await self._record_payment_context(
session,
payment_db_id,
sale_mode=sale_mode_base,
tariff_key=tariff.key if tariff else tariff_key,
purchased_gb=None,
)
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.")
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)
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.")
return None
try:
months_int = int(months)
except Exception:
months_int = 1
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
start_date = datetime.now(timezone.utc)
if (
current_active_sub
and current_active_sub.end_date
and current_active_sub.end_date > start_date
):
start_date = current_active_sub.end_date
# base duration by months
end_after_months = add_months(start_date, months_int)
duration_days_total = (end_after_months - start_date).days
applied_promo_bonus_days = 0
if promo_code_id_from_payment:
promo_model = await promo_code_dal.get_promo_code_by_id(
session, promo_code_id_from_payment
)
if (
promo_model
and promo_model.is_active
and promo_model.current_activations < promo_model.max_activations
):
applied_promo_bonus_days = promo_model.bonus_days
duration_days_total += applied_promo_bonus_days
activation = await promo_code_dal.record_promo_activation(
session,
promo_code_id_from_payment,
user_id,
payment_id=payment_db_id,
)
if activation:
await promo_code_dal.increment_promo_code_usage(
session, promo_code_id_from_payment
)
else:
logging.warning(
f"Promo code {promo_code_id_from_payment} was already activated by user {user_id}, but bonus applied via payment {payment_db_id}." # noqa: E501
)
else:
logging.warning(
f"Promo code ID {promo_code_id_from_payment} (from payment) not found or invalid." # noqa: E501
)
promo_code_id_from_payment = None
final_end_date = start_date + timedelta(days=duration_days_total)
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id
)
auto_renew_should_enable = False
if provider == "yookassa" and self.settings.yookassa_autopayments_active:
auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method(
session, user_id
)
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_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
)
premium_baseline_bytes = tariff.premium_monthly_bytes if tariff else 0
premium_limit_bytes = self._premium_effective_limit_bytes(
premium_baseline_bytes,
premium_topup_balance_bytes,
premium_topup_used_bytes,
)
effective_monthly_price = float(payment_amount) / max(1, months_int)
regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
traffic_limit_bytes = self._traffic_limit_for_period_tariff(
tariff,
topup_balance_bytes,
regular_bonus_carry,
regular_unlimited_override=regular_unl_carry,
traffic_used_bytes=0,
)
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
)
sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
"panel_subscription_uuid": panel_sub_link_id,
"start_date": start_date,
"end_date": final_end_date,
"duration_months": months_int,
"is_active": True,
"status_from_panel": "ACTIVE",
"traffic_limit_bytes": traffic_limit_bytes,
"provider": provider,
"skip_notifications": False,
"auto_renew_enabled": auto_renew_should_enable,
"tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": tier_baseline_bytes,
"topup_balance_bytes": topup_balance_bytes,
"regular_bonus_bytes": regular_bonus_carry,
"regular_unlimited_override": regular_unl_carry,
"premium_baseline_bytes": premium_baseline_bytes,
"premium_topup_balance_bytes": premium_topup_balance_bytes,
"premium_topup_used_bytes": premium_topup_used_bytes,
"premium_used_bytes": premium_used_bytes,
"premium_is_limited": premium_is_limited,
"premium_period_start_at": premium_period_start_at,
"period_start_at": None,
"is_throttled": False,
"effective_monthly_price_rub": effective_monthly_price,
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
}
try:
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}",
exc_info=True,
)
return None
panel_update_payload = self._build_panel_update_payload(
panel_user_uuid=panel_user_uuid,
expire_at=final_end_date,
status="ACTIVE",
traffic_limit_bytes=traffic_limit_bytes,
traffic_limit_strategy="MONTH" if tariff else self.settings.USER_TRAFFIC_STRATEGY,
hwid_device_limit=effective_hwid_limit,
)
if tariff:
panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
)
if not updated_panel_user or updated_panel_user.get("error"):
logging.warning(
f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}" # noqa: E501
)
return None
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="subscription",
months=months_int,
traffic_gb=None,
payment_amount=payment_amount,
end_date=final_end_date,
provider=provider,
)
return {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
"is_active": True,
"panel_user_uuid": panel_user_uuid,
"panel_short_uuid": final_panel_short_uuid,
"subscription_url": final_subscription_url,
"applied_promo_bonus_days": applied_promo_bonus_days,
"tariff_key": tariff.key if tariff else None,
}
async def extend_active_subscription_days(
self,
session: AsyncSession,
user_id: int,
bonus_days: int,
reason: str = "bonus",
) -> Optional[datetime]:
reason_lower = (reason or "").lower()
apply_main_traffic_limit = any(
keyword in reason_lower for keyword in ("admin", "promo code", "referral", "bonus")
)
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.")
return None
panel_uuid, panel_sub_uuid, _, _ = await self._get_or_create_panel_user_link_details(
session, user_id, user
)
if not panel_uuid or not panel_sub_uuid:
logging.error(
f"Failed to ensure panel user for subscription extension of user {user_id}."
)
return None
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_uuid
)
if not active_sub or not active_sub.end_date:
logging.info(
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
)
start_date = datetime.now(timezone.utc)
new_end_date_obj = start_date + timedelta(days=bonus_days)
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise # noqa: E501
traffic_limit = (
self.settings.user_traffic_limit_bytes
if apply_main_traffic_limit
else self.settings.trial_traffic_limit_bytes
)
bonus_sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_uuid,
"panel_subscription_uuid": panel_sub_uuid,
"start_date": start_date,
"end_date": new_end_date_obj,
"duration_months": 0,
"is_active": True,
"status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": traffic_limit,
"auto_renew_enabled": False,
}
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_uuid, panel_sub_uuid
)
updated_sub_model = await subscription_dal.upsert_subscription(
session, bonus_sub_payload
)
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
new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days)
updated_sub_model = await subscription_dal.update_subscription_end_date(
session, active_sub.subscription_id, new_end_date_obj
)
if (
apply_main_traffic_limit
and updated_sub_model
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
):
updated_sub_model = await subscription_dal.update_subscription(
session,
updated_sub_model.subscription_id,
{"traffic_limit_bytes": self.settings.user_traffic_limit_bytes},
)
if updated_sub_model:
# Prepare panel update payload
panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj,
traffic_limit_bytes=(
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
),
include_uuid=False,
include_default_squads=False,
)
panel_update_success = await self.panel_service.update_user_details_on_panel(
panel_uuid,
panel_update_payload,
)
if not panel_update_success:
logging.warning(
f"Panel expiry update failed for {panel_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}." # noqa: E501
)
logging.info(
f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date_obj}." # noqa: E501
)
return new_end_date_obj
else:
logging.error(f"Failed to update subscription end date locally for user {user_id}.")
return None
async def get_active_subscription_details(
self, session: AsyncSession, user_id: int
) -> Optional[Dict[str, Any]]:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
logging.info(
f"User {user_id} not found in DB or no panel_user_uuid for 'my_subscription'."
)
return None
panel_user_uuid = db_user.panel_user_uuid
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
if not panel_user_data:
logging.warning(
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501
)
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
return None
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
if (
panel_lifetime_used is not None
and db_user.lifetime_used_traffic_bytes != panel_lifetime_used
):
await user_dal.update_user(
session,
user_id,
{"lifetime_used_traffic_bytes": panel_lifetime_used},
)
if local_active_sub:
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_sub_uuid_from_panel = panel_user_data.get(
"subscriptionUuid"
) or panel_user_data.get("shortUuid")
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(microsecond=0) != panel_expire_dt.replace(
microsecond=0
):
update_payload_local["end_date"] = panel_expire_dt
update_payload_local["last_notification_sent"] = None
if (
panel_traffic_used is not None
and local_active_sub.traffic_used_bytes != panel_traffic_used
):
update_payload_local["traffic_used_bytes"] = panel_traffic_used
if (
panel_traffic_limit is not None
and local_active_sub.traffic_limit_bytes != panel_traffic_limit
):
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
):
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
)
if local_active_sub.is_active != is_active_based_on_panel:
update_payload_local["is_active"] = is_active_based_on_panel
if update_payload_local:
await subscription_dal.update_subscription(
session, local_active_sub.subscription_id, update_payload_local
)
panel_end_date = (
datetime.fromisoformat(panel_user_data["expireAt"].replace("Z", "+00:00"))
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)
)
config_link_raw = panel_user_data.get("subscriptionUrl")
display_link, connect_button_url = await prepare_config_links(
self.settings, config_link_raw
)
hwid_limit = panel_user_data.get("hwidDeviceLimit")
if hwid_limit is None:
if local_active_sub and local_active_sub.hwid_device_limit is not None:
hwid_limit = self._effective_hwid_limit(
local_active_sub.hwid_device_limit,
int(local_active_sub.extra_hwid_devices or 0),
)
else:
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
tariff = None
if local_active_sub and local_active_sub.tariff_key and self._tariffs_config():
try:
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")
)
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
)
return {
"user_id": panel_user_data.get("uuid"),
"end_date": panel_end_date,
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": display_link,
"connect_button_url": connect_button_url,
"traffic_limit_bytes": panel_traffic_limit,
"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,
"billing_model": billing_model_display,
"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,
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_used_bytes": local_active_sub.premium_used_bytes if local_active_sub else 0,
"premium_bonus_bytes": premium_bonus_bytes,
"premium_unlimited_override": premium_unlimited_override,
"premium_limit_bytes": self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
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_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,
"user_bot_username": db_user.username,
"is_panel_data": True,
"max_devices": hwid_limit,
}
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
)
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
)
results.append(
{
"user_id": 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"),
"days_left": max(0, int(round(days_left))),
"subscription_end_date_iso_for_update": sub_model.end_date,
}
)
return results
@@ -0,0 +1,344 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class PanelIdentityMixin:
def _extract_panel_traffic_details(
self, panel_user_data: Dict[str, Any]
) -> Tuple[Optional[int], Optional[int], Optional[str]]:
traffic_stats = panel_user_data.get("userTraffic") or {}
used = traffic_stats.get("usedTrafficBytes")
if used is None:
used = panel_user_data.get("usedTrafficBytes")
limit = panel_user_data.get("trafficLimitBytes")
strategy = panel_user_data.get("trafficLimitStrategy")
if strategy is None:
strategy = traffic_stats.get("trafficLimitStrategy")
return used, limit, strategy
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:
lifetime = panel_user_data.get("lifetimeUsedTrafficBytes")
try:
if lifetime is None:
return None
return int(lifetime)
except (TypeError, ValueError):
return None
async def _notify_admin_panel_user_creation_failed(self, user_id: int):
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_adm = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw)
msg = _adm("admin_panel_user_creation_failed", user_id=user_id)
for admin_id in self.settings.ADMIN_IDS:
try:
await self.bot.send_message(admin_id, msg)
except Exception as e:
logging.error(
f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
)
def _telegram_id_for_panel(self, db_user: User) -> Optional[int]:
if db_user.telegram_id:
return int(db_user.telegram_id)
if db_user.user_id and int(db_user.user_id) > 0:
return int(db_user.user_id)
return None
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}"
referral_code = await user_dal.ensure_referral_code(session, db_user)
return f"em_{referral_code}"
def _panel_description_for_user(self, db_user: User) -> str:
lines = [
db_user.email or "",
db_user.username or "",
db_user.first_name or "",
db_user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"description": self._panel_description_for_user(db_user),
}
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id:
payload["telegramId"] = telegram_id
if db_user.email:
payload["email"] = db_user.email
return payload
async def _get_or_create_panel_user_link_details(
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(
f"_get_or_create_panel_user_link_details: User {user_id} not found in local DB. Cannot proceed." # noqa: E501
)
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)
telegram_id_for_panel = self._telegram_id_for_panel(db_user)
panel_user_obj_from_api = None
panel_user_created_or_linked_now = False
panel_users_by_tg_id_list = None
if telegram_id_for_panel:
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
telegram_id=telegram_id_for_panel
)
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
panel_user_obj_from_api = panel_users_by_tg_id_list[0]
logging.info(
f"Found panel user by telegramId {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" # noqa: E501
)
elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1:
logging.error(
f"CRITICAL: Multiple panel users found for telegramId {telegram_id_for_panel}. Manual intervention needed." # noqa: E501
)
return None, None, None, False
if not panel_user_obj_from_api and db_user.email:
panel_users_by_email_list = await self.panel_service.get_users_by_filter(
email=db_user.email
)
if panel_users_by_email_list and len(panel_users_by_email_list) == 1:
panel_user_obj_from_api = panel_users_by_email_list[0]
logging.info(
f"Found panel user by email {db_user.email}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" # noqa: E501
)
elif panel_users_by_email_list and len(panel_users_by_email_list) > 1:
logging.error(
f"CRITICAL: Multiple panel users found for email {db_user.email}. Manual intervention needed." # noqa: E501
)
return None, None, None, False
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." # noqa: E501
)
panel_user_obj_from_api = await self.panel_service.get_user_by_uuid(
current_local_panel_uuid
)
if not panel_user_obj_from_api:
logging.warning(
f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced." # noqa: E501
)
logging.info(
f"Creating new panel user '{panel_username_on_panel_standard}' for TG user {user_id}." # noqa: E501
)
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=telegram_id_for_panel,
email=db_user.email,
description=self._panel_description_for_user(db_user),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
)
if (
creation_response
and not creation_response.get("error")
and creation_response.get("response")
):
panel_user_obj_from_api = creation_response.get("response")
panel_user_created_or_linked_now = True
else:
await self._notify_admin_panel_user_creation_failed(user_id)
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}'." # noqa: E501
)
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=telegram_id_for_panel,
email=db_user.email,
description=self._panel_description_for_user(db_user),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
)
if (
creation_response
and not creation_response.get("error")
and creation_response.get("response")
):
panel_user_obj_from_api = creation_response.get("response")
panel_user_created_or_linked_now = True
elif creation_response and creation_response.get("errorCode") == "A019":
logging.warning(
f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username." # noqa: E501
)
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]
if not panel_user_obj_from_api:
logging.error(
f"Failed to create or link panel user for TG_ID {user_id} with panel username '{panel_username_on_panel_standard}'. Response: {creation_response if 'creation_response' in locals() else 'N/A'}" # noqa: E501
)
await self._notify_admin_panel_user_creation_failed(user_id)
return None, None, None, False
if not panel_user_obj_from_api:
logging.error(
f"Could not obtain panel user object for TG user {user_id} after all checks."
)
return (
current_local_panel_uuid if current_local_panel_uuid else None,
None,
None,
panel_user_created_or_linked_now,
)
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
if not actual_panel_uuid_from_api:
logging.error(
f"Panel user object for TG user {user_id} does not contain 'uuid'. Data: {panel_user_obj_from_api}" # noqa: E501
)
return (
current_local_panel_uuid,
None,
None,
panel_user_created_or_linked_now,
)
needs_local_panel_uuid_update = False
if current_local_panel_uuid is None and actual_panel_uuid_from_api:
needs_local_panel_uuid_update = True
elif (
current_local_panel_uuid is not None
and current_local_panel_uuid != actual_panel_uuid_from_api
):
logging.warning(
f"Local panel_uuid for user {user_id} ('{current_local_panel_uuid}') "
f"differs from panel's UUID ('{actual_panel_uuid_from_api}') for their telegramId. "
f"Will attempt to update local to panel's version."
)
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
)
if conflicting_user_record and conflicting_user_record.user_id != user_id:
logging.error(
f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) " # noqa: E501
f"is ALREADY LINKED in local DB to a different TG User {conflicting_user_record.user_id}. " # noqa: E501
f"Cannot update panel_user_uuid for user {user_id}. Manual data correction needed." # noqa: E501
)
return None, None, None, False
else:
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.
await user_dal.update_user(session, user_id, update_data_for_local_user)
db_user.panel_user_uuid = actual_panel_uuid_from_api
panel_user_created_or_linked_now = True
current_local_panel_uuid = actual_panel_uuid_from_api
else:
pass
panel_telegram_id_int = None
if panel_telegram_id_from_api is not None:
try:
panel_telegram_id_int = int(panel_telegram_id_from_api)
except ValueError:
pass
if (
panel_user_obj_from_api
and current_local_panel_uuid
and telegram_id_for_panel
and panel_telegram_id_int != telegram_id_for_panel
):
logging.info(
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'." # noqa: E501
)
await self.panel_service.update_user_details_on_panel(
current_local_panel_uuid,
self._panel_identity_payload_for_user(db_user),
)
panel_sub_link_id = panel_user_obj_from_api.get(
"subscriptionUuid"
) or panel_user_obj_from_api.get("shortUuid")
panel_short_uuid = panel_user_obj_from_api.get("shortUuid")
if not panel_sub_link_id and current_local_panel_uuid:
logging.warning(
f"No subscriptionUuid or shortUuid found on panel for panel_user_uuid {current_local_panel_uuid} (TG ID: {user_id})." # noqa: E501
)
return (
current_local_panel_uuid,
panel_sub_link_id,
panel_short_uuid,
panel_user_created_or_linked_now,
)
def _build_panel_update_payload(
self,
*,
panel_user_uuid: Optional[str] = None,
expire_at: Optional[datetime] = None,
status: Optional[str] = None,
traffic_limit_bytes: Optional[int] = None,
include_uuid: bool = True,
traffic_limit_strategy: Optional[str] = None,
hwid_device_limit: Optional[int] = None,
include_default_squads: bool = True,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {}
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"
)
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
)
if hwid_device_limit is not None:
try:
hwid_limit_int = int(hwid_device_limit)
if hwid_limit_int >= 0:
payload["hwidDeviceLimit"] = hwid_limit_int
except (TypeError, ValueError):
pass
if include_default_squads:
if self.settings.parsed_user_squad_uuids:
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
if self.settings.parsed_user_external_squad_uuid:
payload["externalSquadUuid"] = self.settings.parsed_user_external_squad_uuid
return payload
@@ -0,0 +1,111 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class PaymentContextMixin:
async def _record_payment_context(
self,
session: AsyncSession,
payment_db_id: int,
*,
sale_mode: str,
tariff_key: Optional[str],
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
) -> None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
return
payment.sale_mode = sale_mode
payment.tariff_key = tariff_key
payment.purchased_gb = purchased_gb
payment.purchased_hwid_devices = purchased_hwid_devices
await session.flush()
async def get_user_language(self, session: AsyncSession, user_id: int) -> str:
user_record = await user_dal.get_user_by_id(session, user_id)
return (
user_record.language_code
if user_record and user_record.language_code
else self.settings.DEFAULT_LANGUAGE
)
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
return await subscription_dal.has_any_subscription_for_user(session, user_id)
async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool:
"""Return True if user currently has an active subscription (end_date in future)."""
try:
user_record = await user_dal.get_user_by_id(session, user_id)
if not user_record or not user_record.panel_user_uuid:
return False
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, user_record.panel_user_uuid
)
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
async def _send_payment_success_email(
self,
*,
db_user: User,
sale_mode: str,
months: int,
traffic_gb: Optional[float],
payment_amount: float,
end_date: Optional[datetime],
provider: str,
) -> None:
"""Best-effort branded email confirming the payment. No-op if SMTP or
the user's email aren't set. Failures are logged and swallowed so the
payment flow is never blocked by mail delivery."""
if not self.settings.email_auth_configured:
return
recipient = (db_user.email or "").strip() if db_user else ""
if not recipient:
return
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
try:
content = render_payment_success(
self.settings,
language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE,
sale_mode=sale_mode,
months=int(months or 0),
traffic_gb=traffic_gb,
amount=float(payment_amount or 0),
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
end_date_text=end_date_text,
dashboard_url=dashboard_url,
provider_label=provider_label,
)
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)
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
)
if sub_to_update:
await subscription_dal.update_subscription_notification_time(
session, sub_to_update.subscription_id, datetime.now(timezone.utc)
)
logging.info(
f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}" # noqa: E501
)
else:
logging.warning(
f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time." # noqa: E501
)
@@ -0,0 +1,67 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class RenewalMixin:
async def charge_subscription_renewal(
self,
session: AsyncSession,
sub: Subscription,
) -> bool:
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure.""" # noqa: E501
if getattr(self.settings, "traffic_sale_mode", False):
logging.info("Auto-renew skipped: traffic sale mode enabled")
return True
if not sub.auto_renew_enabled:
return True
# If autopayments are disabled globally, skip charging attempts
if not 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
)
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}")
return False
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):
logging.warning("YooKassa unavailable for auto-renew")
return False
months = sub.duration_months or 1
amount = self.settings.subscription_options.get(months)
if not amount:
logging.error(f"Auto-renew price missing for {months} months")
return False
metadata = {
"user_id": str(sub.user_id),
"auto_renew_for_subscription_id": str(sub.subscription_id),
"subscription_months": str(months),
}
resp = await yk.create_payment(
amount=float(amount),
currency="RUB",
description=f"Auto-renewal for {months} months",
metadata=metadata,
payment_method_id=default_pm.provider_payment_method_id,
save_payment_method=False,
capture=True,
)
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
logging.error(f"Auto-renew create_payment failed: {resp}")
return False
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
return True
@@ -0,0 +1,369 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class TariffMixin:
@staticmethod
def gb_to_bytes(gb: float) -> int:
return int(float(gb) * (1024**3))
@staticmethod
def _far_future() -> datetime:
return datetime(2099, 1, 1, tzinfo=timezone.utc)
def _parse_sale_mode_context(
self,
sale_mode: str,
explicit_tariff_key: Optional[str] = None,
) -> Tuple[str, Optional[str]]:
mode = (sale_mode or "subscription").strip()
tariff_key = explicit_tariff_key
for separator in ("@", "|"):
if separator in mode:
base, suffix = mode.split(separator, 1)
mode = base or mode
tariff_key = tariff_key or suffix or None
break
return mode, tariff_key
def _tariffs_config(self):
return getattr(self.settings, "tariffs_config", None)
def _default_tariff(self) -> Optional[Tariff]:
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]:
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}"
)
return tariff
def _panel_squads_for_tariff(
self,
tariff: Optional[Tariff],
*,
include_premium: bool = True,
) -> Optional[List[str]]:
if tariff:
squads = list(tariff.squad_uuids or [])
if include_premium:
squads.extend(tariff.premium_squad_uuids or [])
return list(dict.fromkeys(squads))
return self.settings.parsed_user_squad_uuids
def _traffic_limit_for_period_tariff(
self,
tariff: Optional[Tariff],
topup_balance_bytes: int = 0,
regular_bonus_bytes: int = 0,
regular_unlimited_override: bool = False,
traffic_used_bytes: int = 0,
) -> int:
if tariff:
baseline = int(tariff.monthly_bytes or 0)
else:
baseline = int(self.settings.user_traffic_limit_bytes)
return self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=baseline,
topup_balance_bytes=topup_balance_bytes,
regular_bonus_bytes=regular_bonus_bytes,
regular_unlimited_override=regular_unlimited_override,
traffic_used_bytes=traffic_used_bytes,
)
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))
@staticmethod
def _premium_effective_limit_bytes(
premium_baseline_bytes: int,
premium_topup_balance_bytes: int = 0,
premium_topup_used_bytes: int = 0,
premium_bonus_bytes: int = 0,
) -> int:
return (
int(premium_baseline_bytes or 0)
+ max(0, int(premium_topup_balance_bytes or 0))
+ max(0, int(premium_topup_used_bytes or 0))
+ max(0, int(premium_bonus_bytes or 0))
)
def _compute_main_traffic_limit_bytes(
self,
*,
tier_baseline_bytes: int,
topup_balance_bytes: int,
regular_bonus_bytes: int,
regular_unlimited_override: bool,
traffic_used_bytes: int,
) -> int:
"""Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" # noqa: E501
floor = (
int(tier_baseline_bytes or 0)
+ max(0, int(topup_balance_bytes or 0))
+ max(0, int(regular_bonus_bytes or 0))
)
if regular_unlimited_override:
used = max(0, int(traffic_used_bytes or 0))
return max(floor, used + 512 * (1024**3), 1024**5)
return floor
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
if not tariff or not tariff.premium_squad_uuids:
return {"squad_uuids": [], "squad_labels": [], "node_labels": []}
cache_key = tuple(sorted(str(uuid) for uuid in tariff.premium_squad_uuids))
now_ts = datetime.now(timezone.utc).timestamp()
cached = self._premium_access_cache.get(cache_key)
if cached and now_ts - float(cached.get("ts", 0)) < 600:
return {
"squad_uuids": list(cached.get("squad_uuids") or []),
"squad_labels": list(cached.get("squad_labels") or []),
"node_labels": list(cached.get("node_labels") or []),
}
def _extract_inbound_uuids(squad_obj: Dict[str, Any]) -> List[str]:
collected: List[str] = []
for field in ("inbounds", "internalInbounds", "configProfileInbounds"):
value = squad_obj.get(field)
if not isinstance(value, list):
continue
for inbound in value:
if isinstance(inbound, dict):
ib_uuid = str(
inbound.get("uuid")
or inbound.get("inboundUuid")
or inbound.get("id")
or ""
)
else:
ib_uuid = str(inbound or "")
if ib_uuid:
collected.append(ib_uuid)
return collected
squad_name_map: Dict[str, str] = {}
squad_inbound_map: Dict[str, List[str]] = {}
try:
squads = await self.panel_service.get_internal_squads() or []
for squad in squads:
if not isinstance(squad, dict):
continue
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_inbound_map[squad_uuid] = _extract_inbound_uuids(squad)
except Exception:
logging.debug("Failed to load internal squad names for premium display", exc_info=True)
for squad_uuid in tariff.premium_squad_uuids:
squad_uuid_str = str(squad_uuid)
if squad_inbound_map.get(squad_uuid_str):
continue
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
)
detail = None
if isinstance(detail, dict):
squad_inbound_map[squad_uuid_str] = _extract_inbound_uuids(detail)
if squad_uuid_str not in squad_name_map:
squad_name_map[squad_uuid_str] = str(
detail.get("name") or detail.get("title") or squad_uuid_str
)
hosts_by_inbound: Dict[str, List[Dict[str, Any]]] = {}
try:
hosts = await self.panel_service.get_hosts() or []
for host in hosts:
if not isinstance(host, dict):
continue
inbound_field = host.get("inbound") if isinstance(host.get("inbound"), dict) else {}
inbound_uuid = (
host.get("inboundUuid")
or host.get("inbound_uuid")
or host.get("configProfileInboundUuid")
or inbound_field.get("configProfileInboundUuid")
or inbound_field.get("inboundUuid")
or inbound_field.get("uuid")
or ""
)
inbound_uuid = str(inbound_uuid)
if not inbound_uuid:
continue
hosts_by_inbound.setdefault(inbound_uuid, []).append(host)
logging.debug(
"Premium label resolution: %d hosts grouped across %d inbounds; squad inbound map: %s", # noqa: E501
len(hosts),
len(hosts_by_inbound),
{k: len(v) for k, v in squad_inbound_map.items()},
)
except Exception:
logging.debug("Failed to load hosts for premium display", exc_info=True)
def _host_remark(host: Dict[str, Any]) -> str:
for key in ("remark", "name", "label", "title"):
value = host.get(key)
if value is None:
continue
candidate = str(value).strip()
if candidate:
return candidate
return ""
node_labels: List[str] = []
for squad_uuid in tariff.premium_squad_uuids:
squad_uuid_str = str(squad_uuid)
inbound_uuids = squad_inbound_map.get(squad_uuid_str) or []
host_labels_for_squad: List[str] = []
for inbound_uuid in inbound_uuids:
for host in hosts_by_inbound.get(inbound_uuid, []):
remark = _host_remark(host)
if remark:
host_labels_for_squad.append(remark)
if host_labels_for_squad:
node_labels.extend(host_labels_for_squad)
continue
try:
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,
)
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_name = ""
for key in (
"nodeName",
"name",
"nodeRemark",
"remark",
"label",
"title",
"address",
"host",
):
value = node.get(key)
if value is None:
continue
candidate = str(value).strip()
if candidate:
node_name = candidate
break
if node_name:
label = node_name
elif node_uuid:
label = f"{node_uuid[:8]}..."
else:
continue
node_labels.append(label)
squad_labels = [
squad_name_map.get(str(uuid), f"{str(uuid)[:8]}...")
for uuid in tariff.premium_squad_uuids
]
payload = {
"ts": now_ts,
"squad_uuids": list(tariff.premium_squad_uuids),
"squad_labels": list(dict.fromkeys(squad_labels)),
"node_labels": list(dict.fromkeys(node_labels)),
}
self._premium_access_cache[cache_key] = payload
return {
"squad_uuids": list(payload["squad_uuids"]),
"squad_labels": list(payload["squad_labels"]),
"node_labels": list(payload["node_labels"]),
}
def _base_hwid_limit_for_tariff(self, tariff: Optional[Tariff]) -> Optional[int]:
if tariff and tariff.hwid_device_limit is not None:
return int(tariff.hwid_device_limit)
value = self.settings.USER_HWID_DEVICE_LIMIT
return int(value) if value is not None else None
@staticmethod
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
if base_limit is None:
return None
base_int = max(0, int(base_limit))
if base_int == 0:
return 0
return base_int + max(0, int(extra_devices or 0))
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
)
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
)
return {
"mode": "period_to_period",
"remaining_days": remaining_days,
"recalc_days": max(0, days_after),
"paid_diff_rub": paid_diff,
"target_monthly_rub": float(target_monthly),
}
if current_model == "period" and target_tariff.billing_model == "traffic":
rub_per_gb = target_tariff.rub_per_gb_for_conversion()
remaining_value = remaining_days * (effective / 30) if effective else 0
converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0
return {
"mode": "period_to_traffic",
"remaining_days": remaining_days,
"converted_gb": max(0, converted_gb),
"rub_per_gb": rub_per_gb,
}
return {"mode": "traffic_to_period", "remaining_days": remaining_days}
@@ -0,0 +1,693 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class TrafficMixin:
async def _activate_traffic_package(
self,
session: AsyncSession,
user_id: int,
traffic_gb: float,
payment_amount: float,
payment_db_id: int,
provider: str = "yookassa",
tariff_key: Optional[str] = None,
sale_mode: str = "traffic",
) -> Optional[Dict[str, Any]]:
"""Activate or extend a traffic-based package instead of a time-based subscription."""
tariff = self._resolve_tariff(tariff_key, "traffic") if self._tariffs_config() else None
await self._record_payment_context(
session,
payment_db_id,
sale_mode=sale_mode,
tariff_key=tariff.key if tariff else tariff_key,
purchased_gb=float(traffic_gb),
)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error("User %s not found for traffic package activation", user_id)
return None
(
panel_user_uuid,
panel_sub_link_id,
panel_short_uuid,
_,
) = await self._get_or_create_panel_user_link_details(session, user_id, db_user)
if not panel_user_uuid or not panel_sub_link_id:
logging.error(
"Failed to ensure panel linkage for user %s during traffic activation", user_id
)
return None
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data)
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
if current_limit is None and active_sub:
current_limit = active_sub.traffic_limit_bytes
if current_used is None and active_sub:
current_used = active_sub.traffic_used_bytes
purchase_bytes = self.gb_to_bytes(traffic_gb)
extra_hwid_devices = int(getattr(active_sub, "extra_hwid_devices", 0) or 0)
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
remaining_bytes = max(0, int(current_limit or 0) - int(current_used or 0))
new_balance = remaining_bytes + purchase_bytes
new_limit = int(current_used or 0) + new_balance
start_date = datetime.now(timezone.utc)
# Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further. # noqa: E501
far_future = self._far_future()
final_end_date = far_future
if active_sub and active_sub.end_date and active_sub.end_date > final_end_date:
final_end_date = active_sub.end_date
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id
)
sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
"panel_subscription_uuid": panel_sub_link_id,
"start_date": start_date,
"end_date": final_end_date,
"duration_months": 0,
"is_active": True,
"status_from_panel": "ACTIVE",
"traffic_limit_bytes": new_limit,
"traffic_used_bytes": current_used,
"provider": provider,
"skip_notifications": True,
"auto_renew_enabled": False,
"tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": 0,
"topup_balance_bytes": new_balance,
"premium_baseline_bytes": self._premium_limit_for_tariff(tariff, 0),
"premium_topup_balance_bytes": 0,
"premium_topup_used_bytes": 0,
"premium_used_bytes": 0,
"premium_is_limited": False,
"premium_period_start_at": None,
"period_start_at": None,
"is_throttled": False,
"effective_monthly_price_rub": None,
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload)
except Exception as exc:
logging.error(
"Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True
)
return None
panel_update_payload = self._build_panel_update_payload(
panel_user_uuid=panel_user_uuid,
expire_at=final_end_date,
status="ACTIVE",
traffic_limit_bytes=new_limit,
traffic_limit_strategy="NO_RESET",
hwid_device_limit=effective_hwid_limit,
)
if tariff:
panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(tariff)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
)
if not updated_panel_user or updated_panel_user.get("error"):
logging.warning(
"Panel user details update FAILED for traffic package user %s. Response: %s",
panel_user_uuid,
updated_panel_user,
)
return None
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await tariff_dal.create_traffic_topup(
session,
subscription_id=new_or_updated_sub.subscription_id,
payment_id=payment_db_id,
purchased_bytes=purchase_bytes,
kind="traffic_package",
)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="traffic",
months=0,
traffic_gb=float(traffic_gb),
payment_amount=payment_amount,
end_date=None,
provider=provider,
)
return {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
"is_active": True,
"panel_user_uuid": panel_user_uuid,
"panel_short_uuid": final_panel_short_uuid,
"subscription_url": final_subscription_url,
"applied_promo_bonus_days": 0,
"traffic_limit_bytes": new_limit,
"tariff_key": tariff.key if tariff else None,
}
async def activate_topup(
self,
session: AsyncSession,
user_id: int,
tariff_key: str,
traffic_gb: float,
payment_amount: float,
payment_db_id: int,
provider: str = "yookassa",
) -> Optional[Dict[str, Any]]:
tariff = self._resolve_tariff(tariff_key)
if tariff.billing_model == "traffic":
return await self._activate_traffic_package(
session=session,
user_id=user_id,
traffic_gb=traffic_gb,
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
tariff_key=tariff.key,
sale_mode="traffic_package",
)
await self._record_payment_context(
session,
payment_db_id,
sale_mode="topup",
tariff_key=tariff.key,
purchased_gb=float(traffic_gb),
)
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
)
if not sub:
return None
purchase_bytes = self.gb_to_bytes(traffic_gb)
new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes
baseline = int(sub.tier_baseline_bytes or tariff.monthly_bytes)
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0)
new_limit = self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=baseline,
topup_balance_bytes=new_topup_balance,
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
traffic_used_bytes=used_for_lim,
)
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"topup_balance_bytes": new_topup_balance,
"traffic_limit_bytes": new_limit,
"is_throttled": False,
"tariff_key": tariff.key,
"hwid_device_limit": base_hwid_limit,
},
)
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=updated_sub.end_date,
status="ACTIVE",
traffic_limit_bytes=new_limit,
hwid_device_limit=effective_hwid_limit,
)
panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
tariff,
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 tariff_dal.create_traffic_topup(
session,
subscription_id=sub.subscription_id,
payment_id=payment_db_id,
purchased_bytes=purchase_bytes,
kind="topup",
)
return {
"subscription_id": sub.subscription_id,
"traffic_limit_bytes": new_limit,
"topup_balance_bytes": new_topup_balance,
"tariff_key": tariff.key,
}
async def activate_premium_topup(
self,
session: AsyncSession,
user_id: int,
tariff_key: str,
traffic_gb: float,
payment_amount: float,
payment_db_id: int,
provider: str = "yookassa",
) -> 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
)
return None
await self._record_payment_context(
session,
payment_db_id,
sale_mode="premium_topup",
tariff_key=tariff.key,
purchased_gb=float(traffic_gb),
)
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
)
if not sub:
return None
purchase_bytes = self.gb_to_bytes(traffic_gb)
now = datetime.now(timezone.utc)
premium_period_start = month_start(now)
current_period_start = getattr(sub, "premium_period_start_at", None)
same_period = bool(current_period_start and current_period_start == premium_period_start)
previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0
premium_used = int(sub.premium_used_bytes or 0) if same_period else 0
premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0)
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes
overflow_to_cover = max(
0, premium_used - premium_baseline - previous_topup_used - premium_bonus
)
consume_now = min(premium_topup_balance, overflow_to_cover)
premium_topup_balance -= consume_now
premium_topup_used = previous_topup_used + consume_now
premium_limit = self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
premium_bonus,
)
premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False))
premium_is_limited = (
not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit
)
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_used_bytes": premium_used,
"premium_is_limited": premium_is_limited,
"premium_period_start_at": premium_period_start,
"tariff_key": tariff.key,
},
)
panel_payload = {
"uuid": db_user.panel_user_uuid,
"activeInternalSquads": self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
),
}
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,
payment_id=payment_db_id,
purchased_bytes=purchase_bytes,
kind="premium_topup",
)
return {
"subscription_id": sub.subscription_id,
"premium_limit_bytes": premium_limit,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_is_limited": premium_is_limited,
"tariff_key": tariff.key,
}
async def sync_premium_squad_access_to_panel(
self,
session: AsyncSession,
user_id: int,
) -> None:
"""Recompute premium quota flags from DB and push internal squads to Remnawave.
Used when admin overrides change without going through the traffic worker
(Telegram/Web admin premium bonus / unlimited).
"""
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
if not tariff or not getattr(tariff, "premium_squad_uuids", None):
return
premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0)
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) 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)
premium_used = int(sub.premium_used_bytes or 0)
premium_limit = self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
premium_bonus,
)
premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False))
premium_is_limited = (
not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit
)
if bool(getattr(sub, "premium_is_limited", False)) != premium_is_limited:
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{"premium_is_limited": premium_is_limited},
)
squads = self._panel_squads_for_tariff(tariff, include_premium=not premium_is_limited)
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid,
{"uuid": db_user.panel_user_uuid, "activeInternalSquads": squads},
log_response=False,
)
except Exception:
logging.exception(
"sync_premium_squad_access_to_panel: failed to push squads for user %s", user_id
)
async def admin_grant_topup(
self,
session: AsyncSession,
user_id: int,
traffic_gb: float,
) -> Optional[Dict[str, Any]]:
"""Credit regular traffic to a user as if they purchased a top-up.
Mirrors :meth:`activate_topup` but skips payment context and tariff
resolution: the grant simply increases ``topup_balance_bytes`` and
recomputes ``traffic_limit_bytes`` from the subscription's current
tier baseline. The audit row in ``traffic_topups`` is stored with
``kind="admin_topup"`` and ``payment_id=NULL`` so reports stay clean.
"""
try:
gb_value = float(traffic_gb)
except (TypeError, ValueError):
logging.error("admin_grant_topup: invalid traffic_gb=%r", traffic_gb)
return None
if gb_value <= 0:
return None
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
)
if not sub:
return None
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
)
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))
used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0)
new_limit = self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=baseline_bytes,
topup_balance_bytes=new_topup_balance,
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
traffic_used_bytes=used_for_lim,
)
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"topup_balance_bytes": new_topup_balance,
"traffic_limit_bytes": new_limit,
"is_throttled": False,
"hwid_device_limit": base_hwid_limit,
},
)
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=updated_sub.end_date,
status="ACTIVE",
traffic_limit_bytes=new_limit,
hwid_device_limit=effective_hwid_limit,
)
if tariff is not None:
panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
tariff,
include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)),
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
except Exception:
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,
payment_id=None,
purchased_bytes=purchase_bytes,
kind="admin_topup",
)
return {
"subscription_id": sub.subscription_id,
"traffic_limit_bytes": new_limit,
"topup_balance_bytes": new_topup_balance,
"granted_bytes": purchase_bytes,
}
async def sync_main_traffic_limit_to_panel(
self,
session: AsyncSession,
user_id: int,
) -> None:
"""Recompute main traffic limit from tier + topups + regular_bonus_bytes and push to panel.""" # noqa: E501
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
baseline = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0)
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
used_now = int(getattr(sub, "traffic_used_bytes", 0) or 0)
new_limit = self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=baseline,
topup_balance_bytes=int(sub.topup_balance_bytes or 0),
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
traffic_used_bytes=used_now,
)
sub.traffic_limit_bytes = new_limit
if runl:
sub.is_throttled = False
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=sub.end_date,
status="ACTIVE",
traffic_limit_bytes=new_limit,
hwid_device_limit=effective_hwid_limit,
)
if tariff is not None:
panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
tariff,
include_premium=not bool(getattr(sub, "premium_is_limited", False)),
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
except Exception:
logging.exception("sync_main_traffic_limit_to_panel failed for user %s", user_id)
async def admin_grant_premium_topup(
self,
session: AsyncSession,
user_id: int,
traffic_gb: float,
) -> Optional[Dict[str, Any]]:
"""Credit premium-squad traffic to a user as if they purchased a premium top-up.
Mirrors :meth:`activate_premium_topup` but skips payment context.
Requires the user's current tariff to expose premium squads. The
balance is absorbed into ``premium_topup_balance_bytes`` (backfilling
any current overuse first), ``premium_is_limited`` is recomputed and,
if access becomes available again, the premium squads are returned to
the user on the panel. The audit row in ``traffic_topups`` is stored
with ``kind="admin_premium_topup"`` and ``payment_id=NULL``.
"""
try:
gb_value = float(traffic_gb)
except (TypeError, ValueError):
logging.error("admin_grant_premium_topup: invalid traffic_gb=%r", traffic_gb)
return None
if gb_value <= 0:
return None
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
)
if not sub:
return None
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
if not tariff or not tariff.premium_squad_uuids:
logging.error(
"admin_grant_premium_topup: tariff %s has no premium squads (user %s)",
getattr(tariff, "key", None),
user_id,
)
return None
purchase_bytes = self.gb_to_bytes(gb_value)
now = datetime.now(timezone.utc)
premium_period_start = month_start(now)
current_period_start = getattr(sub, "premium_period_start_at", None)
same_period = bool(current_period_start and current_period_start == premium_period_start)
previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0
premium_used = int(sub.premium_used_bytes or 0) if same_period else 0
premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0)
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes
overflow_to_cover = max(
0, premium_used - premium_baseline - previous_topup_used - premium_bonus
)
consume_now = min(premium_topup_balance, overflow_to_cover)
premium_topup_balance -= consume_now
premium_topup_used = previous_topup_used + consume_now
premium_limit = self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
premium_bonus,
)
premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False))
premium_is_limited = (
not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit
)
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_used_bytes": premium_used,
"premium_is_limited": premium_is_limited,
"premium_period_start_at": premium_period_start,
},
)
panel_payload = {
"uuid": db_user.panel_user_uuid,
"activeInternalSquads": self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
),
}
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
except Exception:
logging.exception(
"admin_grant_premium_topup: failed to push panel update for user %s",
user_id,
)
await tariff_dal.create_traffic_topup(
session,
subscription_id=sub.subscription_id,
payment_id=None,
purchased_bytes=purchase_bytes,
kind="admin_premium_topup",
)
return {
"subscription_id": sub.subscription_id,
"premium_limit_bytes": premium_limit,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_is_limited": premium_is_limited,
"granted_bytes": purchase_bytes,
}
@@ -0,0 +1,119 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class TrialSubscriptionMixin:
async def activate_trial_subscription(
self, session: AsyncSession, user_id: int
) -> Optional[Dict[str, Any]]:
if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0:
return {
"eligible": False,
"activated": False,
"message_key": "trial_feature_disabled",
}
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, cannot activate trial.")
return {
"eligible": False,
"activated": False,
"message_key": "user_not_found_for_trial",
}
if await self.has_had_any_subscription(session, user_id):
return {
"eligible": False,
"activated": False,
"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)
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}.")
return {
"eligible": True,
"activated": False,
"message_key": "trial_activation_failed_panel_link",
}
start_date = datetime.now(timezone.utc)
end_date = start_date + timedelta(days=self.settings.TRIAL_DURATION_DAYS)
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id
)
trial_sub_data = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
"panel_subscription_uuid": panel_sub_link_id,
"start_date": start_date,
"end_date": end_date,
"duration_months": 0,
"is_active": True,
"status_from_panel": "TRIAL",
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY,
"auto_renew_enabled": False,
}
try:
await subscription_dal.upsert_subscription(session, trial_sub_data)
except Exception as e_upsert:
logging.error(
f"Failed to upsert trial subscription for user {user_id}: {e_upsert}",
exc_info=True,
)
await session.rollback()
return {
"eligible": True,
"activated": False,
"message_key": "trial_activation_failed_db",
}
panel_update_payload = self._build_panel_update_payload(
panel_user_uuid=panel_user_uuid,
expire_at=end_date,
status="ACTIVE",
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
)
if not updated_panel_user or updated_panel_user.get("error"):
logging.warning(
f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}" # noqa: E501
)
await session.rollback()
return {
"eligible": True,
"activated": False,
"message_key": "trial_activation_failed_panel_update",
}
await session.commit()
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
return {
"eligible": True,
"activated": True,
"end_date": end_date,
"days": self.settings.TRIAL_DURATION_DAYS,
"traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
"panel_user_uuid": panel_user_uuid,
"panel_short_uuid": final_panel_short_uuid,
"subscription_url": final_subscription_url,
}
+1 -1
View File
@@ -445,7 +445,7 @@ class TariffTrafficWorker:
ratio = used_val / limit_val
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
# Fully exhausted or over quota — one message per period (same idea as regular traffic at 100%).
# Fully exhausted or over quota — one message per period (same idea as regular traffic at 100%). # noqa: E501
if ratio >= 1.0:
depleted_existing = await tariff_dal.get_warning(
session,
+7 -7
View File
@@ -25,7 +25,7 @@ class YooKassaService:
if self.settings and not self.settings.YOOKASSA_ENABLED:
logging.warning(
"YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED."
"YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501
)
self.configured = False
elif not shop_id or not secret_key:
@@ -48,7 +48,7 @@ class YooKassaService:
elif bot_username_for_default_return:
self.return_url = f"https://t.me/{bot_username_for_default_return}"
logging.info(
f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}"
f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" # noqa: E501
)
else:
self.return_url = "https://example.com/payment_error_no_return_url_configured"
@@ -77,7 +77,7 @@ class YooKassaService:
if not self.settings:
logging.error(
"YooKassaService: Settings object not available. Cannot create payment with receipt details."
"YooKassaService: Settings object not available. Cannot create payment with receipt details." # noqa: E501
)
return {
"error": True,
@@ -93,11 +93,11 @@ class YooKassaService:
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."
"CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." # noqa: E501
)
return {
"error": True,
"internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.",
"internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.", # noqa: E501
}
try:
@@ -163,7 +163,7 @@ class YooKassaService:
)
logging.info(
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" # noqa: E501
)
return {
@@ -203,7 +203,7 @@ class YooKassaService:
if payment_info_yk:
logging.info(
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" # noqa: E501
)
pm = getattr(payment_info_yk, "payment_method", None)
pm_payload: Dict[str, Any] = {}