feat: premium squads inside one tariff
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import hashlib
|
||||
import hashlib
|
||||
import logging
|
||||
import json
|
||||
import hmac
|
||||
@@ -91,7 +91,7 @@ class CryptoPayService:
|
||||
"provider": "cryptopay",
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
||||
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -107,7 +107,7 @@ class CryptoPayService:
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": str(months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
"traffic_gb": str(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
})
|
||||
try:
|
||||
invoice = await self.client.create_invoice(
|
||||
@@ -184,7 +184,7 @@ class CryptoPayService:
|
||||
payment_db_id,
|
||||
provider="cryptopay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
)
|
||||
referral_bonus = None
|
||||
if sale_base == "subscription":
|
||||
@@ -215,7 +215,7 @@ class CryptoPayService:
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
if sale_base in {"traffic", "traffic_package", "topup"}:
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
text = _("payment_successful_traffic_full",
|
||||
traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}",
|
||||
end_date=final_end.strftime('%Y-%m-%d') if final_end else "—",
|
||||
@@ -271,7 +271,7 @@ class CryptoPayService:
|
||||
amount=float(invoice.amount),
|
||||
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=int(months) if sale_base == "subscription" else 0,
|
||||
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
payment_provider="crypto_pay",
|
||||
username=user.username if user else None
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
@@ -286,7 +286,7 @@ class FreeKassaService:
|
||||
payment.payment_id,
|
||||
provider="freekassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
@@ -331,7 +331,7 @@ class FreeKassaService:
|
||||
|
||||
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
|
||||
if sale_mode.split("@", 1)[0].split("|", 1)[0] in {"traffic", "traffic_package", "topup"}:
|
||||
if sale_mode.split("@", 1)[0].split("|", 1)[0] in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
text = _("payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date_str if final_end else "",
|
||||
|
||||
@@ -564,6 +564,76 @@ class PanelApiService:
|
||||
logging.error("Failed to get bandwidth stats for user %s. Response: %s", user_uuid, response_data)
|
||||
return None
|
||||
|
||||
async def get_node_users_bandwidth_stats(
|
||||
self,
|
||||
node_uuid: str,
|
||||
*,
|
||||
start: str,
|
||||
end: str,
|
||||
top_users_limit: int = 10000,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
endpoint = f"/bandwidth-stats/nodes/{node_uuid}/users"
|
||||
response_data = await self._request(
|
||||
"GET",
|
||||
endpoint,
|
||||
params={"start": start, "end": end, "topUsersLimit": top_users_limit},
|
||||
log_full_response=False,
|
||||
)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
response = response_data.get("response")
|
||||
if isinstance(response, dict):
|
||||
return response
|
||||
if isinstance(response, list):
|
||||
return {"topUsers": response}
|
||||
logging.error(
|
||||
"Failed to get node bandwidth stats for node %s. Response: %s",
|
||||
node_uuid,
|
||||
response_data,
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
|
||||
response_data = await self._request("GET", "/internal-squads", log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
response = response_data.get("response")
|
||||
if isinstance(response, list):
|
||||
return response
|
||||
if isinstance(response, dict):
|
||||
for key in ("internalSquads", "squads", "items", "data"):
|
||||
value = response.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
logging.error("Failed to get internal squads. Response: %s", response_data)
|
||||
return None
|
||||
|
||||
async def get_internal_squad_accessible_nodes(
|
||||
self,
|
||||
squad_uuid: str,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
endpoints = (
|
||||
f"/internal-squads/{squad_uuid}/accessible-nodes",
|
||||
f"/internal-squads/{squad_uuid}/nodes",
|
||||
)
|
||||
last_response = None
|
||||
for endpoint in endpoints:
|
||||
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||
last_response = response_data
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
response = response_data.get("response")
|
||||
if isinstance(response, list):
|
||||
return response
|
||||
if isinstance(response, dict):
|
||||
for key in ("nodes", "accessibleNodes", "items", "data"):
|
||||
value = response.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
logging.error(
|
||||
"Failed to get accessible nodes for internal squad %s. Response: %s",
|
||||
squad_uuid,
|
||||
last_response,
|
||||
)
|
||||
return None
|
||||
|
||||
async def reset_user_traffic(self, user_uuid: str) -> bool:
|
||||
endpoint = f"/users/{user_uuid}/actions/reset-traffic"
|
||||
response_data = await self._request("POST", endpoint, log_full_response=False)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hmac
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
@@ -214,7 +214,7 @@ class PlategaService:
|
||||
payment.payment_id,
|
||||
provider="platega",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
@@ -250,7 +250,7 @@ class PlategaService:
|
||||
|
||||
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||
|
||||
if sale_base in {"traffic", "traffic_package", "topup"}:
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
text = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import hmac
|
||||
@@ -210,7 +210,7 @@ class SeverPayService:
|
||||
payment.payment_id,
|
||||
provider="severpay",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
@@ -246,7 +246,7 @@ class SeverPayService:
|
||||
|
||||
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
|
||||
|
||||
if sale_base in {"traffic", "traffic_package", "topup"}:
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
text = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import logging
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot, types
|
||||
@@ -39,7 +39,7 @@ class StarsService:
|
||||
"provider": "telegram_stars",
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
||||
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
}
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(
|
||||
@@ -103,7 +103,7 @@ class StarsService:
|
||||
payment_db_id,
|
||||
provider="telegram_stars",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
)
|
||||
if not activation_details or not activation_details.get("end_date"):
|
||||
logging.error(
|
||||
@@ -136,7 +136,7 @@ class StarsService:
|
||||
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
if sale_base in {"traffic", "traffic_package", "topup"}:
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
success_msg = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}",
|
||||
@@ -201,7 +201,7 @@ class StarsService:
|
||||
months=int(months) if sale_base == "subscription" else 0,
|
||||
payment_provider="stars",
|
||||
username=user.username if user else None,
|
||||
traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup"} else None,
|
||||
traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send stars payment notification: {e}")
|
||||
|
||||
@@ -8,7 +8,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, tariff_dal
|
||||
from config.tariffs_config import Tariff
|
||||
from bot.utils.date_utils import add_months
|
||||
from bot.utils.date_utils import add_months, month_start
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from db.models import User, Subscription
|
||||
|
||||
@@ -31,6 +31,7 @@ class SubscriptionService:
|
||||
self.panel_service = panel_service
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self._premium_access_cache: Dict[Tuple[str, ...], Dict[str, Any]] = {}
|
||||
|
||||
@staticmethod
|
||||
def gb_to_bytes(gb: float) -> int:
|
||||
@@ -71,9 +72,17 @@ class SubscriptionService:
|
||||
raise ValueError(f"Tariff {tariff.key} is {tariff.billing_model}, expected {billing_model}")
|
||||
return tariff
|
||||
|
||||
def _panel_squads_for_tariff(self, tariff: Optional[Tariff]) -> Optional[List[str]]:
|
||||
def _panel_squads_for_tariff(
|
||||
self,
|
||||
tariff: Optional[Tariff],
|
||||
*,
|
||||
include_premium: bool = True,
|
||||
) -> Optional[List[str]]:
|
||||
if tariff:
|
||||
return tariff.squad_uuids
|
||||
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) -> int:
|
||||
@@ -81,6 +90,85 @@ class SubscriptionService:
|
||||
return int(tariff.monthly_bytes + max(0, topup_balance_bytes))
|
||||
return self.settings.user_traffic_limit_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,
|
||||
) -> 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)
|
||||
)
|
||||
|
||||
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 []),
|
||||
}
|
||||
|
||||
squad_name_map: Dict[str, 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)
|
||||
except Exception:
|
||||
logging.debug("Failed to load internal squad names for premium display", exc_info=True)
|
||||
|
||||
node_labels: List[str] = []
|
||||
for squad_uuid in tariff.premium_squad_uuids:
|
||||
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 = str(node.get("name") or node.get("address") or node.get("host") or "").strip()
|
||||
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)
|
||||
@@ -649,6 +737,12 @@ class SubscriptionService:
|
||||
"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,
|
||||
@@ -671,7 +765,7 @@ class SubscriptionService:
|
||||
hwid_device_limit=effective_hwid_limit,
|
||||
)
|
||||
if tariff:
|
||||
panel_update_payload["activeInternalSquads"] = tariff.squad_uuids
|
||||
panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(tariff)
|
||||
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
|
||||
@@ -785,7 +879,10 @@ class SubscriptionService:
|
||||
traffic_limit_bytes=new_limit,
|
||||
hwid_device_limit=effective_hwid_limit,
|
||||
)
|
||||
panel_payload["activeInternalSquads"] = tariff.squad_uuids
|
||||
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(
|
||||
@@ -802,6 +899,93 @@ class SubscriptionService:
|
||||
"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_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes
|
||||
overflow_to_cover = max(0, premium_used - premium_baseline - previous_topup_used)
|
||||
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_is_limited = premium_limit > 0 and premium_used >= premium_limit
|
||||
|
||||
updated_sub = 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 activate_hwid_device_topup(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -966,7 +1150,23 @@ class SubscriptionService:
|
||||
before_tariff_key = sub.tariff_key
|
||||
options = self.calculate_tariff_switch_options(sub, target)
|
||||
now = datetime.now(timezone.utc)
|
||||
update_data: Dict[str, Any] = {"tariff_key": target.key, "is_throttled": False}
|
||||
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)
|
||||
@@ -1011,7 +1211,10 @@ class SubscriptionService:
|
||||
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"] = target.squad_uuids
|
||||
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:
|
||||
@@ -1092,6 +1295,29 @@ class SubscriptionService:
|
||||
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(
|
||||
@@ -1239,11 +1465,22 @@ class SubscriptionService:
|
||||
|
||||
topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0)
|
||||
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
|
||||
premium_topup_balance_bytes = int(getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0)
|
||||
premium_topup_used_bytes = int(getattr(current_active_sub, "premium_topup_used_bytes", 0) or 0)
|
||||
premium_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)
|
||||
traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes)
|
||||
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,
|
||||
@@ -1260,6 +1497,12 @@ class SubscriptionService:
|
||||
"tariff_key": tariff.key if tariff else None,
|
||||
"tier_baseline_bytes": tier_baseline_bytes,
|
||||
"topup_balance_bytes": topup_balance_bytes,
|
||||
"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,
|
||||
@@ -1286,7 +1529,10 @@ class SubscriptionService:
|
||||
hwid_device_limit=effective_hwid_limit,
|
||||
)
|
||||
if tariff:
|
||||
panel_update_payload["activeInternalSquads"] = tariff.squad_uuids
|
||||
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))
|
||||
|
||||
@@ -1552,6 +1798,14 @@ class SubscriptionService:
|
||||
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
|
||||
|
||||
return {
|
||||
"user_id": panel_user_data.get("uuid"),
|
||||
@@ -1568,6 +1822,19 @@ class SubscriptionService:
|
||||
"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,
|
||||
"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_limit_bytes": self._premium_effective_limit_bytes(
|
||||
premium_baseline,
|
||||
premium_topup_balance,
|
||||
premium_topup_used,
|
||||
),
|
||||
"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,
|
||||
|
||||
@@ -17,6 +17,8 @@ from config.settings import Settings
|
||||
from db.dal import subscription_dal, tariff_dal
|
||||
from db.models import Subscription
|
||||
|
||||
PREMIUM_WARNING_LEVEL_OFFSET = 1000
|
||||
|
||||
|
||||
class TariffTrafficWorker:
|
||||
def __init__(
|
||||
@@ -35,6 +37,7 @@ class TariffTrafficWorker:
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self._stopped = asyncio.Event()
|
||||
self._premium_nodes_cache = {}
|
||||
|
||||
async def run(self) -> None:
|
||||
if not self.settings.tariffs_config:
|
||||
@@ -93,6 +96,8 @@ class TariffTrafficWorker:
|
||||
warning_period_start=warning_period_start if tariff.billing_model == "period" else None,
|
||||
)
|
||||
|
||||
await self._sync_premium_squad_limit(session, sub, tariff, now)
|
||||
|
||||
async def _ensure_period_reset_strategy(
|
||||
self,
|
||||
sub: Subscription,
|
||||
@@ -109,7 +114,10 @@ class TariffTrafficWorker:
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
traffic_limit_strategy="MONTH",
|
||||
)
|
||||
payload["activeInternalSquads"] = tariff.squad_uuids
|
||||
payload["activeInternalSquads"] = self.subscription_service._panel_squads_for_tariff(
|
||||
tariff,
|
||||
include_premium=not bool(getattr(sub, "premium_is_limited", False)),
|
||||
)
|
||||
await self.panel_service.update_user_details_on_panel(sub.panel_user_uuid, payload, log_response=False)
|
||||
|
||||
async def _maybe_warn_or_throttle(
|
||||
@@ -176,6 +184,236 @@ class TariffTrafficWorker:
|
||||
sub.subscription_id,
|
||||
)
|
||||
|
||||
async def _sync_premium_squad_limit(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
tariff,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
if not getattr(tariff, "premium_squad_uuids", None):
|
||||
if any(
|
||||
int(value or 0) > 0
|
||||
for value in (
|
||||
sub.premium_baseline_bytes,
|
||||
sub.premium_topup_balance_bytes,
|
||||
sub.premium_used_bytes,
|
||||
)
|
||||
) or sub.premium_is_limited:
|
||||
sub.premium_baseline_bytes = 0
|
||||
sub.premium_topup_balance_bytes = 0
|
||||
sub.premium_used_bytes = 0
|
||||
sub.premium_is_limited = False
|
||||
return
|
||||
|
||||
premium_period_start = month_start(now)
|
||||
same_period = bool(getattr(sub, "premium_period_start_at", None) == premium_period_start)
|
||||
premium_baseline = int(tariff.premium_monthly_bytes or 0)
|
||||
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
|
||||
premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) if same_period else 0
|
||||
premium_limit = premium_baseline + premium_topup_balance + premium_topup_used
|
||||
if premium_limit <= 0:
|
||||
return
|
||||
|
||||
node_uuids = await self._premium_node_uuids_for_tariff(tariff)
|
||||
if not node_uuids:
|
||||
logging.warning("Premium squads for tariff %s have no accessible nodes", tariff.key)
|
||||
return
|
||||
|
||||
start_date = now.date().replace(day=1).isoformat()
|
||||
end_date = now.date().isoformat()
|
||||
premium_used = await self._premium_usage_for_user(sub.panel_user_uuid, node_uuids, start_date, end_date)
|
||||
if premium_used is None:
|
||||
return
|
||||
|
||||
overflow = max(0, int(premium_used) - premium_baseline)
|
||||
delta_overflow = max(0, overflow - premium_topup_used)
|
||||
consume_from_topup = min(premium_topup_balance, delta_overflow)
|
||||
if consume_from_topup > 0:
|
||||
premium_topup_balance -= consume_from_topup
|
||||
premium_topup_used += consume_from_topup
|
||||
premium_limit = premium_baseline + premium_topup_balance + premium_topup_used
|
||||
|
||||
should_limit = premium_used >= premium_limit
|
||||
changed = (
|
||||
int(sub.premium_baseline_bytes or 0) != premium_baseline
|
||||
or int(sub.premium_topup_balance_bytes or 0) != premium_topup_balance
|
||||
or int(getattr(sub, "premium_topup_used_bytes", 0) or 0) != premium_topup_used
|
||||
or int(sub.premium_used_bytes or 0) != premium_used
|
||||
or bool(sub.premium_is_limited) != should_limit
|
||||
or getattr(sub, "premium_period_start_at", None) != premium_period_start
|
||||
)
|
||||
sub.premium_baseline_bytes = premium_baseline
|
||||
sub.premium_topup_balance_bytes = premium_topup_balance
|
||||
sub.premium_topup_used_bytes = premium_topup_used
|
||||
sub.premium_used_bytes = int(premium_used)
|
||||
sub.premium_is_limited = bool(should_limit)
|
||||
sub.premium_period_start_at = premium_period_start
|
||||
await self._maybe_warn_premium_squad_limit(
|
||||
session,
|
||||
sub,
|
||||
tariff,
|
||||
premium_used,
|
||||
premium_limit,
|
||||
premium_period_start,
|
||||
)
|
||||
if not changed:
|
||||
return
|
||||
|
||||
squads = self.subscription_service._panel_squads_for_tariff(
|
||||
tariff,
|
||||
include_premium=not should_limit,
|
||||
)
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
sub.panel_user_uuid,
|
||||
{"uuid": sub.panel_user_uuid, "activeInternalSquads": squads},
|
||||
log_response=False,
|
||||
)
|
||||
logging.info(
|
||||
"Premium squad access %s for user %s tariff %s: %s/%s bytes",
|
||||
"limited" if should_limit else "restored",
|
||||
sub.user_id,
|
||||
tariff.key,
|
||||
premium_used,
|
||||
premium_limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fmt_bytes(value: int) -> str:
|
||||
size = float(max(0, int(value or 0)))
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024 or unit == "TB":
|
||||
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
|
||||
size /= 1024
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
async def _maybe_warn_premium_squad_limit(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
tariff,
|
||||
used: int,
|
||||
limit: int,
|
||||
period_start_at: datetime,
|
||||
) -> None:
|
||||
if limit <= 0:
|
||||
return
|
||||
ratio = int(used or 0) / int(limit)
|
||||
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
|
||||
for level in levels:
|
||||
if ratio < level / 100:
|
||||
continue
|
||||
storage_level = PREMIUM_WARNING_LEVEL_OFFSET + int(level)
|
||||
warning = await tariff_dal.get_warning(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
period_start_at=period_start_at,
|
||||
level=storage_level,
|
||||
)
|
||||
if warning:
|
||||
continue
|
||||
await tariff_dal.create_warning(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
period_start_at=period_start_at,
|
||||
level=storage_level,
|
||||
traffic_limit_bytes=None,
|
||||
)
|
||||
if not self.bot:
|
||||
continue
|
||||
try:
|
||||
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||
if labels:
|
||||
visible = labels[:8]
|
||||
servers = "\n".join(f"• {label}" for label in visible)
|
||||
if len(labels) > len(visible):
|
||||
servers += f"\n• ... еще {len(labels) - len(visible)}"
|
||||
else:
|
||||
servers = "• premium-серверы тарифа"
|
||||
text = (
|
||||
"⚠️ Отдельный лимит premium-серверов почти закончился.\n\n"
|
||||
f"Тариф: {tariff.name(self.settings.DEFAULT_LANGUAGE)}\n"
|
||||
f"Использовано: {self._fmt_bytes(used)} из {self._fmt_bytes(limit)} ({level}%).\n\n"
|
||||
"Этот лимит действует на:\n"
|
||||
f"{servers}\n\n"
|
||||
"Можно докупить premium-трафик. Докупленный остаток переносится на следующие месяцы, пока не израсходуется."
|
||||
)
|
||||
markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="Докупить premium-трафик",
|
||||
callback_data="tariff_topup:list",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await self.bot.send_message(sub.user_id, text, reply_markup=markup)
|
||||
except Exception:
|
||||
logging.exception("Failed to send premium traffic warning to user %s", sub.user_id)
|
||||
|
||||
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
||||
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
|
||||
cached = self._premium_nodes_cache.get(cache_key)
|
||||
now_ts = datetime.now(timezone.utc).timestamp()
|
||||
if cached and now_ts - cached["ts"] < 600:
|
||||
return list(cached["nodes"])
|
||||
|
||||
nodes: list[str] = []
|
||||
for squad_uuid in tariff.premium_squad_uuids or []:
|
||||
accessible = await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or []
|
||||
for node in accessible:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_uuid = node.get("uuid") or node.get("nodeUuid") or node.get("node_uuid")
|
||||
if node_uuid:
|
||||
nodes.append(str(node_uuid))
|
||||
deduped = list(dict.fromkeys(nodes))
|
||||
self._premium_nodes_cache[cache_key] = {"ts": now_ts, "nodes": deduped}
|
||||
return deduped
|
||||
|
||||
async def _premium_usage_for_user(
|
||||
self,
|
||||
user_uuid: str,
|
||||
node_uuids: list[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
) -> Optional[int]:
|
||||
total = 0
|
||||
found = False
|
||||
for node_uuid in node_uuids:
|
||||
stats = await self.panel_service.get_node_users_bandwidth_stats(
|
||||
node_uuid,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
)
|
||||
if not stats:
|
||||
continue
|
||||
entries = stats.get("topUsers") or stats.get("usersStats") or stats.get("users") or []
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
user_obj = entry.get("user") if isinstance(entry.get("user"), dict) else {}
|
||||
entry_uuid = (
|
||||
user_obj.get("uuid")
|
||||
or entry.get("userUuid")
|
||||
or entry.get("uuid")
|
||||
or entry.get("user_uuid")
|
||||
)
|
||||
if entry_uuid != user_uuid:
|
||||
continue
|
||||
value = entry.get("total")
|
||||
if value is None:
|
||||
value = int(entry.get("download", 0) or 0) + int(entry.get("upload", 0) or 0)
|
||||
total += int(value or 0)
|
||||
found = True
|
||||
if len(node_uuids) > 1:
|
||||
await asyncio.sleep(0.1)
|
||||
return total if found else 0
|
||||
|
||||
async def legacy_throttle_recovery_tick(self, session: AsyncSession) -> None:
|
||||
"""Recover subscriptions throttled by older bot versions.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user