refactor: payments providers

This commit is contained in:
3252a8
2026-05-18 10:29:00 +03:00
parent fff7e90e14
commit 51c9c8b4f0
63 changed files with 7480 additions and 6843 deletions
@@ -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,
)