refactor: project architecture refactor, container splitting
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from aiogram import Bot
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_dal
|
||||
|
||||
|
||||
class FreeKassaService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
):
|
||||
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.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
|
||||
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
|
||||
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
|
||||
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
|
||||
|
||||
self.api_base_url: str = "https://api.fk.life/v1"
|
||||
self._timeout = ClientTimeout(total=15)
|
||||
self._session: Optional[ClientSession] = None
|
||||
self._nonce_lock = asyncio.Lock()
|
||||
self._last_nonce = int(time.time() * 1000)
|
||||
|
||||
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
|
||||
if not self.configured:
|
||||
logging.warning(
|
||||
"FreeKassaService initialized but not fully configured. Payments disabled."
|
||||
)
|
||||
if settings.FREEKASSA_ENABLED and not self.server_ip:
|
||||
logging.warning(
|
||||
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount: float) -> str:
|
||||
"""Format amount for payloads and signature with two decimal places."""
|
||||
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
return f"{quantized:.2f}"
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
email: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
payment_method_id: Optional[int] = None,
|
||||
extra_params: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("FreeKassaService is not configured. Cannot create order.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
ip_address = ip_address or self.server_ip
|
||||
if not ip_address:
|
||||
logging.error("FreeKassaService: payment IP is required but not configured.")
|
||||
return False, {"message": "missing_ip"}
|
||||
|
||||
email = email or f"{user_id}@telegram.org"
|
||||
amount_str = self._format_amount(amount)
|
||||
currency_code = (currency or self.default_currency or "RUB").upper()
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"shopId": int(self.shop_id),
|
||||
"nonce": await self._generate_nonce(),
|
||||
"paymentId": str(payment_db_id),
|
||||
"i": int(payment_method_id),
|
||||
"amount": amount_str,
|
||||
"currency": currency_code,
|
||||
"email": email,
|
||||
"ip": ip_address,
|
||||
"us_user_id": str(user_id),
|
||||
"us_months": str(months),
|
||||
"us_payment_db_id": str(payment_db_id),
|
||||
}
|
||||
|
||||
if extra_params:
|
||||
for key, value in extra_params.items():
|
||||
if value is None:
|
||||
continue
|
||||
payload[key] = value
|
||||
|
||||
payload["signature"] = self._sign_payload(payload)
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"{self.api_base_url}/orders/create"
|
||||
try:
|
||||
async with session.post(url, json=payload) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error(
|
||||
"FreeKassa create_order: failed to decode JSON: %s", response_text
|
||||
)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": "invalid_json",
|
||||
"raw": response_text,
|
||||
}
|
||||
|
||||
if response.status != 200 or response_data.get("type") != "success":
|
||||
logging.error(
|
||||
"FreeKassa create_order: API returned error (status=%s, body=%s)",
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.exception("FreeKassa create_order: request failed.")
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
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 _generate_nonce(self) -> int:
|
||||
async with self._nonce_lock:
|
||||
candidate = int(time.time() * 1000)
|
||||
if candidate <= self._last_nonce:
|
||||
candidate = self._last_nonce + 1
|
||||
self._last_nonce = candidate
|
||||
return candidate
|
||||
|
||||
def _sign_payload(self, payload: Dict[str, Any]) -> str:
|
||||
if not self.api_key:
|
||||
raise RuntimeError("FreeKassa API key is not configured.")
|
||||
items = [
|
||||
(key, value)
|
||||
for key, value in payload.items()
|
||||
if key != "signature" and value is not None
|
||||
]
|
||||
items.sort(key=lambda pair: pair[0])
|
||||
message = "|".join(str(value) for _, value in items)
|
||||
return hmac.new(
|
||||
self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def _validate_signature(
|
||||
self,
|
||||
raw_body: bytes,
|
||||
provided_signature: str,
|
||||
) -> bool:
|
||||
if not provided_signature:
|
||||
return False
|
||||
if not self.second_secret:
|
||||
return False
|
||||
|
||||
expected_signature = hmac.new(
|
||||
self.second_secret.encode("utf-8"),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected_signature, provided_signature)
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="freekassa_disabled")
|
||||
|
||||
try:
|
||||
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
|
||||
if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips):
|
||||
return web.Response(status=403)
|
||||
|
||||
raw_body = await request.read()
|
||||
except Exception:
|
||||
logging.exception("FreeKassa webhook: failed to read request body.")
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
payload_dict: Dict[str, Any] = {}
|
||||
if raw_body:
|
||||
try:
|
||||
if request.content_type.startswith("application/json"):
|
||||
decoded_json = json.loads(raw_body.decode("utf-8"))
|
||||
if isinstance(decoded_json, dict):
|
||||
payload_dict = {str(k): v for k, v in decoded_json.items()}
|
||||
else:
|
||||
payload_dict = {
|
||||
str(key): value
|
||||
for key, value in parse_qsl(
|
||||
raw_body.decode("utf-8"), keep_blank_values=True
|
||||
)
|
||||
}
|
||||
except Exception:
|
||||
payload_dict = {}
|
||||
|
||||
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
|
||||
|
||||
merchant_id = _get("MERCHANT_ID")
|
||||
if merchant_id != self.shop_id:
|
||||
return web.Response(status=403)
|
||||
|
||||
signature = _get("SIGN") or _get("signature")
|
||||
if not signature:
|
||||
return web.Response(status=400, text="missing_signature")
|
||||
|
||||
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
|
||||
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
|
||||
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
|
||||
|
||||
if not order_id_str or not amount_str:
|
||||
return web.Response(status=400, text="missing_data")
|
||||
|
||||
if not self._validate_signature(raw_body, signature):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
payment_db_id = int(order_id_str)
|
||||
except (TypeError, ValueError):
|
||||
logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'")
|
||||
return web.Response(status=400, text="invalid_order_id")
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.status == "succeeded":
|
||||
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
|
||||
return web.Response(text="YES")
|
||||
|
||||
# Optional amount verification
|
||||
try:
|
||||
amount_decimal = Decimal(amount_str)
|
||||
expected_amount = Decimal(str(payment.amount)).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
if (
|
||||
amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
!= expected_amount
|
||||
):
|
||||
logging.warning(
|
||||
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
|
||||
f"(expected {expected_amount}, got {amount_decimal})"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}"
|
||||
)
|
||||
|
||||
activation = None
|
||||
referral_bonus = None
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session=session,
|
||||
payment_db_id=payment.payment_id,
|
||||
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
|
||||
new_status="succeeded",
|
||||
)
|
||||
|
||||
months = 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"
|
||||
)
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(months) if sale_base == "subscription" else int(float(months)),
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
provider="freekassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
)
|
||||
|
||||
referral_bonus = None
|
||||
if sale_base == "subscription":
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
int(months),
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("FreeKassa webhook: failed to process payment %s.", payment_db_id)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
raw_config_link = activation.get("subscription_url") if activation else None
|
||||
config_link_display, connect_button_url = await prepare_config_links(
|
||||
self.settings, raw_config_link
|
||||
)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
final_end = activation.get("end_date") if activation else None
|
||||
months = 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"
|
||||
)
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
|
||||
applied_days = 0
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
if not final_end and activation and activation.get("end_date"):
|
||||
final_end = activation["end_date"]
|
||||
|
||||
if final_end:
|
||||
end_date_str = final_end.strftime("%Y-%m-%d")
|
||||
else:
|
||||
end_date_str = _("config_link_not_available")
|
||||
|
||||
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
|
||||
if sale_mode.split("@", 1)[0].split("|", 1)[0] in {
|
||||
"traffic",
|
||||
"traffic_package",
|
||||
"topup",
|
||||
"premium_topup",
|
||||
}:
|
||||
text = _(
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date_str if final_end else "",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = (
|
||||
sanitize_display_name(inviter.first_name)
|
||||
if inviter.first_name
|
||||
else None
|
||||
)
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(
|
||||
inviter.username, with_at=False
|
||||
)
|
||||
text = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation["end_date"].strftime("%Y-%m-%d")
|
||||
if activation and activation.get("end_date")
|
||||
else end_date_str,
|
||||
bonus_days=applied_days,
|
||||
final_end_date=end_date_str,
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=end_date_str,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
if provider_payment_id:
|
||||
order_info_text = _(
|
||||
"free_kassa_order_full",
|
||||
order_id=provider_payment_id,
|
||||
date=datetime.now().strftime("%Y-%m-%d"),
|
||||
)
|
||||
text = f"{order_info_text}\n{text}"
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
payment.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"FreeKassa notification: failed to send message to user %s.", payment.user_id
|
||||
)
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=self.default_currency,
|
||||
months=int(months) if sale_base == "subscription" else 0,
|
||||
traffic_gb=float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
payment_provider="freekassa",
|
||||
username=db_user.username if db_user else None,
|
||||
traffic_is_premium=sale_base == "premium_topup",
|
||||
tariff_key=getattr(payment, "tariff_key", None),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("FreeKassa notification: failed to notify admins.")
|
||||
|
||||
return web.Response(text="YES")
|
||||
|
||||
|
||||
async def freekassa_webhook_route(request: web.Request) -> web.Response:
|
||||
service: FreeKassaService = request.app["freekassa_service"]
|
||||
return await service.webhook_route(request)
|
||||
Reference in New Issue
Block a user