refactor: split backend domains and add API behavior coverage
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;"> </td>'
|
||||
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
|
||||
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </td>'
|
||||
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </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;"> </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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
Reference in New Issue
Block a user