fix: separate HWID device renewal flows
Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout. Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows. Add admin extension controls, docs, demo data, and regression coverage.
This commit is contained in:
@@ -8,8 +8,10 @@ from aiogram import types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
HWID_RENEWAL_TOKEN,
|
||||
get_payment_url_keyboard,
|
||||
payment_methods_back_callback,
|
||||
sale_mode_has_token,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import payment_dal
|
||||
@@ -123,6 +125,27 @@ async def quote_hwid_callback_parts(
|
||||
subscription_service,
|
||||
currency: str = "rub",
|
||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||
base = sale_mode_base(parts.sale_mode)
|
||||
if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN):
|
||||
try:
|
||||
months = int(parts.months)
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||
months=months,
|
||||
currency=currency,
|
||||
)
|
||||
if not quote:
|
||||
return parts, None
|
||||
quoted_parts = PaymentCallbackParts(
|
||||
months=months,
|
||||
price=float(parts.price or 0) + float(quote.get("price") or 0),
|
||||
sale_mode=parts.sale_mode,
|
||||
)
|
||||
return quoted_parts, quote
|
||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||
return parts, None
|
||||
device_count = parse_positive_int_units(parts.months)
|
||||
|
||||
@@ -100,6 +100,11 @@ 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)
|
||||
hwid_devices = int(float(months)) if is_hwid else None
|
||||
if hwid_quote:
|
||||
quote_devices = parse_positive_int_units(hwid_quote.get("device_count"))
|
||||
if quote_devices is not None:
|
||||
hwid_devices = quote_devices
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"amount": amount,
|
||||
@@ -111,9 +116,9 @@ def build_payment_record_payload(
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode_tariff_key(sale_mode),
|
||||
"purchased_gb": float(months) if is_traffic else None,
|
||||
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
|
||||
"purchased_hwid_devices": hwid_devices,
|
||||
}
|
||||
if hwid_quote and is_hwid:
|
||||
if hwid_quote and hwid_devices is not None:
|
||||
payload.update(
|
||||
{
|
||||
"hwid_valid_from": hwid_quote.get("valid_from"),
|
||||
@@ -164,14 +169,20 @@ def payment_record_amounts(
|
||||
months: Any,
|
||||
sale_mode: str,
|
||||
traffic_gb: Optional[float] = None,
|
||||
hwid_device_count: Optional[int] = None,
|
||||
) -> PaymentRecordAmounts:
|
||||
traffic_sale = sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
|
||||
units = traffic_gb if traffic_sale and traffic_gb is not None else months
|
||||
purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None
|
||||
if not hwid_devices_sale and hwid_device_count is not None:
|
||||
parsed_hwid_devices = parse_positive_int_units(hwid_device_count)
|
||||
if parsed_hwid_devices is not None:
|
||||
purchased_hwid_devices = parsed_hwid_devices
|
||||
return PaymentRecordAmounts(
|
||||
months=int(float(units)) if traffic_sale else int(float(months)),
|
||||
purchased_gb=float(units) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
purchased_hwid_devices=purchased_hwid_devices,
|
||||
tariff_key=sale_mode_tariff_key(sale_mode),
|
||||
traffic_sale=traffic_sale,
|
||||
hwid_devices_sale=hwid_devices_sale,
|
||||
@@ -281,6 +292,7 @@ async def create_webapp_payment_record(
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
return await create_base_payment_record(
|
||||
ctx.session,
|
||||
|
||||
@@ -156,6 +156,28 @@ def append_hwid_renewal_note(
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
def append_hwid_renewed_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_renewed_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,
|
||||
@@ -320,8 +342,37 @@ async def finalize_successful_payment(
|
||||
req.log_prefix,
|
||||
req.payment.payment_id,
|
||||
)
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
req.session,
|
||||
req.payment.payment_id,
|
||||
"activation_failed",
|
||||
)
|
||||
await req.session.commit()
|
||||
except Exception:
|
||||
await req.session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark payment %s activation_failed.",
|
||||
req.log_prefix,
|
||||
req.payment.payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
|
||||
await invalidate_webapp_user_caches(
|
||||
req.settings,
|
||||
req.user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"%s: failed to invalidate webapp caches for user %s.",
|
||||
req.log_prefix,
|
||||
req.user_id,
|
||||
)
|
||||
|
||||
db_user, language = await resolve_user_language(
|
||||
req.session,
|
||||
user_id=req.user_id,
|
||||
@@ -363,12 +414,20 @@ async def finalize_successful_payment(
|
||||
)
|
||||
)
|
||||
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 activation.get("hwid_devices_renewed_count"):
|
||||
success_text = append_hwid_renewed_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewed_count"),
|
||||
valid_until=final_end_date or activation.get("hwid_devices_renewed_until"),
|
||||
)
|
||||
else:
|
||||
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}"
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async def notify_user_payment_failed(
|
||||
message_key: str = "payment_failed",
|
||||
) -> None:
|
||||
"""Send the localized ``payment_failed`` text to the user; never raises."""
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
language = (
|
||||
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user