Merge pull request #148 from machka-pasla/lknpd

Lknpd
This commit is contained in:
machka pasla
2026-01-14 14:54:31 +03:00
committed by GitHub
21 changed files with 225 additions and 80 deletions
+4
View File
@@ -50,6 +50,10 @@ YOOKASSA_VAT_CODE=1 #
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
# Nalogo (self-employed receipts)
NALOGO_INN=your_inn # INN for nalog.ru
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
# FreeKassa Payment Gateway Configuration
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
FREEKASSA_API_KEY=your_api_key # API key for REST requests
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim AS builder
FROM python:3.12-slim AS builder
WORKDIR /app
@@ -7,11 +7,11 @@ COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY . .
+3 -1
View File
@@ -24,7 +24,7 @@
## 🚀 Технологии
- **Python 3.11**
- **Python 3.12**
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
@@ -86,6 +86,8 @@
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
| `NALOGO_PASSWORD` | Пароль для авторизации в nalog.ru (самозанятый). |
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
+6
View File
@@ -14,6 +14,7 @@ from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
from bot.services.severpay_service import SeverPayService
from bot.services.nalogo_service import NalogoService
def build_core_services(
@@ -72,6 +73,10 @@ def build_core_services(
bot_username_for_default_return=bot_username_for_default_return,
settings_obj=settings,
)
nalogo_service = NalogoService(
settings.NALOGO_INN,
settings.NALOGO_PASSWORD,
)
# Wire services that depend on each other
try:
@@ -92,6 +97,7 @@ def build_core_services(
"freekassa_service": freekassa_service,
"panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service,
"nalogo_service": nalogo_service,
"platega_service": platega_service,
"severpay_service": severpay_service,
}
+1
View File
@@ -23,6 +23,7 @@ async def build_and_start_web_app(
app["i18n"] = dp.get("i18n_instance")
for key in (
"yookassa_service",
"nalogo_service",
"subscription_service",
"referral_service",
"panel_service",
+39 -3
View File
@@ -18,6 +18,7 @@ from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.panel_api_service import PanelApiService
from bot.services.yookassa_service import YooKassaService
from bot.services.nalogo_service import NalogoService
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from bot.services.notification_service import NotificationService
@@ -37,7 +38,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
i18n: JsonI18n, settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService):
referral_service: ReferralService,
nalogo_service: Optional[NalogoService] = None):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months")
@@ -152,6 +154,19 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
return
try:
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_before_update = None
if payment_db_id is not None:
payment_before_update = await payment_dal.get_payment_by_db_id(
session,
payment_db_id,
)
should_send_nalogo_receipt = bool(
nalogo_service
and nalogo_service.configured
and payment_before_update
and not payment_before_update.yookassa_payment_id
)
# Try to capture and save payment method for future charges if available
try:
payment_method = payment_info_from_webhook.get("payment_method")
@@ -261,7 +276,26 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
traffic_label = (
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
)
config_link_display, connect_button_url = prepare_config_links(
if should_send_nalogo_receipt:
receipt_item_name = payment_info_from_webhook.get("description")
if not receipt_item_name:
if sale_mode == "traffic":
receipt_item_name = f"Remnawave traffic package {traffic_label} GB"
else:
receipt_item_name = f"Remnawave subscription {int(subscription_months)} months"
try:
await nalogo_service.create_income_receipt(
item_name=receipt_item_name,
amount=payment_value,
quantity=1.0,
operation_time=datetime.now(timezone.utc),
)
except Exception:
logging.exception(
"Failed to send Nalogo receipt for payment %s",
yk_payment_id_from_hook,
)
config_link_display, connect_button_url = await prepare_config_links(
settings, activation_details.get("subscription_url") if activation_details else None
)
config_link_text = config_link_display or _("config_link_not_available")
@@ -437,6 +471,7 @@ async def yookassa_webhook_route(request: web.Request):
subscription_service: SubscriptionService = request.app[
'subscription_service']
referral_service: ReferralService = request.app['referral_service']
nalogo_service: Optional[NalogoService] = request.app.get('nalogo_service')
async_session_factory: sessionmaker = request.app[
'async_session_factory']
except KeyError as e_app_ctx:
@@ -529,7 +564,8 @@ async def yookassa_webhook_route(request: web.Request):
await process_successful_payment(
session, bot, payment_dict_for_processing,
i18n_instance, settings, panel_service,
subscription_service, referral_service)
subscription_service, referral_service,
nalogo_service)
await session.commit()
else:
logging.warning(
+2 -2
View File
@@ -88,7 +88,7 @@ async def request_trial_confirmation_handler(
pass
end_date_obj = activation_result.get("end_date")
config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links(
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
settings, activation_result.get("subscription_url")
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
@@ -240,7 +240,7 @@ async def confirm_activate_trial_handler(
pass
end_date_obj = activation_result.get("end_date")
config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links(
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
settings, activation_result.get("subscription_url")
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
+1
View File
@@ -201,6 +201,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
"freekassa_service",
"panel_webhook_service",
"yookassa_service",
"nalogo_service",
"promo_code_service",
"stars_service",
"subscription_service",
+1 -1
View File
@@ -190,7 +190,7 @@ class CryptoPayService:
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
raw_config_link = activation.get("subscription_url") if activation else None
display_link, button_link = prepare_config_links(settings, raw_config_link)
display_link, button_link = await prepare_config_links(settings, raw_config_link)
config_link_text = display_link or _("config_link_not_available")
final_end = activation.get("end_date")
applied_days = 0
+1 -1
View File
@@ -319,7 +319,7 @@ class FreeKassaService:
_ = 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 = prepare_config_links(self.settings, raw_config_link)
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.subscription_duration_months or 1
+103
View File
@@ -0,0 +1,103 @@
import asyncio
import logging
from datetime import datetime
from decimal import Decimal
from typing import Optional
from nalogo import Client
from nalogo.dto.income import (
AtomDateTime,
IncomeClient,
IncomeRequest,
IncomeServiceItem,
PaymentType,
)
class NalogoService:
def __init__(self, inn: Optional[str], password: Optional[str]) -> None:
self.inn = inn.strip() if inn else None
self.password = password
self.configured = bool(self.inn and self.password)
self._client = Client() if self.configured else None
self._auth_lock = asyncio.Lock()
if not self.configured:
logging.warning("Nalogo credentials are missing. Receipt sending disabled.")
async def _ensure_authenticated(self) -> bool:
if not self._client:
return False
async with self._auth_lock:
token_data = await self._client.auth_provider.get_token()
if token_data:
return True
try:
token_json = await self._client.create_new_access_token(
self.inn,
self.password,
)
await self._client.authenticate(token_json)
logging.info("Nalogo authentication succeeded.")
return True
except Exception:
logging.exception("Nalogo authentication failed.")
return False
async def create_income_receipt(
self,
*,
item_name: str,
amount: float,
quantity: float = 1.0,
client: Optional[IncomeClient] = None,
operation_time: Optional[datetime] = None,
) -> Optional[str]:
if not self.configured:
return None
if not await self._ensure_authenticated():
return None
try:
service_item = IncomeServiceItem(
name=item_name,
amount=Decimal(str(amount)),
quantity=Decimal(str(quantity)),
)
total_amount = service_item.get_total_amount()
request = IncomeRequest(
operation_time=(
AtomDateTime.from_datetime(operation_time)
if operation_time
else AtomDateTime.now()
),
request_time=AtomDateTime.now(),
services=[service_item],
total_amount=str(total_amount),
client=client or IncomeClient(),
payment_type=PaymentType.ACCOUNT,
ignore_max_total_income_restriction=False,
)
response = await self._client.http_client.post(
"/income",
json_data=request.model_dump(),
)
payload = response.json()
receipt_uuid = (
payload.get("approvedReceiptUuid")
or payload.get("receiptUuid")
or payload.get("receipt_uuid")
)
if receipt_uuid:
logging.info("Nalogo receipt created: %s", receipt_uuid)
else:
logging.info("Nalogo receipt created without a UUID in response.")
return receipt_uuid
except Exception:
logging.exception("Failed to create Nalogo receipt.")
return None
async def close(self) -> None:
return None
+17
View File
@@ -562,3 +562,20 @@ class PanelApiService:
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
return None
async def encrypt_happ_link(self, link_to_encrypt: str) -> Optional[str]:
"""Encrypt a subscription link using the panel's happ crypt4 API.
Returns the encrypted link string or None if encryption failed.
"""
payload = {"linkToEncrypt": link_to_encrypt}
response_data = await self._request(
"POST",
"/system/tools/happ/encrypt",
json=payload,
log_full_response=False
)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response", {}).get("encryptedLink")
logging.error(f"Failed to encrypt happ link. Response: {response_data}")
return None
+1 -1
View File
@@ -215,7 +215,7 @@ class PlategaService:
_ = 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 = prepare_config_links(self.settings, raw_config_link)
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
applied_days = 0
+1 -1
View File
@@ -233,7 +233,7 @@ class SeverPayService:
_ = 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 = prepare_config_links(self.settings, raw_config_link)
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
applied_days = 0
+1 -1
View File
@@ -123,7 +123,7 @@ class StarsService:
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
raw_config_link = activation_details.get("subscription_url") if activation_details else None
config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link)
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")
if sale_mode == "traffic":
+1 -1
View File
@@ -902,7 +902,7 @@ class SubscriptionService:
else None
)
config_link_raw = panel_user_data.get("subscriptionUrl")
display_link, connect_button_url = prepare_config_links(self.settings, config_link_raw)
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
hwid_limit = panel_user_data.get("hwidDeviceLimit")
if hwid_limit is None:
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
+11 -39
View File
@@ -1,53 +1,25 @@
import base64
import logging
from functools import lru_cache
from pathlib import Path
from typing import Optional, Tuple
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
from config.settings import Settings
CRYPT4_PREFIX = "happ://crypt4/"
from bot.services.panel_api_service import PanelApiService
@lru_cache(maxsize=1)
def _load_crypt4_public_key() -> Optional[RSAPublicKey]:
"""Load and cache the happ crypt4 public key from the project root."""
pem_path = Path(__file__).resolve().parent.parent.parent / "happ-crypt4.pem"
try:
pem_bytes = pem_path.read_bytes()
key = serialization.load_pem_public_key(pem_bytes)
return key if isinstance(key, RSAPublicKey) else None
except FileNotFoundError:
logging.error("Crypt4 public key file not found at %s", pem_path)
except Exception as exc:
logging.error("Failed to load crypt4 public key: %s", exc, exc_info=True)
async def _encrypt_raw_link(settings: Settings, raw_link: str) -> Optional[str]:
"""Encrypt the raw subscription URL using the panel's happ crypt4 API."""
async with PanelApiService(settings) as panel_service:
encrypted_link = await panel_service.encrypt_happ_link(raw_link)
if encrypted_link:
return encrypted_link
return None
def _encrypt_raw_link(raw_link: str) -> Optional[str]:
"""Encrypt the raw subscription URL with RSA PKCS#1 v1.5 and return base64 payload."""
public_key = _load_crypt4_public_key()
if not public_key:
return None
try:
encrypted = public_key.encrypt(raw_link.encode("utf-8"), padding.PKCS1v15())
return base64.b64encode(encrypted).decode("utf-8")
except Exception as exc:
logging.error("Failed to encrypt config link with crypt4: %s", exc, exc_info=True)
return None
def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
async def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
"""
Build the user-facing connection key and the URL for the connect button.
Returns (display_link, button_link). When CRYPT4 is enabled the display link
is encrypted and prefixed with happ://crypt4/, and the button link is wrapped
is encrypted and prefixed with happ://crypt4/ by panel API, and the button link is wrapped
with CRYPT4_REDIRECT_URL if provided.
"""
if not raw_link:
@@ -61,9 +33,9 @@ def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[O
button_link = cleaned
if settings.CRYPT4_ENABLED:
encrypted_payload = _encrypt_raw_link(cleaned)
encrypted_payload = await _encrypt_raw_link(settings, cleaned)
if encrypted_payload:
display_link = f"{CRYPT4_PREFIX}{encrypted_payload}"
display_link = encrypted_payload
button_link = display_link
else:
logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.")
+19
View File
@@ -46,6 +46,15 @@ class Settings(BaseSettings):
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
)
NALOGO_INN: Optional[str] = Field(
default=None,
description="INN for nalog.ru (self-employed) authentication"
)
NALOGO_PASSWORD: Optional[str] = Field(
default=None,
description="Password for nalog.ru (self-employed) authentication"
)
WEBHOOK_BASE_URL: Optional[str] = None
CRYPTOPAY_TOKEN: Optional[str] = None
@@ -556,6 +565,16 @@ def get_settings() -> Settings:
logging.warning(
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
)
if (
_settings_instance.NALOGO_INN
or _settings_instance.NALOGO_PASSWORD
) and not (
_settings_instance.NALOGO_INN
and _settings_instance.NALOGO_PASSWORD
):
logging.warning(
"WARNING: Nalogo credentials are incomplete. Receipt sending will be disabled."
)
if _settings_instance.FREEKASSA_ENABLED:
if (
not _settings_instance.FREEKASSA_MERCHANT_ID
+1 -1
View File
@@ -6,7 +6,7 @@ services:
env_file:
- .env
ports:
- 8080:8080
- '127.0.0.1:8080:8080'
networks:
- remnawave-network
environment:
-14
View File
@@ -1,14 +0,0 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3UZ0M3L4K+WjM3vkbQnz
ozHg/cRbEXvQ6i4A8RVN4OM3rK9kU01FdjyoIgywve8OEKsFnVwERZAQZ1Trv60B
hmaM76QQEE+EUlIOL9EpwKWGtTL5lYC1sT9XJMNP3/CI0gP5wwQI88cY/xedpOEB
W72EmOOShHUm/b/3m+HPmqwc4ugKj5zWV5SyiT829aFA5DxSjmIIFBAms7DafmSq
LFTYIQL5cShDY2u+/sqyAw9yZIOoqW2TFIgIHhLPWek/ocDU7zyOrlu1E0SmcQQb
LFqHq02fsnH6IcqTv3N5Adb/CkZDDQ6HvQVBmqbKZKf7ZdXkqsc/Zw27xhG7OfXC
tUmWsiL7zA+KoTd3avyOh93Q9ju4UQsHthL3Gs4vECYOCS9dsXXSHEY/1ngU/hjO
WFF8QEE/rYV6nA4PTyUvo5RsctSQL/9DJX7XNh3zngvif8LsCN2MPvx6X+zLouBX
zgBkQ9DFfZAGLWf9TR7KVjZC/3NsuUCDoAOcpmN8pENBbeB0puiKMMWSvll36+2M
YR1Xs0MgT8Y9TwhE2+TnnTJOhzmHi/BxiUlY/w2E0s4ax9GHAmX0wyF4zeV7kDkc
vHuEdc0d7vDmdw0oqCqWj0Xwq86HfORu6tm1A8uRATjb4SzjTKclKuoElVAVa5Jo
oh/uZMozC65SmDw+N5p6Su8CAwEAAQ==
-----END PUBLIC KEY-----
+9 -11
View File
@@ -1,12 +1,10 @@
aiogram==3.21.0
python-dotenv==1.0.1
aiohttp==3.12.14
pydantic==2.7.1
yookassa==3.5.0
pycountry==23.12.11
pydantic_settings
sqlalchemy[asyncio]==2.0.29
asyncpg==0.29.0
alembic==1.13.1
aiogram==3.24.0
python-dotenv==1.2.1
aiohttp==3.13.3
pydantic==2.12.5
yookassa==3.9.0
nalogo==1.0.0
pydantic_settings==2.12.0
sqlalchemy[asyncio]==2.0.45
asyncpg==0.31.0
aiocryptopay==0.4.8
cryptography==42.0.8