refactor: payments providers
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""Cross-provider helpers.
|
||||
|
||||
Provider modules at ``bot.payment_providers.<name>`` import the building
|
||||
blocks they need from here. Nothing in this package depends on any
|
||||
specific provider — it is the layer below them.
|
||||
"""
|
||||
|
||||
from .callbacks import (
|
||||
PaymentCallbackParts,
|
||||
describe_payment,
|
||||
edit_or_answer,
|
||||
notify_callback_parse_error,
|
||||
notify_payment_gateway_failure,
|
||||
notify_payment_record_failure,
|
||||
notify_service_unavailable,
|
||||
parse_payment_callback,
|
||||
payment_link_message_text,
|
||||
render_link_or_fail,
|
||||
render_payment_link,
|
||||
safe_callback_answer,
|
||||
safe_mark_failed_creation,
|
||||
safe_store_provider_payment_id,
|
||||
)
|
||||
from .common import (
|
||||
PaymentRecordAmounts,
|
||||
Translator,
|
||||
build_payment_description,
|
||||
build_payment_record_payload,
|
||||
create_base_payment_record,
|
||||
create_webapp_payment_record,
|
||||
decimal_amounts_equal,
|
||||
format_decimal_amount,
|
||||
format_human_units,
|
||||
format_number_for_payload,
|
||||
json_error,
|
||||
make_translator,
|
||||
mark_payment_failed_creation,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
sale_mode_base,
|
||||
sale_mode_is_hwid_devices,
|
||||
sale_mode_is_traffic,
|
||||
sale_mode_tariff_key,
|
||||
)
|
||||
from .http_client import (
|
||||
HttpClientMixin,
|
||||
SuccessCheck,
|
||||
first_value,
|
||||
http_ok,
|
||||
post_json_request,
|
||||
)
|
||||
from .success import (
|
||||
PaymentSuccessOutcome,
|
||||
PaymentSuccessRequest,
|
||||
SuccessMessage,
|
||||
build_success_message,
|
||||
finalize_successful_payment,
|
||||
is_traffic_sale_base,
|
||||
notify_admins_payment_received,
|
||||
resolve_inviter_name,
|
||||
resolve_user_language,
|
||||
send_success_message_to_user,
|
||||
)
|
||||
from .webapp import finalize_webapp_link_payment
|
||||
from .webhooks import (
|
||||
coerce_payment_db_id,
|
||||
lookup_payment_by_order_or_provider_id,
|
||||
notify_user_payment_failed,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"HttpClientMixin",
|
||||
"PaymentCallbackParts",
|
||||
"PaymentRecordAmounts",
|
||||
"PaymentSuccessOutcome",
|
||||
"PaymentSuccessRequest",
|
||||
"SuccessCheck",
|
||||
"SuccessMessage",
|
||||
"Translator",
|
||||
"build_payment_description",
|
||||
"build_payment_record_payload",
|
||||
"build_success_message",
|
||||
"coerce_payment_db_id",
|
||||
"create_base_payment_record",
|
||||
"create_webapp_payment_record",
|
||||
"decimal_amounts_equal",
|
||||
"describe_payment",
|
||||
"edit_or_answer",
|
||||
"finalize_successful_payment",
|
||||
"finalize_webapp_link_payment",
|
||||
"first_value",
|
||||
"format_decimal_amount",
|
||||
"format_human_units",
|
||||
"format_number_for_payload",
|
||||
"http_ok",
|
||||
"is_traffic_sale_base",
|
||||
"json_error",
|
||||
"lookup_payment_by_order_or_provider_id",
|
||||
"make_translator",
|
||||
"mark_payment_failed_creation",
|
||||
"notify_admins_payment_received",
|
||||
"notify_callback_parse_error",
|
||||
"notify_payment_gateway_failure",
|
||||
"notify_payment_record_failure",
|
||||
"notify_service_unavailable",
|
||||
"notify_user_payment_failed",
|
||||
"parse_payment_callback",
|
||||
"payment_failed",
|
||||
"payment_link_message_text",
|
||||
"payment_link_response",
|
||||
"payment_record_amounts",
|
||||
"payment_unavailable",
|
||||
"post_json_request",
|
||||
"render_link_or_fail",
|
||||
"render_payment_link",
|
||||
"resolve_inviter_name",
|
||||
"resolve_user_language",
|
||||
"safe_callback_answer",
|
||||
"safe_mark_failed_creation",
|
||||
"safe_store_provider_payment_id",
|
||||
"sale_mode_base",
|
||||
"sale_mode_is_hwid_devices",
|
||||
"sale_mode_is_traffic",
|
||||
"sale_mode_tariff_key",
|
||||
"send_success_message_to_user",
|
||||
]
|
||||
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import payment_dal
|
||||
from db.models import Payment
|
||||
|
||||
from .common import (
|
||||
Translator,
|
||||
build_payment_description,
|
||||
format_human_units,
|
||||
mark_payment_failed_creation,
|
||||
sale_mode_base,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PaymentCallbackParts:
|
||||
months: float
|
||||
price: float
|
||||
sale_mode: str
|
||||
|
||||
@property
|
||||
def human_value(self) -> str:
|
||||
return format_human_units(self.months)
|
||||
|
||||
@property
|
||||
def sale_base(self) -> str:
|
||||
return sale_mode_base(self.sale_mode)
|
||||
|
||||
|
||||
def parse_payment_callback(callback_data: str) -> Optional[PaymentCallbackParts]:
|
||||
"""Parse the ``<prefix>:<value>:<price>:<sale_mode>`` payload all providers use.
|
||||
|
||||
Returns ``None`` if the payload doesn't have the expected shape — callers
|
||||
answer with ``error_try_again`` in that case.
|
||||
"""
|
||||
try:
|
||||
_, data_payload = callback_data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return PaymentCallbackParts(months=months, price=price, sale_mode=sale_mode)
|
||||
|
||||
|
||||
async def safe_callback_answer(
|
||||
callback: types.CallbackQuery,
|
||||
text: Optional[str] = None,
|
||||
*,
|
||||
show_alert: bool = False,
|
||||
) -> None:
|
||||
"""``callback.answer`` that never raises (Telegram occasionally 400s)."""
|
||||
try:
|
||||
if text is None:
|
||||
await callback.answer()
|
||||
else:
|
||||
await callback.answer(text, show_alert=show_alert)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def edit_or_answer(
|
||||
callback: types.CallbackQuery,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup=None,
|
||||
disable_web_page_preview: bool = False,
|
||||
log_prefix: str = "payment_providers",
|
||||
) -> None:
|
||||
"""Edit the callback message if possible, else send a fresh reply."""
|
||||
if not callback.message:
|
||||
return
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
logging.warning("%s: failed to edit message (%s), sending new one.", log_prefix, exc)
|
||||
try:
|
||||
await callback.message.answer(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str:
|
||||
"""Shortcut around ``build_payment_description`` for callback usage."""
|
||||
return build_payment_description(
|
||||
translator,
|
||||
months=parts.months,
|
||||
sale_mode=parts.sale_mode,
|
||||
human_value=parts.human_value,
|
||||
)
|
||||
|
||||
|
||||
def payment_link_message_text(
|
||||
translator: Translator,
|
||||
parts: PaymentCallbackParts,
|
||||
*,
|
||||
lead_text: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build the ``payment_link_message`` text (with optional lead block)."""
|
||||
traffic_like = sale_mode_base(parts.sale_mode) in {
|
||||
"traffic",
|
||||
"traffic_package",
|
||||
"topup",
|
||||
"premium_topup",
|
||||
}
|
||||
key = (
|
||||
"payment_link_message_traffic"
|
||||
if traffic_like
|
||||
else "payment_link_message"
|
||||
)
|
||||
body = translator(
|
||||
key,
|
||||
months=int(parts.months),
|
||||
traffic_gb=parts.human_value,
|
||||
)
|
||||
if lead_text:
|
||||
return f"{lead_text}\n\n{body}"
|
||||
return body
|
||||
|
||||
|
||||
async def render_payment_link(
|
||||
callback: types.CallbackQuery,
|
||||
*,
|
||||
translator: Translator,
|
||||
current_lang: str,
|
||||
i18n: Optional[JsonI18n],
|
||||
parts: PaymentCallbackParts,
|
||||
payment_url: str,
|
||||
lead_text: Optional[str] = None,
|
||||
back_text_key: str = "back_to_payment_methods_button",
|
||||
log_prefix: str = "payment_providers",
|
||||
) -> None:
|
||||
"""Show the payment link with the standard back button and shared fallbacks."""
|
||||
text = payment_link_message_text(translator, parts, lead_text=lead_text)
|
||||
keyboard = get_payment_url_keyboard(
|
||||
payment_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{parts.human_value}",
|
||||
back_text_key=back_text_key,
|
||||
)
|
||||
await edit_or_answer(
|
||||
callback,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
await safe_callback_answer(callback)
|
||||
|
||||
|
||||
async def notify_service_unavailable(
|
||||
callback: types.CallbackQuery,
|
||||
translator: Translator,
|
||||
) -> None:
|
||||
"""Render the standard ``payment_service_unavailable`` UX."""
|
||||
await safe_callback_answer(
|
||||
callback,
|
||||
translator("payment_service_unavailable_alert"),
|
||||
show_alert=True,
|
||||
)
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(translator("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def notify_callback_parse_error(
|
||||
callback: types.CallbackQuery,
|
||||
translator: Translator,
|
||||
) -> None:
|
||||
"""The 4-line "callback payload looked wrong" guard every provider repeats."""
|
||||
await safe_callback_answer(callback, translator("error_try_again"), show_alert=True)
|
||||
|
||||
|
||||
async def notify_payment_record_failure(
|
||||
callback: types.CallbackQuery,
|
||||
translator: Translator,
|
||||
) -> None:
|
||||
"""Both error_creating_payment_record + error_try_again shown after DB failure."""
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(translator("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
await safe_callback_answer(callback, translator("error_try_again"), show_alert=True)
|
||||
|
||||
|
||||
async def notify_payment_gateway_failure(
|
||||
callback: types.CallbackQuery,
|
||||
translator: Translator,
|
||||
) -> None:
|
||||
"""``error_payment_gateway`` shown both inline and as alert."""
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(translator("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
await safe_callback_answer(
|
||||
callback,
|
||||
translator("error_payment_gateway"),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
async def safe_store_provider_payment_id(
|
||||
session: AsyncSession,
|
||||
payment: Payment,
|
||||
*,
|
||||
provider_payment_id: str,
|
||||
new_status: Optional[str] = None,
|
||||
log_prefix: str,
|
||||
) -> bool:
|
||||
"""Persist ``(provider_payment_id, status)`` on the payment with rollback-on-fail.
|
||||
|
||||
Returns True on success; logs and rolls back on failure. ``new_status``
|
||||
defaults to the payment's existing status (used after a successful API call
|
||||
that doesn't change the pending state).
|
||||
"""
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
str(provider_payment_id),
|
||||
new_status or payment.status,
|
||||
)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to store provider payment id for payment %s.",
|
||||
log_prefix,
|
||||
payment.payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def safe_mark_failed_creation(
|
||||
session: AsyncSession,
|
||||
payment: Payment,
|
||||
*,
|
||||
log_prefix: str,
|
||||
) -> None:
|
||||
"""Mark the payment as ``failed_creation``; swallow + log on failure."""
|
||||
try:
|
||||
await mark_payment_failed_creation(session, payment.payment_id)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark payment %s as failed_creation.",
|
||||
log_prefix,
|
||||
payment.payment_id,
|
||||
)
|
||||
|
||||
|
||||
async def render_link_or_fail(
|
||||
callback: types.CallbackQuery,
|
||||
*,
|
||||
translator: Translator,
|
||||
current_lang: str,
|
||||
i18n: Optional[JsonI18n],
|
||||
parts: "PaymentCallbackParts",
|
||||
session: AsyncSession,
|
||||
payment: Payment,
|
||||
api_success: bool,
|
||||
payment_url: Optional[str],
|
||||
provider_payment_id: Optional[str] = None,
|
||||
new_status: Optional[str] = None,
|
||||
lead_text: Optional[str] = None,
|
||||
log_prefix: str,
|
||||
) -> None:
|
||||
"""Finalize the link-based callback flow after the provider API responded.
|
||||
|
||||
Persists the provider payment id (when one was returned), shows the
|
||||
payment link, or falls through to ``error_payment_gateway`` and marks the
|
||||
payment as ``failed_creation``. Every link-style provider used to inline
|
||||
this same sequence.
|
||||
"""
|
||||
if api_success and provider_payment_id:
|
||||
await safe_store_provider_payment_id(
|
||||
session,
|
||||
payment,
|
||||
provider_payment_id=provider_payment_id,
|
||||
new_status=new_status,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
if api_success and payment_url:
|
||||
await render_payment_link(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
payment_url=payment_url,
|
||||
lead_text=lead_text,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
return
|
||||
|
||||
await safe_mark_failed_creation(session, payment, log_prefix=log_prefix)
|
||||
await notify_payment_gateway_failure(callback, translator)
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.dal import payment_dal
|
||||
from db.models import Payment
|
||||
|
||||
from ..base import WebAppPaymentContext
|
||||
|
||||
Translator = Callable[..., str]
|
||||
|
||||
|
||||
def make_translator(i18n: Any, language: str) -> Translator:
|
||||
"""Return a ``_(key, **kw)`` callable that falls back to the key when i18n is absent."""
|
||||
|
||||
def _(key: str, **kwargs: Any) -> str:
|
||||
if i18n is None:
|
||||
return key
|
||||
return i18n.gettext(language, key, **kwargs)
|
||||
|
||||
return _
|
||||
|
||||
|
||||
def format_decimal_amount(amount: Any, places: int = 2) -> Decimal:
|
||||
"""Quantize ``amount`` to the given decimal places using bank rounding."""
|
||||
return Decimal(str(amount)).quantize(Decimal(10) ** -places, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool:
|
||||
"""True when both values round to the same fixed-point representation."""
|
||||
return format_decimal_amount(left, places) == format_decimal_amount(right, places)
|
||||
|
||||
|
||||
def format_human_units(value: Any) -> str:
|
||||
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
|
||||
numeric = float(value)
|
||||
return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}"
|
||||
|
||||
|
||||
def build_payment_description(
|
||||
translator: Translator,
|
||||
*,
|
||||
months: Any,
|
||||
sale_mode: str,
|
||||
human_value: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Render the standard user-visible payment description.
|
||||
|
||||
Mirrors the branching every callback handler used to repeat
|
||||
(traffic / hwid_devices / subscription).
|
||||
"""
|
||||
base = sale_mode_base(sale_mode)
|
||||
if base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
return translator(
|
||||
"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"}:
|
||||
return translator("payment_description_hwid_devices", count=int(float(months)))
|
||||
return translator("payment_description_subscription", months=int(float(months)))
|
||||
|
||||
|
||||
def build_payment_record_payload(
|
||||
*,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str,
|
||||
status: str,
|
||||
description: str,
|
||||
months: Any,
|
||||
provider: str,
|
||||
sale_mode: str,
|
||||
) -> dict:
|
||||
"""Assemble the payment-record dict that every callback handler used to inline.
|
||||
|
||||
For the ``traffic`` sale modes, ``purchased_gb`` is taken from ``months``
|
||||
(callbacks encode the GB amount in the ``months`` slot); webapp creators
|
||||
use the ``payment_record_amounts`` helper directly to split the two.
|
||||
"""
|
||||
base = sale_mode_base(sale_mode)
|
||||
is_traffic = sale_mode_is_traffic(sale_mode)
|
||||
is_hwid = sale_mode_is_hwid_devices(sale_mode)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"status": status,
|
||||
"description": description,
|
||||
"subscription_duration_months": int(float(months)) if base == "subscription" else None,
|
||||
"provider": provider,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PaymentRecordAmounts:
|
||||
months: int
|
||||
purchased_gb: Optional[float]
|
||||
purchased_hwid_devices: Optional[int]
|
||||
tariff_key: Optional[str]
|
||||
traffic_sale: bool
|
||||
hwid_devices_sale: bool
|
||||
|
||||
|
||||
def sale_mode_base(sale_mode: str) -> str:
|
||||
return str(sale_mode or "").split("@", 1)[0].split("|", 1)[0]
|
||||
|
||||
|
||||
def sale_mode_is_traffic(sale_mode: str) -> bool:
|
||||
return sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
|
||||
|
||||
def sale_mode_is_hwid_devices(sale_mode: str) -> bool:
|
||||
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
|
||||
|
||||
|
||||
def sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
|
||||
return str(sale_mode or "").split("@", 1)[1] if "@" in str(sale_mode or "") else None
|
||||
|
||||
|
||||
def format_number_for_payload(value: Any) -> str:
|
||||
value_float = float(value)
|
||||
return str(int(value_float)) if value_float.is_integer() else f"{value_float:g}"
|
||||
|
||||
|
||||
def payment_record_amounts(
|
||||
*,
|
||||
months: Any,
|
||||
sale_mode: str,
|
||||
traffic_gb: Optional[float] = 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
|
||||
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,
|
||||
tariff_key=sale_mode_tariff_key(sale_mode),
|
||||
traffic_sale=traffic_sale,
|
||||
hwid_devices_sale=hwid_devices_sale,
|
||||
)
|
||||
|
||||
|
||||
def json_error(status: int, code: str, message: str) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
|
||||
|
||||
|
||||
def payment_unavailable() -> web.Response:
|
||||
return json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
|
||||
|
||||
def payment_failed(message: str = "Failed to create payment") -> web.Response:
|
||||
return json_error(502, "payment_failed", message)
|
||||
|
||||
|
||||
def payment_link_response(
|
||||
*,
|
||||
payment_url: str,
|
||||
payment_id: Optional[int],
|
||||
action: str = "open_link",
|
||||
) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"payment_url": payment_url,
|
||||
"payment_id": payment_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def create_base_payment_record(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str,
|
||||
status: str,
|
||||
description: str,
|
||||
months: int,
|
||||
provider: str,
|
||||
sale_mode: Optional[str] = None,
|
||||
tariff_key: Optional[str] = None,
|
||||
purchased_gb: Optional[float] = None,
|
||||
purchased_hwid_devices: Optional[int] = None,
|
||||
) -> Payment:
|
||||
payment = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"status": status,
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider": provider,
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": tariff_key,
|
||||
"purchased_gb": purchased_gb,
|
||||
"purchased_hwid_devices": purchased_hwid_devices,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return payment
|
||||
|
||||
|
||||
async def create_webapp_payment_record(
|
||||
ctx: WebAppPaymentContext,
|
||||
*,
|
||||
amount: float,
|
||||
currency: str,
|
||||
status: str,
|
||||
provider: str,
|
||||
) -> Payment:
|
||||
amounts = payment_record_amounts(
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
)
|
||||
return await create_base_payment_record(
|
||||
ctx.session,
|
||||
user_id=ctx.user_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
status=status,
|
||||
description=ctx.description,
|
||||
months=amounts.months,
|
||||
provider=provider,
|
||||
sale_mode=ctx.sale_mode,
|
||||
tariff_key=amounts.tariff_key,
|
||||
purchased_gb=amounts.purchased_gb,
|
||||
purchased_hwid_devices=amounts.purchased_hwid_devices,
|
||||
)
|
||||
|
||||
|
||||
async def mark_payment_failed_creation(session: AsyncSession, payment_id: int) -> None:
|
||||
await payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation")
|
||||
await session.commit()
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
|
||||
SuccessCheck = Callable[[int, Any], bool]
|
||||
|
||||
|
||||
def http_ok(status: int, _body: Any) -> bool:
|
||||
"""Default success criterion — HTTP 200 with any body."""
|
||||
return status == 200
|
||||
|
||||
|
||||
async def post_json_request(
|
||||
session: ClientSession,
|
||||
url: str,
|
||||
*,
|
||||
body: Any,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
log_prefix: str,
|
||||
is_success: SuccessCheck = http_ok,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""Centralized JSON-POST every HTTP-API provider used to inline ~25 lines for.
|
||||
|
||||
On transport failure, JSON decode failure, or rejected ``is_success`` check,
|
||||
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
|
||||
can decide what to do (typically: mark the payment as ``failed_creation``).
|
||||
"""
|
||||
try:
|
||||
async with session.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=dict(headers) if headers else None,
|
||||
) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": "invalid_json",
|
||||
"raw": response_text,
|
||||
}
|
||||
if not is_success(response.status, response_data):
|
||||
logging.error(
|
||||
"%s: API returned error (status=%s, body=%s)",
|
||||
log_prefix,
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.exception("%s: request failed.", log_prefix)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
|
||||
def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]:
|
||||
"""Return the first non-empty value among ``keys`` (cast to ``str``)."""
|
||||
if not data:
|
||||
return None
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
class HttpClientMixin:
|
||||
"""Shared lazy ``aiohttp.ClientSession`` lifecycle for provider services.
|
||||
|
||||
Each subclass calls ``self._init_http_client(total_timeout=...)`` from
|
||||
``__init__`` and inherits ``_get_session`` / ``close``. The session is
|
||||
created on first use and recreated transparently if it was closed.
|
||||
"""
|
||||
|
||||
_timeout: ClientTimeout
|
||||
_session: Optional[ClientSession]
|
||||
|
||||
def _init_http_client(self, *, total_timeout: float = 20.0) -> None:
|
||||
self._timeout = ClientTimeout(total=total_timeout)
|
||||
self._session = None
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(timeout=self._timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
@@ -0,0 +1,383 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from db.dal import payment_dal, user_dal
|
||||
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"}
|
||||
|
||||
|
||||
def is_traffic_sale_base(sale_base: str) -> bool:
|
||||
return sale_base in _TRAFFIC_MODES
|
||||
|
||||
|
||||
async def resolve_user_language(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
db_user: Optional[User],
|
||||
settings: Any,
|
||||
) -> tuple[Optional[User], str]:
|
||||
"""Return the loaded user and the language to use for messaging."""
|
||||
if db_user is None:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
language = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
return db_user, language
|
||||
|
||||
|
||||
async def resolve_inviter_name(
|
||||
session: AsyncSession,
|
||||
translator: Translator,
|
||||
db_user: Optional[User],
|
||||
) -> str:
|
||||
"""Return a display name for the user's inviter, or the localized placeholder."""
|
||||
placeholder = translator("friend_placeholder")
|
||||
if not db_user or not db_user.referred_by_id:
|
||||
return placeholder
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if not inviter:
|
||||
return placeholder
|
||||
if inviter.first_name:
|
||||
safe_name = sanitize_display_name(inviter.first_name)
|
||||
if safe_name:
|
||||
return safe_name
|
||||
if inviter.username:
|
||||
return username_for_display(inviter.username, with_at=False)
|
||||
return placeholder
|
||||
|
||||
|
||||
@dataclass
|
||||
class SuccessMessage:
|
||||
"""Inputs for ``build_success_message``."""
|
||||
|
||||
translator: Translator
|
||||
sale_mode: str
|
||||
months: Any
|
||||
base_end_date: Optional[datetime]
|
||||
final_end_date: Optional[datetime]
|
||||
config_link_text: str
|
||||
applied_referee_bonus_days: int = 0
|
||||
applied_promo_bonus_days: int = 0
|
||||
inviter_name: Optional[str] = None
|
||||
fallback_date_text: str = ""
|
||||
|
||||
|
||||
def _fmt_date(dt: Optional[datetime], fallback: str) -> str:
|
||||
return dt.strftime("%Y-%m-%d") if dt else fallback
|
||||
|
||||
|
||||
def build_success_message(payload: SuccessMessage) -> str:
|
||||
"""Render the post-payment user-facing text.
|
||||
|
||||
Picks one of: ``payment_successful_traffic_full`` /
|
||||
``payment_successful_with_referral_bonus_full`` /
|
||||
``payment_successful_with_promo_full`` / ``payment_successful_full``.
|
||||
"""
|
||||
base = sale_mode_base(payload.sale_mode)
|
||||
_ = payload.translator
|
||||
end_text = _fmt_date(payload.final_end_date, payload.fallback_date_text)
|
||||
|
||||
if is_traffic_sale_base(base):
|
||||
return _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=format_human_units(payload.months),
|
||||
end_date=end_text,
|
||||
config_link=payload.config_link_text,
|
||||
)
|
||||
if payload.applied_referee_bonus_days and payload.final_end_date:
|
||||
base_end_text = _fmt_date(payload.base_end_date or payload.final_end_date, end_text)
|
||||
return _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=payload.months,
|
||||
base_end_date=base_end_text,
|
||||
bonus_days=payload.applied_referee_bonus_days,
|
||||
final_end_date=end_text,
|
||||
inviter_name=payload.inviter_name or _("friend_placeholder"),
|
||||
config_link=payload.config_link_text,
|
||||
)
|
||||
if payload.applied_promo_bonus_days and payload.final_end_date:
|
||||
return _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=payload.months,
|
||||
bonus_days=payload.applied_promo_bonus_days,
|
||||
end_date=end_text,
|
||||
config_link=payload.config_link_text,
|
||||
)
|
||||
return _(
|
||||
"payment_successful_full",
|
||||
months=payload.months,
|
||||
end_date=end_text,
|
||||
config_link=payload.config_link_text,
|
||||
)
|
||||
|
||||
|
||||
async def send_success_message_to_user(
|
||||
*,
|
||||
bot: Bot,
|
||||
user_id: int,
|
||||
text: str,
|
||||
language: str,
|
||||
i18n: Any,
|
||||
settings: Any,
|
||||
config_link_display: Optional[str],
|
||||
connect_button_url: Optional[str],
|
||||
include_keyboard: bool = True,
|
||||
log_prefix: str = "payment_providers",
|
||||
) -> None:
|
||||
"""Send the rendered success text with the standard connect keyboard."""
|
||||
markup = None
|
||||
if include_keyboard:
|
||||
markup = get_connect_and_main_keyboard(
|
||||
language,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("%s: failed to notify user %s.", log_prefix, user_id)
|
||||
|
||||
|
||||
async def notify_admins_payment_received(
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Any,
|
||||
i18n: Any,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str,
|
||||
months_for_admin: int,
|
||||
traffic_gb_for_admin: Optional[float],
|
||||
payment_provider: str,
|
||||
username: Optional[str],
|
||||
traffic_is_premium: bool,
|
||||
tariff_key: Optional[str],
|
||||
log_prefix: str = "payment_providers",
|
||||
) -> None:
|
||||
"""Push the standard ``notify_payment_received`` to the admin log channel."""
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
months=months_for_admin,
|
||||
traffic_gb=traffic_gb_for_admin,
|
||||
payment_provider=payment_provider,
|
||||
username=username,
|
||||
traffic_is_premium=traffic_is_premium,
|
||||
tariff_key=tariff_key,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("%s: failed to notify admins.", log_prefix)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentSuccessRequest:
|
||||
"""All the inputs ``finalize_successful_payment`` needs."""
|
||||
|
||||
bot: Bot
|
||||
settings: Any
|
||||
i18n: Any
|
||||
session: AsyncSession
|
||||
subscription_service: Any
|
||||
referral_service: Any
|
||||
|
||||
payment: Payment
|
||||
user_id: int
|
||||
amount: float
|
||||
currency: str
|
||||
|
||||
sale_mode: str
|
||||
months: Any
|
||||
traffic_amount: Optional[float]
|
||||
|
||||
provider_subscription: str
|
||||
provider_notification: str
|
||||
|
||||
db_user: Optional[User] = None
|
||||
log_prefix: str = "payment_providers"
|
||||
activation_extra_kwargs: dict = field(default_factory=dict)
|
||||
skip_keyboard: bool = False
|
||||
text_prefix: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentSuccessOutcome:
|
||||
activation: Optional[dict]
|
||||
referral_bonus: Optional[dict]
|
||||
final_end_date: Optional[datetime]
|
||||
applied_referee_bonus_days: int
|
||||
applied_promo_bonus_days: int
|
||||
db_user: Optional[User]
|
||||
language: str
|
||||
|
||||
|
||||
async def finalize_successful_payment(
|
||||
req: PaymentSuccessRequest,
|
||||
) -> Optional[PaymentSuccessOutcome]:
|
||||
"""Activate the subscription, apply referral bonus, notify user + admins.
|
||||
|
||||
Returns ``None`` if the activation pipeline failed mid-way (errors are
|
||||
logged and the session is rolled back). On success returns an outcome
|
||||
object so callers can drive extra side-effects (e.g. yookassa LKNPD
|
||||
receipts) using the same activation result.
|
||||
"""
|
||||
base = sale_mode_base(req.sale_mode)
|
||||
is_subscription = base == "subscription"
|
||||
is_traffic = is_traffic_sale_base(base)
|
||||
|
||||
activation_months = (
|
||||
int(float(req.months)) if is_subscription else int(float(req.traffic_amount or req.months))
|
||||
)
|
||||
traffic_gb_for_activation = (
|
||||
float(req.traffic_amount or req.months) if is_traffic else None
|
||||
)
|
||||
|
||||
try:
|
||||
activation = await req.subscription_service.activate_subscription(
|
||||
req.session,
|
||||
req.user_id,
|
||||
activation_months,
|
||||
req.amount,
|
||||
req.payment.payment_id,
|
||||
provider=req.provider_subscription,
|
||||
sale_mode=req.sale_mode,
|
||||
traffic_gb=traffic_gb_for_activation,
|
||||
**req.activation_extra_kwargs,
|
||||
)
|
||||
referral_bonus = None
|
||||
if is_subscription:
|
||||
referral_bonus = await req.referral_service.apply_referral_bonuses_for_payment(
|
||||
req.session,
|
||||
req.user_id,
|
||||
activation_months or 1,
|
||||
current_payment_db_id=req.payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
await req.session.commit()
|
||||
except Exception:
|
||||
await req.session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to activate subscription for payment %s.",
|
||||
req.log_prefix,
|
||||
req.payment.payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
db_user, language = await resolve_user_language(
|
||||
req.session,
|
||||
user_id=req.user_id,
|
||||
db_user=req.db_user,
|
||||
settings=req.settings,
|
||||
)
|
||||
translator = make_translator(req.i18n, language)
|
||||
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(
|
||||
req.settings, raw_config_link
|
||||
)
|
||||
config_link_text = config_link_display or translator("config_link_not_available")
|
||||
|
||||
base_end_date = activation.get("end_date") if activation else None
|
||||
final_end_date = base_end_date
|
||||
applied_referee_bonus_days = 0
|
||||
applied_promo_bonus_days = (
|
||||
activation.get("applied_promo_bonus_days", 0) if activation else 0
|
||||
)
|
||||
|
||||
inviter_name: Optional[str] = None
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end_date = referral_bonus["referee_new_end_date"]
|
||||
applied_referee_bonus_days = referral_bonus.get("referee_bonus_applied_days", 0) or 0
|
||||
inviter_name = await resolve_inviter_name(req.session, translator, db_user)
|
||||
|
||||
success_text = build_success_message(
|
||||
SuccessMessage(
|
||||
translator=translator,
|
||||
sale_mode=req.sale_mode,
|
||||
months=(
|
||||
activation_months
|
||||
if is_subscription
|
||||
else format_human_units(req.traffic_amount or req.months)
|
||||
),
|
||||
base_end_date=base_end_date,
|
||||
final_end_date=final_end_date,
|
||||
config_link_text=config_link_text,
|
||||
applied_referee_bonus_days=applied_referee_bonus_days,
|
||||
applied_promo_bonus_days=applied_promo_bonus_days,
|
||||
inviter_name=inviter_name,
|
||||
)
|
||||
)
|
||||
if req.text_prefix:
|
||||
success_text = f"{req.text_prefix}\n{success_text}"
|
||||
|
||||
await send_success_message_to_user(
|
||||
bot=req.bot,
|
||||
user_id=req.user_id,
|
||||
text=success_text,
|
||||
language=language,
|
||||
i18n=req.i18n,
|
||||
settings=req.settings,
|
||||
config_link_display=config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
include_keyboard=not req.skip_keyboard,
|
||||
log_prefix=req.log_prefix,
|
||||
)
|
||||
|
||||
refreshed_payment = await payment_dal.get_payment_by_db_id(
|
||||
req.session, req.payment.payment_id
|
||||
)
|
||||
tariff_key = getattr(refreshed_payment or req.payment, "tariff_key", None)
|
||||
|
||||
await notify_admins_payment_received(
|
||||
bot=req.bot,
|
||||
settings=req.settings,
|
||||
i18n=req.i18n,
|
||||
user_id=req.user_id,
|
||||
amount=req.amount,
|
||||
currency=req.currency,
|
||||
months_for_admin=activation_months if is_subscription else 0,
|
||||
traffic_gb_for_admin=traffic_gb_for_activation,
|
||||
payment_provider=req.provider_notification,
|
||||
username=db_user.username if db_user else None,
|
||||
traffic_is_premium=base == "premium_topup",
|
||||
tariff_key=tariff_key,
|
||||
log_prefix=req.log_prefix,
|
||||
)
|
||||
|
||||
return PaymentSuccessOutcome(
|
||||
activation=activation,
|
||||
referral_bonus=referral_bonus,
|
||||
final_end_date=final_end_date,
|
||||
applied_referee_bonus_days=applied_referee_bonus_days,
|
||||
applied_promo_bonus_days=applied_promo_bonus_days,
|
||||
db_user=db_user,
|
||||
language=language,
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.dal import payment_dal
|
||||
from db.models import Payment
|
||||
|
||||
from .common import mark_payment_failed_creation, payment_failed, payment_link_response
|
||||
|
||||
|
||||
async def finalize_webapp_link_payment(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
payment: Payment,
|
||||
api_success: bool,
|
||||
payment_url: Optional[str],
|
||||
provider_payment_id: Optional[str] = None,
|
||||
new_status: Optional[str] = None,
|
||||
log_prefix: str,
|
||||
) -> web.Response:
|
||||
"""The trailing "persist id → return link or fail" used by every link-style webapp creator.
|
||||
|
||||
Mirrors :func:`render_link_or_fail` but for the webapp HTTP context: instead
|
||||
of editing a Telegram message it returns either ``payment_link_response``
|
||||
or ``payment_failed``. Provider modules just call:
|
||||
|
||||
payment = await create_webapp_payment_record(ctx, ...)
|
||||
success, data = await service.create_xxx(...)
|
||||
return await finalize_webapp_link_payment(
|
||||
session=ctx.session,
|
||||
payment=payment,
|
||||
api_success=success,
|
||||
payment_url=first_value(data, "url", "payment_url"),
|
||||
provider_payment_id=first_value(data, "id"),
|
||||
log_prefix="Wata",
|
||||
)
|
||||
"""
|
||||
if api_success and provider_payment_id:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
str(provider_payment_id),
|
||||
new_status or payment.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to persist provider payment id for payment %s.",
|
||||
log_prefix,
|
||||
payment.payment_id,
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
try:
|
||||
await mark_payment_failed_creation(session, payment.payment_id)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark payment %s as failed_creation.",
|
||||
log_prefix,
|
||||
payment.payment_id,
|
||||
)
|
||||
return payment_failed()
|
||||
|
||||
return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id)
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.dal import payment_dal, user_dal
|
||||
from db.models import Payment
|
||||
|
||||
from .common import make_translator
|
||||
|
||||
|
||||
def coerce_payment_db_id(order_id_raw: Any) -> Optional[int]:
|
||||
"""Pull a numeric DB id out of a webhook's ``orderId``/``order_id`` field."""
|
||||
if isinstance(order_id_raw, int):
|
||||
return order_id_raw
|
||||
if isinstance(order_id_raw, str) and order_id_raw.isdigit():
|
||||
return int(order_id_raw)
|
||||
return None
|
||||
|
||||
|
||||
async def lookup_payment_by_order_or_provider_id(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
order_id_raw: Any = None,
|
||||
provider_payment_id: Optional[str] = None,
|
||||
) -> Optional[Payment]:
|
||||
"""Find a payment by DB id first, fall back to provider id.
|
||||
|
||||
Returns ``None`` so callers stay in charge of the not-found response.
|
||||
"""
|
||||
payment_db_id = coerce_payment_db_id(order_id_raw)
|
||||
payment: Optional[Payment] = None
|
||||
if payment_db_id is not None:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment and provider_payment_id:
|
||||
payment = await payment_dal.get_payment_by_provider_payment_id(
|
||||
session, provider_payment_id
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def notify_user_payment_failed(
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Any,
|
||||
i18n: Any,
|
||||
session: AsyncSession,
|
||||
payment: Payment,
|
||||
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)
|
||||
language = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
translator = make_translator(i18n, language)
|
||||
try:
|
||||
await bot.send_message(payment.user_id, translator(message_key))
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Webhook helper: failed to notify user %s about %s.",
|
||||
payment.user_id,
|
||||
message_key,
|
||||
)
|
||||
Reference in New Issue
Block a user