refactor: payments providers
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiohttp import web
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext
|
||||
from .shared import (
|
||||
HttpClientMixin,
|
||||
PaymentSuccessRequest,
|
||||
build_payment_record_payload,
|
||||
create_webapp_payment_record,
|
||||
decimal_amounts_equal,
|
||||
describe_payment,
|
||||
finalize_successful_payment,
|
||||
finalize_webapp_link_payment,
|
||||
first_value,
|
||||
format_decimal_amount,
|
||||
lookup_payment_by_order_or_provider_id,
|
||||
make_translator,
|
||||
notify_callback_parse_error,
|
||||
notify_payment_record_failure,
|
||||
notify_service_unavailable,
|
||||
notify_user_payment_failed,
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
post_json_request,
|
||||
render_link_or_fail,
|
||||
)
|
||||
|
||||
router = Router(name="user_subscription_payments_wata_router")
|
||||
_LOG = "wata"
|
||||
|
||||
|
||||
class WataService(HttpClientMixin):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
default_return_url: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.base_url = (settings.WATA_BASE_URL or "https://api.wata.pro/api/h2h").rstrip("/")
|
||||
self.api_token = settings.WATA_API_TOKEN or ""
|
||||
self.return_url = settings.WATA_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||
self.failed_url = settings.WATA_FAILED_URL or self.return_url
|
||||
self.payment_link_ttl_days = settings.WATA_PAYMENT_LINK_TTL_DAYS
|
||||
self.verify_webhook_signature = settings.WATA_WEBHOOK_VERIFY_SIGNATURE
|
||||
self._public_key_pem = settings.WATA_PUBLIC_KEY
|
||||
|
||||
self._init_http_client(total_timeout=20)
|
||||
self.configured: bool = bool(settings.WATA_ENABLED and self.api_token)
|
||||
if not self.configured:
|
||||
logging.warning("WataService initialized but not fully configured. Payments disabled.")
|
||||
|
||||
def _auth_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def create_payment_link(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("WataService is not configured. Cannot create payment link.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
session = await self._get_session()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)
|
||||
body: Dict[str, Any] = {
|
||||
"amount": float(format_decimal_amount(amount)),
|
||||
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
|
||||
"description": description,
|
||||
"orderId": str(payment_db_id),
|
||||
"successRedirectUrl": self.return_url,
|
||||
"failRedirectUrl": self.failed_url,
|
||||
"expirationDateTime": expires_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
return await post_json_request(
|
||||
session,
|
||||
f"{self.base_url}/links",
|
||||
body=body,
|
||||
headers=self._auth_headers(),
|
||||
log_prefix="Wata create_payment_link",
|
||||
)
|
||||
|
||||
async def _get_public_key_pem(self) -> Optional[str]:
|
||||
if self._public_key_pem:
|
||||
return self._public_key_pem.replace("\\n", "\n")
|
||||
|
||||
session = await self._get_session()
|
||||
try:
|
||||
async with session.get(f"{self.base_url}/public-key") as response:
|
||||
if response.status != 200:
|
||||
logging.error("Wata public key request failed with status %s", response.status)
|
||||
return None
|
||||
data = await response.json()
|
||||
value = data.get("value") if isinstance(data, dict) else None
|
||||
if isinstance(value, str) and value.strip():
|
||||
self._public_key_pem = value
|
||||
return value.replace("\\n", "\n")
|
||||
except Exception:
|
||||
logging.exception("Wata public key request failed.")
|
||||
return None
|
||||
|
||||
async def _verify_signature(self, raw_body: bytes, signature_header: str) -> bool:
|
||||
if not signature_header:
|
||||
return False
|
||||
public_key_pem = await self._get_public_key_pem()
|
||||
if not public_key_pem:
|
||||
return False
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
|
||||
signature = base64.b64decode(signature_header)
|
||||
public_key.verify(signature, raw_body, padding.PKCS1v15(), hashes.SHA512())
|
||||
return True
|
||||
except (InvalidSignature, ValueError, TypeError):
|
||||
logging.warning("Wata webhook: invalid signature.")
|
||||
return False
|
||||
except Exception:
|
||||
logging.exception("Wata webhook: signature verification failed.")
|
||||
return False
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="wata_disabled")
|
||||
|
||||
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
|
||||
if self.settings.wata_trusted_ips and not ip_in_allowlist(
|
||||
client_ip, self.settings.wata_trusted_ips
|
||||
):
|
||||
logging.warning("Wata webhook denied from unauthorized IP source.")
|
||||
return web.Response(status=403, text="forbidden")
|
||||
|
||||
raw_body = await request.read()
|
||||
if self.verify_webhook_signature:
|
||||
signature = request.headers.get("X-Signature", "")
|
||||
if not await self._verify_signature(raw_body, signature):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode("utf-8"))
|
||||
except Exception:
|
||||
logging.exception("Wata webhook: failed to parse JSON.")
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
transaction_id = str(payload.get("transactionId") or "").strip()
|
||||
status = str(payload.get("transactionStatus") or "").strip().lower()
|
||||
order_id_raw = payload.get("orderId")
|
||||
amount_raw = payload.get("amount")
|
||||
currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
if not status or not (transaction_id or order_id_raw):
|
||||
logging.error("Wata webhook: missing transaction status or ids: %s", payload)
|
||||
return web.Response(status=400, text="missing_fields")
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = await lookup_payment_by_order_or_provider_id(
|
||||
session,
|
||||
order_id_raw=order_id_raw,
|
||||
provider_payment_id=transaction_id or None,
|
||||
)
|
||||
if not payment:
|
||||
logging.error(
|
||||
"Wata webhook: payment not found (order_id=%s, transaction_id=%s)",
|
||||
order_id_raw,
|
||||
transaction_id,
|
||||
)
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.status == "succeeded" and status == "paid":
|
||||
return web.Response(text="ok")
|
||||
|
||||
resolved_transaction_id = transaction_id or str(payment.payment_id)
|
||||
|
||||
if status == "paid":
|
||||
if amount_raw is not None:
|
||||
try:
|
||||
if not decimal_amounts_equal(amount_raw, payment.amount):
|
||||
logging.warning(
|
||||
"Wata webhook: amount mismatch for payment %s "
|
||||
"(expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
format_decimal_amount(payment.amount),
|
||||
format_decimal_amount(amount_raw),
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.warning(
|
||||
"Wata webhook: failed to compare amounts for %s: %s",
|
||||
payment.payment_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
resolved_transaction_id,
|
||||
"succeeded",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Wata webhook: failed to mark payment %s as succeeded.",
|
||||
resolved_transaction_id,
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
subscription_service=self.subscription_service,
|
||||
referral_service=self.referral_service,
|
||||
payment=payment,
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=str(currency),
|
||||
sale_mode=sale_mode,
|
||||
months=payment_units,
|
||||
traffic_amount=float(payment_units),
|
||||
provider_subscription="wata",
|
||||
provider_notification="wata",
|
||||
db_user=payment.user,
|
||||
log_prefix="Wata webhook",
|
||||
)
|
||||
)
|
||||
if outcome is None:
|
||||
return web.Response(status=500, text="processing_error")
|
||||
return web.Response(text="ok")
|
||||
|
||||
if status == "declined":
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
resolved_transaction_id,
|
||||
"failed",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Wata webhook: failed to mark payment %s as failed.",
|
||||
resolved_transaction_id,
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
await notify_user_payment_failed(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
payment=payment,
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
|
||||
logging.warning(
|
||||
"Wata webhook: unhandled status '%s' for transaction %s",
|
||||
status,
|
||||
transaction_id,
|
||||
)
|
||||
return web.Response(status=202, text="status_ignored")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_wata:"))
|
||||
async def pay_wata_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
wata_service: WataService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
translator = make_translator(i18n, current_lang)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
if not wata_service or not wata_service.configured:
|
||||
logging.error("Wata service is not configured or unavailable.")
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
parts = parse_payment_callback(callback.data or "")
|
||||
if not parts:
|
||||
logging.error("Invalid pay_wata data in callback: %s", callback.data)
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
payment_description = describe_payment(translator, parts)
|
||||
record_payload = build_payment_record_payload(
|
||||
user_id=callback.from_user.id,
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
status="pending_wata",
|
||||
description=payment_description,
|
||||
months=parts.months,
|
||||
provider="wata",
|
||||
sale_mode=parts.sale_mode,
|
||||
)
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Wata: failed to create payment record for user %s.", callback.from_user.id
|
||||
)
|
||||
await notify_payment_record_failure(callback, translator)
|
||||
return
|
||||
|
||||
success, response_data = await wata_service.create_payment_link(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
)
|
||||
await render_link_or_fail(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
session=session,
|
||||
payment=payment_record,
|
||||
api_success=success,
|
||||
payment_url=first_value(response_data, "url"),
|
||||
provider_payment_id=first_value(response_data, "id"),
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
|
||||
|
||||
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
settings: Settings = ctx.request.app["settings"]
|
||||
service: WataService = ctx.request.app["wata_service"]
|
||||
if not service or not service.configured:
|
||||
return payment_unavailable()
|
||||
|
||||
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
try:
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
amount=ctx.price,
|
||||
currency=currency,
|
||||
status="pending_wata",
|
||||
provider="wata",
|
||||
)
|
||||
success, response_data = await service.create_payment_link(
|
||||
payment_db_id=payment.payment_id,
|
||||
amount=ctx.price,
|
||||
currency=currency,
|
||||
description=ctx.description,
|
||||
)
|
||||
except Exception:
|
||||
await ctx.session.rollback()
|
||||
logging.exception("Wata WebApp payment failed")
|
||||
return payment_failed()
|
||||
|
||||
return await finalize_webapp_link_payment(
|
||||
session=ctx.session,
|
||||
payment=payment,
|
||||
api_success=success,
|
||||
payment_url=first_value(response_data, "url") if success else None,
|
||||
provider_payment_id=first_value(response_data, "id"),
|
||||
log_prefix="Wata",
|
||||
)
|
||||
|
||||
|
||||
async def wata_webhook_route(request: web.Request) -> web.Response:
|
||||
service: WataService = request.app["wata_service"]
|
||||
return await service.webhook_route(request)
|
||||
|
||||
|
||||
def create_service(ctx: ServiceFactoryContext) -> WataService:
|
||||
return WataService(
|
||||
bot=ctx.bot,
|
||||
settings=ctx.settings,
|
||||
i18n=ctx.i18n,
|
||||
async_session_factory=ctx.async_session_factory,
|
||||
subscription_service=ctx.subscription_service,
|
||||
referral_service=ctx.referral_service,
|
||||
default_return_url=ctx.bot_username_for_default_return,
|
||||
)
|
||||
|
||||
|
||||
SPEC = PaymentProviderSpec(
|
||||
id="wata",
|
||||
provider_key="wata",
|
||||
label="Wata",
|
||||
webapp_label="Wata",
|
||||
webapp_labels={"ru": "Wata", "en": "Wata"},
|
||||
webapp_icon="WalletCards",
|
||||
telegram_labels={"ru": "Wata", "en": "Wata"},
|
||||
telegram_emoji="💳",
|
||||
pending_status="pending_wata",
|
||||
enabled=lambda settings: settings.WATA_ENABLED,
|
||||
service_key="wata_service",
|
||||
button_text_key="pay_with_wata_button",
|
||||
callback_prefix="pay_wata",
|
||||
router=router,
|
||||
create_service=create_service,
|
||||
webhook_path=lambda settings: settings.wata_webhook_path,
|
||||
webhook_route=wata_webhook_route,
|
||||
create_webapp_payment=create_webapp_payment,
|
||||
)
|
||||
Reference in New Issue
Block a user