fix: bind HWID top-ups to subscription periods

This commit is contained in:
3252a8
2026-05-25 19:33:50 +03:00
parent 59fa301344
commit c68cb97964
49 changed files with 2027 additions and 119 deletions
+5
View File
@@ -113,6 +113,11 @@ class WebAppPaymentContext:
description: str
sale_mode: str
traffic_gb: Optional[float] = None
hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None
hwid_pricing_period_months: Optional[int] = None
hwid_proration_ratio: Optional[float] = None
hwid_full_price: Optional[float] = None
EnabledPredicate = Callable[[Any], bool]
+37 -3
View File
@@ -38,9 +38,12 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
render_payment_link,
sale_mode_base,
sale_mode_is_traffic,
sale_mode_tariff_key,
)
@@ -155,13 +158,14 @@ class CryptoPayService:
description: str,
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
try:
payment_record = await payment_dal.create_payment_record(
session,
@@ -177,7 +181,17 @@ class CryptoPayService:
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_gb": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio")
if hwid_quote
else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
},
)
await session.commit()
@@ -192,7 +206,7 @@ class CryptoPayService:
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months) if is_traffic else None,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
}
)
try:
@@ -363,6 +377,16 @@ async def pay_crypto_callback_handler(
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=cryptopay_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
@@ -375,6 +399,7 @@ async def pay_crypto_callback_handler(
amount=parts.price,
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
if invoice_url:
@@ -424,6 +449,15 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
hwid_quote={
"valid_from": ctx.hwid_valid_from,
"valid_until": ctx.hwid_valid_until,
"pricing_period_months": ctx.hwid_pricing_period_months,
"proration_ratio": ctx.hwid_proration_ratio,
"full_price": ctx.hwid_full_price,
}
if ctx.hwid_valid_from and ctx.hwid_valid_until
else None,
)
if not url:
return payment_failed()
@@ -50,6 +50,7 @@ from .shared import (
payment_failed,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -470,6 +471,16 @@ async def pay_fk_callback_handler(
logging.error("Invalid pay_fk data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=freekassa_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (
getattr(freekassa_service, "default_currency", None)
@@ -486,6 +497,7 @@ async def pay_fk_callback_handler(
months=parts.months,
provider="freekassa",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+12
View File
@@ -49,6 +49,7 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_unavailable,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -565,6 +566,16 @@ async def pay_heleket_callback_handler(
logging.error("Invalid pay_heleket data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=heleket_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
payment_description = describe_payment(translator, parts)
@@ -577,6 +588,7 @@ async def pay_heleket_callback_handler(
months=parts.months,
provider="heleket",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+12
View File
@@ -46,6 +46,7 @@ from .shared import (
payment_record_amounts,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
safe_callback_answer,
)
@@ -475,6 +476,16 @@ async def pay_platega_callback_handler(
logging.error("Invalid pay_platega data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=platega_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
@@ -487,6 +498,7 @@ async def pay_platega_callback_handler(
months=parts.months,
provider="platega",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+12
View File
@@ -47,6 +47,7 @@ from .shared import (
payment_failed,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -414,6 +415,16 @@ async def pay_severpay_callback_handler(
logging.error("Invalid pay_severpay data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=severpay_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
@@ -426,6 +437,7 @@ async def pay_severpay_callback_handler(
months=parts.months,
provider="severpay",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
@@ -15,6 +15,7 @@ from .callbacks import (
notify_service_unavailable,
parse_payment_callback,
payment_link_message_text,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
@@ -55,6 +56,7 @@ from .success import (
PaymentSuccessOutcome,
PaymentSuccessRequest,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
finalize_successful_payment,
is_traffic_sale_base,
@@ -82,6 +84,7 @@ __all__ = [
"build_payment_description",
"build_payment_record_payload",
"build_success_message",
"append_hwid_renewal_note",
"coerce_payment_db_id",
"create_base_payment_record",
"create_webapp_payment_record",
@@ -113,6 +116,7 @@ __all__ = [
"payment_record_amounts",
"payment_unavailable",
"post_json_request",
"quote_hwid_callback_parts",
"render_link_or_fail",
"render_payment_link",
"resolve_inviter_name",
@@ -21,6 +21,8 @@ from .common import (
format_human_units,
mark_payment_failed_creation,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_tariff_key,
)
@@ -112,6 +114,34 @@ def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str
)
async def quote_hwid_callback_parts(
*,
session: AsyncSession,
user_id: int,
parts: PaymentCallbackParts,
subscription_service,
currency: str = "rub",
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
if not sale_mode_is_hwid_devices(parts.sale_mode):
return parts, None
quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=int(parts.months),
tariff_key=sale_mode_tariff_key(parts.sale_mode),
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
currency=currency,
)
if not quote:
return None, None
quoted_parts = PaymentCallbackParts(
months=parts.months,
price=float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
return quoted_parts, quote
def payment_link_message_text(
translator: Translator,
parts: PaymentCallbackParts,
+30 -3
View File
@@ -60,7 +60,7 @@ def build_payment_description(
"payment_description_traffic",
traffic_gb=human_value if human_value is not None else format_human_units(months),
)
if base in {"hwid_device", "hwid_devices"}:
if base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
return translator("payment_description_hwid_devices", count=int(float(months)))
return translator("payment_description_subscription", months=int(float(months)))
@@ -75,6 +75,7 @@ def build_payment_record_payload(
months: Any,
provider: str,
sale_mode: str,
hwid_quote: Optional[dict] = None,
) -> dict:
"""Assemble the payment-record dict that every callback handler used to inline.
@@ -85,7 +86,7 @@ def build_payment_record_payload(
base = sale_mode_base(sale_mode)
is_traffic = sale_mode_is_traffic(sale_mode)
is_hwid = sale_mode_is_hwid_devices(sale_mode)
return {
payload = {
"user_id": user_id,
"amount": amount,
"currency": currency,
@@ -98,6 +99,17 @@ def build_payment_record_payload(
"purchased_gb": float(months) if is_traffic else None,
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
}
if hwid_quote and is_hwid:
payload.update(
{
"hwid_valid_from": hwid_quote.get("valid_from"),
"hwid_valid_until": hwid_quote.get("valid_until"),
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
"hwid_full_price": hwid_quote.get("full_price"),
}
)
return payload
@dataclass(frozen=True)
@@ -119,7 +131,7 @@ def sale_mode_is_traffic(sale_mode: str) -> bool:
def sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
@@ -194,6 +206,11 @@ async def create_base_payment_record(
tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
hwid_valid_from: Optional[Any] = None,
hwid_valid_until: Optional[Any] = None,
hwid_pricing_period_months: Optional[int] = None,
hwid_proration_ratio: Optional[float] = None,
hwid_full_price: Optional[float] = None,
) -> Payment:
payment = await payment_dal.create_payment_record(
session,
@@ -209,6 +226,11 @@ async def create_base_payment_record(
"tariff_key": tariff_key,
"purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices,
"hwid_valid_from": hwid_valid_from,
"hwid_valid_until": hwid_valid_until,
"hwid_pricing_period_months": hwid_pricing_period_months,
"hwid_proration_ratio": hwid_proration_ratio,
"hwid_full_price": hwid_full_price,
},
)
await session.commit()
@@ -241,6 +263,11 @@ async def create_webapp_payment_record(
tariff_key=amounts.tariff_key,
purchased_gb=amounts.purchased_gb,
purchased_hwid_devices=amounts.purchased_hwid_devices,
hwid_valid_from=ctx.hwid_valid_from,
hwid_valid_until=ctx.hwid_valid_until,
hwid_pricing_period_months=ctx.hwid_pricing_period_months,
hwid_proration_ratio=ctx.hwid_proration_ratio,
hwid_full_price=ctx.hwid_full_price,
)
@@ -19,7 +19,7 @@ from db.models import Payment, User
from .common import Translator, format_human_units, make_translator, sale_mode_base
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def is_traffic_sale_base(sale_base: str) -> bool:
@@ -128,6 +128,28 @@ def build_success_message(payload: SuccessMessage) -> str:
)
def append_hwid_renewal_note(
text: str,
translator: Translator,
*,
count: Any,
valid_until: Optional[datetime],
) -> str:
try:
count_int = int(count or 0)
except (TypeError, ValueError):
count_int = 0
if count_int <= 0:
return text
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
note = translator(
"payment_successful_hwid_devices_renewal_note",
count=format_human_units(count_int),
date=date_text,
)
return f"{text}\n\n{note}"
async def send_success_message_to_user(
*,
bot: Bot,
@@ -333,6 +355,13 @@ async def finalize_successful_payment(
inviter_name=inviter_name,
)
)
if is_subscription and activation:
success_text = append_hwid_renewal_note(
success_text,
translator,
count=activation.get("hwid_devices_renewal_recommended_count"),
valid_until=activation.get("hwid_devices_valid_until"),
)
if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}"
+23 -2
View File
@@ -35,6 +35,7 @@ from .shared import (
payment_failed,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
safe_callback_answer,
sale_mode_base,
sale_mode_tariff_key,
@@ -80,9 +81,10 @@ class StarsService:
stars_price: int,
description: str,
sale_mode: str = "subscription",
hwid_quote: Optional[dict] = None,
) -> Optional[int]:
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
@@ -93,7 +95,15 @@ class StarsService:
"provider": "telegram_stars",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_gb": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
}
try:
db_payment_record = await payment_dal.create_payment_record(
@@ -206,6 +216,16 @@ async def pay_stars_callback_handler(
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=stars_service.subscription_service,
currency="stars",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
@@ -221,6 +241,7 @@ async def pay_stars_callback_handler(
stars_price=stars_price,
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
if payment_db_id:
+12
View File
@@ -53,6 +53,7 @@ from .shared import (
payment_record_amounts,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
@@ -856,6 +857,16 @@ async def pay_wata_callback_handler(
logging.error("Invalid pay_wata data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=wata_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
@@ -901,6 +912,7 @@ async def pay_wata_callback_handler(
months=parts.months,
provider="wata",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+40 -3
View File
@@ -52,7 +52,9 @@ from .base import (
provider_runtime_enabled,
)
from .shared import (
PaymentCallbackParts,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
create_webapp_payment_record,
format_human_units,
@@ -65,6 +67,7 @@ from .shared import (
payment_link_response,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
resolve_inviter_name,
send_success_message_to_user,
)
@@ -419,7 +422,7 @@ YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"77.75.154.128/25",
"2a02:5180::/32",
]
HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices"}
HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def _is_hwid_device_sale_base(sale_mode_base: str) -> bool:
@@ -832,6 +835,14 @@ async def process_successful_payment(
)
include_keyboard = True
if sale_mode_base == "subscription" and activation_details:
details_message = append_hwid_renewal_note(
details_message,
translator,
count=activation_details.get("hwid_devices_renewal_recommended_count"),
valid_until=activation_details.get("hwid_devices_valid_until"),
)
install_share_url = None
if include_keyboard:
install_links = await ensure_user_install_guide_links(session, settings, user_id)
@@ -1288,6 +1299,7 @@ async def _initiate_yk_payment(
payment_method_id: Optional[str] = None,
selected_method_internal_id: Optional[int] = None,
sale_mode: str = "subscription",
hwid_quote: Optional[dict] = None,
) -> bool:
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
if not callback.message:
@@ -1299,7 +1311,7 @@ async def _initiate_yk_payment(
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
if sale_base in HWID_DEVICE_SALE_BASES
else get_text("payment_description_subscription", months=int(months))
)
)
@@ -1316,8 +1328,15 @@ async def _initiate_yk_payment(
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
if sale_base in HWID_DEVICE_SALE_BASES
else None,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
}
db_payment_record = None
@@ -1634,6 +1653,23 @@ async def pay_yk_callback_handler(
return
months, price_rub, sale_mode = parsed
hwid_quote = None
if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES:
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency="rub",
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(
@@ -1710,6 +1746,7 @@ async def pay_yk_callback_handler(
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
sale_mode=sale_mode,
hwid_quote=hwid_quote,
)
try:
await callback.answer()