Compare commits

..
19 Commits
Author SHA1 Message Date
kavoreandGitHub c0851c5339 Merge pull request #151 from kavore/dev
bugfix
2026-01-17 23:55:25 +03:00
kavore 785b6c2d41 merge 2026-01-17 23:54:58 +03:00
kavore cce3fd4f58 bugfix #4 2026-01-17 23:23:06 +03:00
kavore 0dd6beebb2 remove nalogo and use custom client 2026-01-17 21:58:12 +03:00
kavore 8fb2a76698 bugfix 2026-01-17 21:25:02 +03:00
kavoreandGitHub 3d6713caa9 Merge pull request #150 from kavore/dev
bugfix and nalogo custom label
2026-01-17 21:11:41 +03:00
kavore 8d8fdce519 bugfix 2026-01-17 21:10:46 +03:00
kavore c385a1466c added custom label 2026-01-17 21:06:58 +03:00
kavoreandGitHub 8e1fdb33d2 Merge pull request #149 from kavore/dev
added nalogo
2026-01-17 16:18:38 +03:00
machka paslaandkavore 29222be1fa .env.example and docker-compose upd 2026-01-17 16:01:29 +03:00
machka pasla 08aca3b28d Add LOG_LEVEL env config and fix Nalogo receipt trigger 2026-01-14 16:03:21 +03:00
machka paslaandGitHub 658139d607 Merge pull request #148 from machka-pasla/lknpd
Lknpd
2026-01-14 14:54:31 +03:00
machka paslaandGitHub 1d4abf7977 Merge branch 'dev' into lknpd 2026-01-14 14:54:11 +03:00
machka pasla 62e8bc08d5 version update 2026-01-06 12:22:30 +03:00
machka pasla 475a30caf4 added my nalog management 2026-01-05 19:43:40 +03:00
machka paslaandGitHub c1593d7868 Merge pull request #145 from tdlcompiler/crypt4-fix 2025-12-30 07:00:41 +03:00
Denis Kartsev 0dbcfe3770 removed crypt4 prefix
panel api already returns a link with the prefix
2025-12-30 04:58:34 +03:00
machka pasla e3ffb87cfc updated crypt 2025-12-29 14:51:50 +03:00
machka pasla b4218ec6a7 Идемпотентная обработка вебхуков YooKassa 2025-12-26 13:44:51 +03:00
25 changed files with 614 additions and 120 deletions
+7
View File
@@ -50,6 +50,12 @@ YOOKASSA_VAT_CODE=1 #
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle 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) 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
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
# FreeKassa Payment Gateway Configuration # FreeKassa Payment Gateway Configuration
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
FREEKASSA_API_KEY=your_api_key # API key for REST requests FREEKASSA_API_KEY=your_api_key # API key for REST requests
@@ -160,6 +166,7 @@ WEB_SERVER_PORT=8080
# Admin Panel Log Pagination # Admin Panel Log Pagination
LOGS_PAGE_SIZE=10 # Number of events in the log LOGS_PAGE_SIZE=10 # Number of events in the log
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
# Admin Logging Configuration # Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim AS builder FROM python:3.12-slim AS builder
WORKDIR /app WORKDIR /app
@@ -7,11 +7,11 @@ COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim FROM python:3.12-slim
WORKDIR /app 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 . . COPY . .
+3 -1
View File
@@ -24,7 +24,7 @@
## 🚀 Технологии ## 🚀 Технологии
- **Python 3.11** - **Python 3.12**
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки). - **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
@@ -86,6 +86,8 @@
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | | `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | | `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
| `NALOGO_PASSWORD` | Пароль для авторизации в nalog.ru (самозанятый). |
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). | | `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
+7
View File
@@ -14,6 +14,7 @@ from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.freekassa_service import FreeKassaService from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService from bot.services.platega_service import PlategaService
from bot.services.severpay_service import SeverPayService from bot.services.severpay_service import SeverPayService
from bot.services.lknpd_service import LknpdService
def build_core_services( def build_core_services(
@@ -72,6 +73,11 @@ def build_core_services(
bot_username_for_default_return=bot_username_for_default_return, bot_username_for_default_return=bot_username_for_default_return,
settings_obj=settings, settings_obj=settings,
) )
lknpd_service = LknpdService(
settings.LKNPD_INN,
settings.LKNPD_PASSWORD,
api_url=settings.LKNPD_API_URL,
)
# Wire services that depend on each other # Wire services that depend on each other
try: try:
@@ -92,6 +98,7 @@ def build_core_services(
"freekassa_service": freekassa_service, "freekassa_service": freekassa_service,
"panel_webhook_service": panel_webhook_service, "panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service, "yookassa_service": yookassa_service,
"lknpd_service": lknpd_service,
"platega_service": platega_service, "platega_service": platega_service,
"severpay_service": severpay_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") app["i18n"] = dp.get("i18n_instance")
for key in ( for key in (
"yookassa_service", "yookassa_service",
"lknpd_service",
"subscription_service", "subscription_service",
"referral_service", "referral_service",
"panel_service", "panel_service",
+85 -39
View File
@@ -18,6 +18,7 @@ from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.yookassa_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from bot.services.lknpd_service import LknpdService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from config.settings import Settings from config.settings import Settings
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
@@ -37,7 +38,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
i18n: JsonI18n, settings: Settings, i18n: JsonI18n, settings: Settings,
panel_service: PanelApiService, panel_service: PanelApiService,
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
referral_service: ReferralService): referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None):
metadata = payment_info_from_webhook.get("metadata", {}) metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id") user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months") subscription_months_str = metadata.get("subscription_months")
@@ -75,44 +77,53 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
amount_data = payment_info_from_webhook.get("amount", {}) amount_data = payment_info_from_webhook.get("amount", {})
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0 months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
payment_value = float(amount_data.get("value", 0.0)) payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
if payment_db_id is None and auto_renew_subscription_id_str: if payment_db_id is None and auto_renew_subscription_id_str:
try: try:
# Create/ensure provider payment by YooKassa payment id for idempotency if not yk_payment_id_from_hook:
yk_payment_id_from_hook = payment_info_from_webhook.get("id") logging.error(
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record."
)
return
from db.dal import payment_dal as _payment_dal from db.dal import payment_dal as _payment_dal
ensured_payment = await _payment_dal.ensure_payment_with_provider_id( payment_record = await _payment_dal.get_payment_by_provider_payment_id(
session, session, yk_payment_id_from_hook
user_id=user_id,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=months_for_record or 1,
description=payment_info_from_webhook.get(
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
) )
payment_db_id = ensured_payment.payment_id if not payment_record:
# Also persist yookassa_payment_id field if not set yet payment_record = await _payment_dal.ensure_payment_with_provider_id(
try:
await _payment_dal.update_payment_status_by_db_id(
session, session,
payment_db_id, user_id=user_id,
payment_info_from_webhook.get("status", "succeeded"), amount=payment_value,
yk_payment_id_from_hook, currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
) months=months_for_record or 1,
except Exception: description=payment_info_from_webhook.get(
# Non-fatal; continue processing "description") or f"Auto-renewal for {months_for_record or subscription_months} months",
logging.exception( provider="yookassa",
"Failed to backfill yookassa_payment_id for ensured auto-renew payment" provider_payment_id=yk_payment_id_from_hook,
) )
payment_db_id = payment_record.payment_id
except Exception as e_ensure: except Exception as e_ensure:
logging.error( logging.error(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
exc_info=True, exc_info=True,
) )
return return
elif payment_db_id is not None:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment_record:
logging.error(
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
)
return
if payment_record and payment_record.status == "succeeded":
logging.info(
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
)
return
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user: if not db_user:
@@ -144,6 +155,20 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
try: try:
yk_payment_id_from_hook = payment_info_from_webhook.get("id") 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_lknpd_receipt = bool(
lknpd_service
and lknpd_service.configured
and payment_info_from_webhook.get("paid") is True
and payment_info_from_webhook.get("status") == "succeeded"
and payment_before_update
and payment_before_update.status != "succeeded"
)
# Try to capture and save payment method for future charges if available # Try to capture and save payment method for future charges if available
try: try:
payment_method = payment_info_from_webhook.get("payment_method") payment_method = payment_info_from_webhook.get("payment_method")
@@ -192,18 +217,6 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
logging.exception("Failed to persist multi-card YooKassa method from webhook") logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception: except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook") logging.exception("Failed to persist YooKassa payment method from webhook")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id=yk_payment_id_from_hook)
if not updated_payment_record:
logging.error(
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
)
raise Exception(
f"DB Error: Could not update payment record {payment_db_id}")
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0 months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
activation_details = await subscription_service.activate_subscription( activation_details = await subscription_service.activate_subscription(
session, session,
@@ -224,6 +237,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
raise Exception( raise Exception(
f"Subscription Error: Failed to activate for user {user_id}") f"Subscription Error: Failed to activate for user {user_id}")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id=yk_payment_id_from_hook)
if not updated_payment_record:
logging.error(
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
)
raise Exception(
f"DB Error: Could not update payment record {payment_db_id}")
base_subscription_end_date = activation_details['end_date'] base_subscription_end_date = activation_details['end_date']
final_end_date_for_user = base_subscription_end_date final_end_date_for_user = base_subscription_end_date
applied_promo_bonus_days = activation_details.get( applied_promo_bonus_days = activation_details.get(
@@ -253,7 +278,26 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
traffic_label = ( traffic_label = (
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}" 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_lknpd_receipt:
receipt_item_name = payment_info_from_webhook.get("description")
if not receipt_item_name:
if sale_mode == "traffic":
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
try:
await lknpd_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 LKNPD 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 settings, activation_details.get("subscription_url") if activation_details else None
) )
config_link_text = config_link_display or _("config_link_not_available") config_link_text = config_link_display or _("config_link_not_available")
@@ -429,6 +473,7 @@ async def yookassa_webhook_route(request: web.Request):
subscription_service: SubscriptionService = request.app[ subscription_service: SubscriptionService = request.app[
'subscription_service'] 'subscription_service']
referral_service: ReferralService = request.app['referral_service'] referral_service: ReferralService = request.app['referral_service']
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
async_session_factory: sessionmaker = request.app[ async_session_factory: sessionmaker = request.app[
'async_session_factory'] 'async_session_factory']
except KeyError as e_app_ctx: except KeyError as e_app_ctx:
@@ -521,7 +566,8 @@ async def yookassa_webhook_route(request: web.Request):
await process_successful_payment( await process_successful_payment(
session, bot, payment_dict_for_processing, session, bot, payment_dict_for_processing,
i18n_instance, settings, panel_service, i18n_instance, settings, panel_service,
subscription_service, referral_service) subscription_service, referral_service,
lknpd_service)
await session.commit() await session.commit()
else: else:
logging.warning( logging.warning(
+2 -2
View File
@@ -88,7 +88,7 @@ async def request_trial_confirmation_handler(
pass pass
end_date_obj = activation_result.get("end_date") 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") settings, activation_result.get("subscription_url")
) )
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available") 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 pass
end_date_obj = activation_result.get("end_date") 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") settings, activation_result.get("subscription_url")
) )
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available") 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", "freekassa_service",
"panel_webhook_service", "panel_webhook_service",
"yookassa_service", "yookassa_service",
"lknpd_service",
"promo_code_service", "promo_code_service",
"stars_service", "stars_service",
"subscription_service", "subscription_service",
+1 -1
View File
@@ -190,7 +190,7 @@ class CryptoPayService:
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) _ = lambda k, **kw: i18n.gettext(lang, k, **kw)
raw_config_link = activation.get("subscription_url") if activation else None 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") config_link_text = display_link or _("config_link_not_available")
final_end = activation.get("end_date") final_end = activation.get("end_date")
applied_days = 0 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 _ = 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 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") config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None final_end = activation.get("end_date") if activation else None
months = payment.subscription_duration_months or 1 months = payment.subscription_duration_months or 1
+321
View File
@@ -0,0 +1,321 @@
"""
LKNPD API client for self-employed (NPD) tax receipts.
Custom implementation for lknpd.nalog.ru API.
"""
import asyncio
import logging
import uuid
from datetime import UTC, datetime
from decimal import Decimal
from enum import Enum
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class PaymentType(str, Enum):
"""Payment type for income registration."""
CASH = "CASH"
WIRE = "WIRE"
class IncomeType(str, Enum):
"""Income source type."""
FROM_INDIVIDUAL = "FROM_INDIVIDUAL"
FROM_LEGAL_ENTITY = "FROM_LEGAL_ENTITY"
FROM_FOREIGN_AGENCY = "FROM_FOREIGN_AGENCY"
class LknpdApiError(Exception):
"""Base exception for LKNPD API errors."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class LknpdAuthError(LknpdApiError):
"""Authentication error (401)."""
pass
class LknpdValidationError(LknpdApiError):
"""Validation error (400)."""
pass
def _generate_device_id() -> str:
"""Generate device ID for API requests."""
return str(uuid.uuid4()).replace("-", "")[:21].lower()
def _format_datetime(dt: datetime) -> str:
"""Format datetime to ISO/ATOM format with Z suffix."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
elif dt.tzinfo != UTC:
dt = dt.astimezone(UTC)
return dt.isoformat().replace("+00:00", "Z")
class LknpdClient:
"""
Async client for LKNPD (lknpd.nalog.ru) self-employed API.
Supports:
- INN + password authentication
- Token refresh
- Income registration with proper payment types (CASH/WIRE)
"""
DEFAULT_HEADERS = {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"Referrer": "https://lknpd.nalog.ru/auth/login",
}
DEVICE_INFO_TEMPLATE = {
"sourceType": "WEB",
"appVersion": "1.0.0",
"metaDetails": {
"userAgent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36"
)
},
}
def __init__(
self,
base_url: str = "https://lknpd.nalog.ru/api",
timeout: float = 10.0,
):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.device_id = _generate_device_id()
self._token_data: dict[str, Any] | None = None
self._refresh_lock = asyncio.Lock()
def _get_device_info(self) -> dict[str, Any]:
"""Get device info with current device ID."""
info = self.DEVICE_INFO_TEMPLATE.copy()
info["sourceDeviceId"] = self.device_id
return info
async def authenticate(self, inn: str, password: str) -> bool:
"""
Authenticate with INN and password.
Returns True if authentication was successful.
"""
request_data = {
"username": inn,
"password": password,
"deviceInfo": self._get_device_info(),
}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/v1/auth/lkfl",
json=request_data,
headers=self.DEFAULT_HEADERS,
)
if response.status_code == 401:
raise LknpdAuthError("Invalid credentials", 401)
if response.status_code >= 400:
raise LknpdApiError(
f"Authentication failed: {response.text}",
response.status_code,
)
self._token_data = response.json()
logger.info("LKNPD authentication successful")
return True
except httpx.RequestError as e:
logger.exception("Network error during authentication")
raise LknpdApiError(f"Network error: {e}")
async def _refresh_token(self) -> bool:
"""Refresh access token using refresh token."""
async with self._refresh_lock:
if not self._token_data or "refreshToken" not in self._token_data:
return False
request_data = {
"deviceInfo": self._get_device_info(),
"refreshToken": self._token_data["refreshToken"],
}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/v1/auth/token",
json=request_data,
headers=self.DEFAULT_HEADERS,
)
if response.status_code != 200:
return False
self._token_data = response.json()
logger.info("LKNPD token refreshed")
return True
except Exception:
logger.exception("Token refresh failed")
return False
def _get_auth_headers(self) -> dict[str, str]:
"""Get authorization headers from current token."""
if not self._token_data or "token" not in self._token_data:
return {}
return {"Authorization": f"Bearer {self._token_data['token']}"}
async def _request(
self,
method: str,
path: str,
json_data: dict[str, Any] | None = None,
retry_on_401: bool = True,
) -> httpx.Response:
"""Make authenticated API request with auto-retry on 401."""
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
url = f"{self.base_url}/v1{path}"
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method,
url,
json=json_data,
headers=headers,
)
# Handle 401 with token refresh
if response.status_code == 401 and retry_on_401:
if await self._refresh_token():
headers = {**self.DEFAULT_HEADERS, **self._get_auth_headers()}
response = await client.request(
method,
url,
json=json_data,
headers=headers,
)
return response
@property
def is_authenticated(self) -> bool:
"""Check if client has valid token data."""
return self._token_data is not None and "token" in self._token_data
async def create_income(
self,
*,
name: str,
amount: Decimal | float,
quantity: Decimal | float | int = 1,
payment_type: PaymentType = PaymentType.WIRE,
income_type: IncomeType = IncomeType.FROM_INDIVIDUAL,
client_inn: str | None = None,
client_name: str | None = None,
client_phone: str | None = None,
operation_time: datetime | None = None,
) -> str | None:
"""
Register income and create receipt.
Args:
name: Service/item description
amount: Price per unit
quantity: Number of units
payment_type: CASH or WIRE (for card/bank payments)
income_type: Source type (individual, legal entity, foreign)
client_inn: Client's INN (required for legal entities)
client_name: Client's display name
client_phone: Client's phone number
operation_time: Time of operation (defaults to now)
Returns:
Receipt UUID if successful, None otherwise
"""
if not self.is_authenticated:
raise LknpdAuthError("Not authenticated")
# Prepare times
now = datetime.now(UTC)
op_time = operation_time or now
# Calculate total
amount_decimal = Decimal(str(amount))
qty_decimal = Decimal(str(quantity))
total = amount_decimal * qty_decimal
# API expects quantity as integer when it's a whole number
qty_value: int | str
if qty_decimal == qty_decimal.to_integral_value():
qty_value = int(qty_decimal)
else:
qty_value = str(qty_decimal)
# Build request
request_data = {
"operationTime": _format_datetime(op_time),
"requestTime": _format_datetime(now),
"services": [
{
"name": name,
"amount": str(amount_decimal),
"quantity": qty_value,
}
],
"totalAmount": str(total),
"client": {
"contactPhone": client_phone,
"displayName": client_name,
"incomeType": income_type.value,
"inn": client_inn,
},
"paymentType": payment_type.value,
"ignoreMaxTotalIncomeRestriction": False,
}
try:
response = await self._request("POST", "/income", json_data=request_data)
if response.status_code == 400:
logger.error("LKNPD validation error: %s", response.text)
raise LknpdValidationError(response.text, 400)
if response.status_code == 401:
raise LknpdAuthError("Authentication expired", 401)
if response.status_code >= 400:
logger.error(
"LKNPD API error: status=%d body=%s",
response.status_code,
response.text,
)
raise LknpdApiError(response.text, response.status_code)
payload = response.json()
receipt_uuid = (
payload.get("approvedReceiptUuid")
or payload.get("receiptUuid")
or payload.get("receipt_uuid")
)
if receipt_uuid:
logger.info("LKNPD receipt created: %s", receipt_uuid)
return receipt_uuid
except httpx.RequestError as e:
logger.exception("Network error creating income")
raise LknpdApiError(f"Network error: {e}")
+69
View File
@@ -0,0 +1,69 @@
import asyncio
import logging
from datetime import datetime
from typing import Optional
from .lknpd_client import LknpdClient, PaymentType, LknpdApiError
class LknpdService:
def __init__(
self,
inn: Optional[str],
password: Optional[str],
api_url: str = "https://lknpd.nalog.ru/api",
) -> None:
self.inn = inn.strip() if inn else None
self.password = password
self.configured = bool(self.inn and self.password)
self._client = LknpdClient(base_url=api_url) if self.configured else None
self._auth_lock = asyncio.Lock()
if not self.configured:
logging.warning("LKNPD credentials are missing. Receipt sending disabled.")
async def _ensure_authenticated(self) -> bool:
if not self._client:
return False
async with self._auth_lock:
if self._client.is_authenticated:
return True
try:
await self._client.authenticate(self.inn, self.password)
return True
except LknpdApiError:
logging.exception("LKNPD authentication failed.")
return False
async def create_income_receipt(
self,
*,
item_name: str,
amount: float,
quantity: float = 1.0,
operation_time: Optional[datetime] = None,
) -> Optional[str]:
if not self.configured:
return None
if not await self._ensure_authenticated():
return None
try:
receipt_uuid = await self._client.create_income(
name=item_name,
amount=amount,
quantity=quantity,
payment_type=PaymentType.WIRE,
operation_time=operation_time,
)
if not receipt_uuid:
logging.info("LKNPD receipt created without a UUID in response.")
return receipt_uuid
except LknpdApiError:
logging.exception("Failed to create LKNPD 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: if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response") return response_data.get("response")
return None 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 _ = 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 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") config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None final_end = activation.get("end_date") if activation else None
applied_days = 0 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 _ = 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 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") config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None final_end = activation.get("end_date") if activation else None
applied_days = 0 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 _ = 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 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") config_link_text = config_link_display or _("config_link_not_available")
if sale_mode == "traffic": if sale_mode == "traffic":
+1 -1
View File
@@ -902,7 +902,7 @@ class SubscriptionService:
else None else None
) )
config_link_raw = panel_user_data.get("subscriptionUrl") 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") hwid_limit = panel_user_data.get("hwidDeviceLimit")
if hwid_limit is None: if hwid_limit is None:
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
+11 -39
View File
@@ -1,53 +1,25 @@
import base64
import logging import logging
from functools import lru_cache
from pathlib import Path
from typing import Optional, Tuple 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 from config.settings import Settings
from bot.services.panel_api_service import PanelApiService
CRYPT4_PREFIX = "happ://crypt4/"
@lru_cache(maxsize=1) async def _encrypt_raw_link(settings: Settings, raw_link: str) -> Optional[str]:
def _load_crypt4_public_key() -> Optional[RSAPublicKey]: """Encrypt the raw subscription URL using the panel's happ crypt4 API."""
"""Load and cache the happ crypt4 public key from the project root.""" async with PanelApiService(settings) as panel_service:
pem_path = Path(__file__).resolve().parent.parent.parent / "happ-crypt4.pem" encrypted_link = await panel_service.encrypt_happ_link(raw_link)
try: if encrypted_link:
pem_bytes = pem_path.read_bytes() return encrypted_link
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)
return None return None
def _encrypt_raw_link(raw_link: str) -> Optional[str]: async def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[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]]:
""" """
Build the user-facing connection key and the URL for the connect button. 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 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. with CRYPT4_REDIRECT_URL if provided.
""" """
if not raw_link: if not raw_link:
@@ -61,9 +33,9 @@ def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[O
button_link = cleaned button_link = cleaned
if settings.CRYPT4_ENABLED: if settings.CRYPT4_ENABLED:
encrypted_payload = _encrypt_raw_link(cleaned) encrypted_payload = await _encrypt_raw_link(settings, cleaned)
if encrypted_payload: if encrypted_payload:
display_link = f"{CRYPT4_PREFIX}{encrypted_payload}" display_link = encrypted_payload
button_link = display_link button_link = display_link
else: else:
logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.") logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.")
+49
View File
@@ -46,6 +46,32 @@ class Settings(BaseSettings):
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox." description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
) )
LKNPD_INN: Optional[str] = Field(
default=None,
alias="NALOGO_INN",
description="INN for lknpd.nalog.ru (self-employed) authentication"
)
LKNPD_PASSWORD: Optional[str] = Field(
default=None,
alias="NALOGO_PASSWORD",
description="Password for lknpd.nalog.ru (self-employed) authentication"
)
LKNPD_API_URL: str = Field(
default="https://lknpd.nalog.ru/api",
alias="NALOGO_API_URL",
description="Base URL for LKNPD API (can be overridden for proxies)"
)
LKNPD_RECEIPT_NAME_SUBSCRIPTION: str = Field(
default="subscription {months} months",
alias="NALOGO_RECEIPT_NAME_SUBSCRIPTION",
description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration."
)
LKNPD_RECEIPT_NAME_TRAFFIC: str = Field(
default="traffic package {gb} GB",
alias="NALOGO_RECEIPT_NAME_TRAFFIC",
description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount."
)
WEBHOOK_BASE_URL: Optional[str] = None WEBHOOK_BASE_URL: Optional[str] = None
CRYPTOPAY_TOKEN: Optional[str] = None CRYPTOPAY_TOKEN: Optional[str] = None
@@ -488,9 +514,22 @@ class Settings(BaseSettings):
return methods or default_order return methods or default_order
# Logging Configuration # Logging Configuration
LOG_LEVEL: str = Field(
default="INFO",
description="Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
)
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications") LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)") LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
@field_validator('LOG_LEVEL', mode='before')
@classmethod
def normalize_log_level(cls, v):
if isinstance(v, str):
v = v.strip().upper()
if not v:
return "INFO"
return v
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before') @field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
@classmethod @classmethod
def validate_optional_int_fields(cls, v): def validate_optional_int_fields(cls, v):
@@ -556,6 +595,16 @@ def get_settings() -> Settings:
logging.warning( logging.warning(
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
) )
if (
_settings_instance.LKNPD_INN
or _settings_instance.LKNPD_PASSWORD
) and not (
_settings_instance.LKNPD_INN
and _settings_instance.LKNPD_PASSWORD
):
logging.warning(
"WARNING: LKNPD credentials are incomplete. Receipt sending will be disabled."
)
if _settings_instance.FREEKASSA_ENABLED: if _settings_instance.FREEKASSA_ENABLED:
if ( if (
not _settings_instance.FREEKASSA_MERCHANT_ID not _settings_instance.FREEKASSA_MERCHANT_ID
+3 -2
View File
@@ -60,17 +60,18 @@ async def ensure_payment_with_provider_id(
"""Idempotently create a payment record for a provider event. """Idempotently create a payment record for a provider event.
If a payment with the same provider_payment_id already exists, returns it. If a payment with the same provider_payment_id already exists, returns it.
Otherwise creates a new succeeded payment with provided data. Otherwise creates a new pending payment with provided data.
""" """
existing = await get_payment_by_provider_payment_id(session, provider_payment_id) existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
if existing: if existing:
return existing return existing
pending_status = f"pending_{provider}" if provider else "pending"
payment_payload: Dict[str, Any] = { payment_payload: Dict[str, Any] = {
"user_id": user_id, "user_id": user_id,
"amount": float(amount), "amount": float(amount),
"currency": currency, "currency": currency,
"status": "succeeded", "status": pending_status,
"description": description, "description": description,
"subscription_duration_months": months, "subscription_duration_months": months,
"provider_payment_id": provider_payment_id, "provider_payment_id": provider_payment_id,
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
remnawave-tg-shop: remnawave-tg-shop:
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest image: kavore/remnawave-tg-shop:latest
container_name: remnawave-tg-shop container_name: remnawave-tg-shop
hostname: remnawave-tg-shop hostname: remnawave-tg-shop
env_file: env_file:
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
remnawave-tg-shop: remnawave-tg-shop:
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest image: kavore/remnawave-tg-shop:latest
# build: . # build: .
container_name: remnawave-tg-shop container_name: remnawave-tg-shop
hostname: remnawave-tg-shop hostname: remnawave-tg-shop
-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-----
+18 -1
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import os
import sys import sys
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -9,6 +10,21 @@ from config.settings import get_settings, Settings
from db.database_setup import init_db, init_db_connection from db.database_setup import init_db, init_db_connection
def _resolve_log_level(value: str) -> int:
if not value:
return logging.INFO
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return logging.INFO
if normalized.isdigit():
return int(normalized)
level = getattr(logging, normalized.upper(), None)
if isinstance(level, int):
return level
return logging.INFO
async def main(): async def main():
load_dotenv() load_dotenv()
settings = get_settings() settings = get_settings()
@@ -25,8 +41,9 @@ async def main():
if __name__ == "__main__": if __name__ == "__main__":
load_dotenv()
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")),
stream=sys.stdout, stream=sys.stdout,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
try: try:
+9 -11
View File
@@ -1,12 +1,10 @@
aiogram==3.21.0 aiogram==3.24.0
python-dotenv==1.0.1 python-dotenv==1.2.1
aiohttp==3.12.14 aiohttp==3.13.3
pydantic==2.7.1 pydantic==2.12.5
yookassa==3.5.0 yookassa==3.9.0
pycountry==23.12.11 httpx>=0.27.0
pydantic_settings pydantic_settings==2.12.0
sqlalchemy[asyncio]==2.0.29 sqlalchemy[asyncio]==2.0.45
asyncpg==0.29.0 asyncpg==0.31.0
alembic==1.13.1
aiocryptopay==0.4.8 aiocryptopay==0.4.8
cryptography==42.0.8