feat: route purchases through tariff catalog

This commit is contained in:
3252a8
2026-04-28 14:33:52 +03:00
parent 87b2443a06
commit 26af92b5ac
14 changed files with 541 additions and 58 deletions
+5 -4
View File
@@ -55,6 +55,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
subscription_months_str = metadata.get("subscription_months")
traffic_gb_str = metadata.get("traffic_gb")
sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription")
sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
promo_code_id_str = metadata.get("promo_code_id")
payment_db_id_str = metadata.get("payment_db_id")
auto_renew_subscription_id_str = metadata.get(
@@ -79,13 +80,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
payment_db_id = int(
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic")
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode_base == "subscription")
promo_code_id = int(
promo_code_id_str
) if promo_code_id_str and promo_code_id_str.isdigit() else None
amount_data = payment_info_from_webhook.get("amount", {})
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
@@ -227,7 +228,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
months_for_activation = int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
@@ -237,7 +238,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
traffic_gb=traffic_amount_gb if sale_mode_base in {"traffic", "traffic_package", "topup"} else None,
)
if not activation_details or not activation_details.get('end_date'):
@@ -52,9 +52,10 @@ async def pay_crypto_callback_handler(
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
if sale_base in {"traffic", "traffic_package", "topup"}
else get_text("payment_description_subscription", months=int(months))
)
@@ -71,7 +72,7 @@ async def pay_crypto_callback_handler(
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup"} else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -88,7 +89,7 @@ async def pay_crypto_callback_handler(
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup"} else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -61,9 +61,10 @@ async def pay_fk_callback_handler(
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
if sale_base in {"traffic", "traffic_package", "topup"}
else get_text("payment_description_subscription", months=int(months))
)
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
@@ -76,6 +77,9 @@ async def pay_fk_callback_handler(
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "freekassa",
"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,
}
try:
@@ -88,9 +88,10 @@ async def pay_platega_callback_handler(
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
if sale_base in {"traffic", "traffic_package", "topup"}
else get_text("payment_description_subscription", months=int(months))
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
@@ -103,6 +104,9 @@ async def pay_platega_callback_handler(
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "platega",
"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,
}
try:
@@ -60,9 +60,10 @@ async def pay_severpay_callback_handler(
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
if sale_base in {"traffic", "traffic_package", "topup"}
else get_text("payment_description_subscription", months=int(months))
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
@@ -75,6 +76,9 @@ async def pay_severpay_callback_handler(
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "severpay",
"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,
}
try:
@@ -53,9 +53,10 @@ async def pay_stars_callback_handler(
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_mode == "traffic"
if sale_base in {"traffic", "traffic_package", "topup"}
else get_text("payment_description_subscription", months=int(months))
)
@@ -72,7 +73,7 @@ async def pay_stars_callback_handler(
try:
await callback.message.edit_text(
get_text(
"payment_invoice_sent_message_traffic" if sale_mode == "traffic" else "payment_invoice_sent_message",
"payment_invoice_sent_message_traffic" if sale_base in {"traffic", "traffic_package", "topup"} else "payment_invoice_sent_message",
months=int(months),
traffic_gb=human_value,
),
@@ -33,6 +33,10 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
return None
def _sale_mode_base(sale_mode: str) -> str:
return (sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0]
def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
def _is_yoomoney_network(name: Optional[str]) -> bool:
s = (name or "").lower()
@@ -79,9 +83,10 @@ async def _initiate_yk_payment(
payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_mode == "traffic"
if _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup"}
else get_text("payment_description_subscription", months=int(months))
)
sale_base = _sale_mode_base(sale_mode)
payment_record_data = {
"user_id": user_id,
"amount": price_rub,
@@ -89,6 +94,9 @@ async def _initiate_yk_payment(
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": int(months),
"sale_mode": sale_base,
"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,
}
db_payment_record = None
@@ -123,7 +131,7 @@ async def _initiate_yk_payment(
"payment_db_id": str(db_payment_record.payment_id),
"sale_mode": sale_mode,
}
if sale_mode == "traffic":
if sale_base in {"traffic", "traffic_package", "topup"}:
yookassa_metadata["traffic_gb"] = str(months)
if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
@@ -363,7 +371,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_require_binding = bool(
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
)
@@ -481,7 +489,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_require_binding = bool(
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
)
@@ -553,7 +561,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
pass
return
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -708,7 +716,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
pass
return
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
+12 -7
View File
@@ -78,6 +78,7 @@ class CryptoPayService:
# Create pending payment in DB and commit to persist
try:
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_record = await payment_dal.create_payment_record(
session,
{
@@ -88,6 +89,9 @@ class CryptoPayService:
"description": description,
"subscription_duration_months": int(months),
"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,
},
)
await session.commit()
@@ -103,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_mode == "traffic" else None,
"traffic_gb": str(months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
})
try:
invoice = await self.client.create_invoice(
@@ -151,6 +155,7 @@ class CryptoPayService:
months = float(meta.get("subscription_months") or 0)
payment_db_id = int(meta["payment_db_id"])
sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription")
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
except Exception:
logging.exception("Failed to parse CryptoPay payload.")
@@ -174,15 +179,15 @@ class CryptoPayService:
activation = await subscription_service.activate_subscription(
session,
user_id,
int(months) if sale_mode != "traffic" else 0,
int(months) if sale_base == "subscription" else int(float(traffic_gb)),
float(invoice.amount),
payment_db_id,
provider="cryptopay",
sale_mode=sale_mode,
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup"} else None,
)
referral_bonus = None
if sale_mode != "traffic":
if sale_base == "subscription":
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
@@ -210,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_mode == "traffic":
if sale_base in {"traffic", "traffic_package", "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 "",
@@ -265,8 +270,8 @@ class CryptoPayService:
user_id=user_id,
amount=float(invoice.amount),
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
months=int(months) if sale_mode != "traffic" else 0,
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
months=int(months) if sale_base == "subscription" else 0,
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup"} else None,
payment_provider="crypto_pay",
username=user.username if user else None
)
+9 -8
View File
@@ -274,22 +274,23 @@ class FreeKassaService:
new_status="succeeded",
)
months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription")
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(months) if sale_mode != "traffic" else 0,
int(months) if sale_base == "subscription" else int(float(months)),
float(payment.amount),
payment.payment_id,
provider="freekassa",
sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None,
traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
)
referral_bonus = None
if sale_mode != "traffic":
if sale_base == "subscription":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
@@ -312,8 +313,8 @@ class FreeKassaService:
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None
months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription")
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
@@ -330,7 +331,7 @@ class FreeKassaService:
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
if sale_mode == "traffic":
if sale_mode.split("@", 1)[0].split("|", 1)[0] in {"traffic", "traffic_package", "topup"}:
text = _("payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=end_date_str if final_end else "",
+42
View File
@@ -556,6 +556,48 @@ class PanelApiService:
return response_data.get("response")
return None
async def get_user_bandwidth_stats(self, user_uuid: str) -> Optional[Dict[str, Any]]:
endpoint = f"/bandwidth-stats/users/{user_uuid}"
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
logging.error("Failed to get bandwidth stats for user %s. Response: %s", user_uuid, response_data)
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)
if response_data and not response_data.get("error"):
return True
logging.error("Failed to reset traffic for user %s. Response: %s", user_uuid, response_data)
return False
async def add_users_to_internal_squad(self, squad_uuid: str, user_uuids: List[str]) -> bool:
endpoint = f"/internal-squads/{squad_uuid}/bulk-actions/add-users"
response_data = await self._request(
"POST",
endpoint,
json={"users": user_uuids, "userUuids": user_uuids},
log_full_response=False,
)
if response_data and not response_data.get("error"):
return True
logging.error("Failed to add users to squad %s. Response: %s", squad_uuid, response_data)
return False
async def remove_users_from_internal_squad(self, squad_uuid: str, user_uuids: List[str]) -> bool:
endpoint = f"/internal-squads/{squad_uuid}/bulk-actions/remove-users"
response_data = await self._request(
"DELETE",
endpoint,
json={"users": user_uuids, "userUuids": user_uuids},
log_full_response=False,
)
if response_data and not response_data.get("error"):
return True
logging.error("Failed to remove users from squad %s. Response: %s", squad_uuid, response_data)
return False
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
"""Get nodes statistics"""
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
+7 -6
View File
@@ -179,8 +179,9 @@ class PlategaService:
if payment.status == "succeeded" and status == "CONFIRMED":
return web.Response(text="ok")
payment_months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription")
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
if status == "CONFIRMED":
if amount_raw is not None:
@@ -208,16 +209,16 @@ class PlategaService:
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_months) if sale_mode != "traffic" else 0,
int(payment_months) if sale_base == "subscription" else int(float(payment_months)),
float(payment.amount),
payment.payment_id,
provider="platega",
sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None,
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
)
referral_bonus = None
if sale_mode != "traffic":
if sale_base == "subscription":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
@@ -249,7 +250,7 @@ class PlategaService:
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
if sale_mode == "traffic":
if sale_base in {"traffic", "traffic_package", "topup"}:
text = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
+7 -6
View File
@@ -190,8 +190,9 @@ class SeverPayService:
logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id)
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
payment_months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription")
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
if status == "success":
try:
await payment_dal.update_provider_payment_and_status(
@@ -204,16 +205,16 @@ class SeverPayService:
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_months) if sale_mode != "traffic" else 0,
int(payment_months) if sale_base == "subscription" else int(float(payment_months)),
float(payment.amount),
payment.payment_id,
provider="severpay",
sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None,
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup"} else None,
)
referral_bonus = None
if sale_mode != "traffic":
if sale_base == "subscription":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
@@ -245,7 +246,7 @@ class SeverPayService:
traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}"
if sale_mode == "traffic":
if sale_base in {"traffic", "traffic_package", "topup"}:
text = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
+11 -6
View File
@@ -28,6 +28,7 @@ class StarsService:
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]:
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
@@ -36,6 +37,9 @@ class StarsService:
"description": description,
"subscription_duration_months": int(months),
"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,
}
try:
db_payment_record = await payment_dal.create_payment_record(
@@ -85,15 +89,16 @@ class StarsService:
exc_info=True)
return
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
activation_details = await self.subscription_service.activate_subscription(
session,
message.from_user.id,
int(months) if sale_mode != "traffic" else 0,
int(months) if sale_base == "subscription" else int(float(months)),
float(stars_amount),
payment_db_id,
provider="telegram_stars",
sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None,
traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup"} else None,
)
if not activation_details or not activation_details.get("end_date"):
logging.error(
@@ -101,7 +106,7 @@ class StarsService:
return
referral_bonus = None
if sale_mode != "traffic":
if sale_base == "subscription":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
message.from_user.id,
@@ -126,7 +131,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_mode == "traffic":
if sale_base in {"traffic", "traffic_package", "topup"}:
success_msg = _(
"payment_successful_traffic_full",
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}",
@@ -188,10 +193,10 @@ class StarsService:
user_id=message.from_user.id,
amount=float(stars_amount),
currency="XTR",
months=int(months) if sale_mode != "traffic" else 0,
months=int(months) if sale_base == "subscription" else 0,
payment_provider="stars",
username=user.username if user else None,
traffic_gb=months if sale_mode == "traffic" else None,
traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup"} else None,
)
except Exception as e:
logging.error(f"Failed to send stars payment notification: {e}")
+412 -7
View File
@@ -1,11 +1,13 @@
import logging
import math
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List, Tuple
from aiogram import Bot
from bot.middlewares.i18n import JsonI18n
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
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.config_link import prepare_config_links
from db.models import User, Subscription
@@ -30,6 +32,72 @@ class SubscriptionService:
self.bot = bot
self.i18n = i18n
@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]) -> Optional[List[str]]:
if tariff:
return tariff.squad_uuids
return self.settings.parsed_user_squad_uuids
def _traffic_limit_for_period_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int:
if tariff:
return int(tariff.monthly_bytes + max(0, topup_balance_bytes))
return self.settings.user_traffic_limit_bytes
async def _record_payment_context(
self,
session: AsyncSession,
payment_db_id: int,
*,
sale_mode: str,
tariff_key: Optional[str],
purchased_gb: Optional[float] = 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
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 (
@@ -490,8 +558,18 @@ class SubscriptionService:
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)
@@ -516,12 +594,14 @@ class SubscriptionService:
if current_used is None and active_sub:
current_used = active_sub.traffic_used_bytes
purchase_bytes = int(float(traffic_gb) * (1024**3))
new_limit = (current_limit or 0) + purchase_bytes
purchase_bytes = self.gb_to_bytes(traffic_gb)
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.
far_future = datetime(2099, 1, 1, tzinfo=timezone.utc)
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
@@ -544,6 +624,12 @@ class SubscriptionService:
"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,
"period_start_at": None,
"is_throttled": False,
"effective_monthly_price_rub": None,
}
try:
@@ -559,6 +645,8 @@ class SubscriptionService:
traffic_limit_bytes=new_limit,
traffic_limit_strategy="NO_RESET",
)
if tariff:
panel_update_payload["activeInternalSquads"] = tariff.squad_uuids
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
@@ -575,6 +663,13 @@ class SubscriptionService:
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,
@@ -595,8 +690,204 @@ class SubscriptionService:
"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
new_limit = int(sub.tier_baseline_bytes or tariff.monthly_bytes) + new_topup_balance
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,
},
)
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,
)
panel_payload["activeInternalSquads"] = tariff.squad_uuids
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,
}
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}
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)
update_data: Dict[str, Any] = {"tariff_key": target.key, "is_throttled": False}
converted_bytes = None
if target.billing_model == "period":
update_data["tier_baseline_bytes"] = target.monthly_bytes
update_data["traffic_limit_bytes"] = target.monthly_bytes + int(sub.topup_balance_bytes or 0)
update_data["period_start_at"] = sub.period_start_at or now
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
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)
update_data.update(
{
"end_date": self._far_future(),
"period_start_at": None,
"tier_baseline_bytes": 0,
"topup_balance_bytes": new_balance,
"traffic_limit_bytes": int(current_used or 0) + new_balance,
"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 self.settings.USER_TRAFFIC_STRATEGY,
)
panel_payload["activeInternalSquads"] = target.squad_uuids
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,
@@ -608,9 +899,14 @@ class SubscriptionService:
provider: str = "yookassa",
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
tariff_key: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if sale_mode == "traffic" or getattr(self.settings, "traffic_sale_mode", False):
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,
@@ -619,7 +915,79 @@ class SubscriptionService:
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 == "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:
@@ -647,6 +1015,11 @@ class SubscriptionService:
session, user_id, panel_user_uuid
)
start_date = datetime.now(timezone.utc)
starts_after_lapse = not (
current_active_sub
and current_active_sub.end_date
and current_active_sub.end_date > start_date
)
if (
current_active_sub
and current_active_sub.end_date
@@ -702,6 +1075,15 @@ class SubscriptionService:
session, user_id
)
topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0)
tier_baseline_bytes = tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes
period_start_at = (
datetime.now(timezone.utc)
if starts_after_lapse or not current_active_sub or not getattr(current_active_sub, "period_start_at", None)
else current_active_sub.period_start_at
)
effective_monthly_price = float(payment_amount) / max(1, months_int)
traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes)
sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
@@ -711,10 +1093,16 @@ class SubscriptionService:
"duration_months": months_int,
"is_active": True,
"status_from_panel": "ACTIVE",
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"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,
"period_start_at": period_start_at,
"is_throttled": False,
"effective_monthly_price_rub": effective_monthly_price,
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(
@@ -731,8 +1119,10 @@ class SubscriptionService:
panel_user_uuid=panel_user_uuid,
expire_at=final_end_date,
status="ACTIVE",
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
traffic_limit_bytes=traffic_limit_bytes,
)
if tariff:
panel_update_payload["activeInternalSquads"] = tariff.squad_uuids
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
@@ -766,6 +1156,7 @@ class SubscriptionService:
"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(
@@ -983,6 +1374,12 @@ class SubscriptionService:
hwid_limit = panel_user_data.get("hwidDeviceLimit")
if hwid_limit is None:
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
return {
"user_id": panel_user_data.get("uuid"),
@@ -993,6 +1390,14 @@ class SubscriptionService:
"traffic_limit_bytes": panel_traffic_limit,
"traffic_used_bytes": panel_traffic_used,
"traffic_limit_strategy": panel_traffic_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,
"billing_model": tariff.billing_model if tariff else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period"),
"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,
"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,
"user_bot_username": db_user.username,
"is_panel_data": True,
"max_devices": hwid_limit,