From b4218ec6a7a186e572d44e413203f3d7b69161ee Mon Sep 17 00:00:00 2001 From: machka pasla Date: Fri, 26 Dec 2025 13:44:51 +0300 Subject: [PATCH 01/11] =?UTF-8?q?=D0=98=D0=B4=D0=B5=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=B2=D0=B5=D0=B1=D1=85?= =?UTF-8?q?=D1=83=D0=BA=D0=BE=D0=B2=20YooKassa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/handlers/user/payment.py | 82 ++++++++++--------- db/dal/payment_dal.py | 5 +- ...er.yml => docker-compose-remote-server.yml | 0 3 files changed, 48 insertions(+), 39 deletions(-) rename docker-compose-removed-server.yml => docker-compose-remote-server.yml (100%) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 20ca199..30704c6 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -75,44 +75,53 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, amount_data = payment_info_from_webhook.get("amount", {}) months_for_record = int(subscription_months) if sale_mode != "traffic" else 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 payment_db_id is None and auto_renew_subscription_id_str: try: - # Create/ensure provider payment by YooKassa payment id for idempotency - yk_payment_id_from_hook = payment_info_from_webhook.get("id") + if not yk_payment_id_from_hook: + logging.error( + "Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." + ) + return from db.dal import payment_dal as _payment_dal - ensured_payment = await _payment_dal.ensure_payment_with_provider_id( - session, - 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_record = await _payment_dal.get_payment_by_provider_payment_id( + session, yk_payment_id_from_hook ) - payment_db_id = ensured_payment.payment_id - # Also persist yookassa_payment_id field if not set yet - try: - await _payment_dal.update_payment_status_by_db_id( + if not payment_record: + payment_record = await _payment_dal.ensure_payment_with_provider_id( session, - payment_db_id, - payment_info_from_webhook.get("status", "succeeded"), - yk_payment_id_from_hook, - ) - except Exception: - # Non-fatal; continue processing - logging.exception( - "Failed to backfill yookassa_payment_id for ensured auto-renew payment" + 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 = payment_record.payment_id except Exception as e_ensure: logging.error( f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", exc_info=True, ) 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) if not db_user: @@ -143,7 +152,6 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, return try: - yk_payment_id_from_hook = payment_info_from_webhook.get("id") # Try to capture and save payment method for future charges if available try: payment_method = payment_info_from_webhook.get("payment_method") @@ -192,18 +200,6 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, logging.exception("Failed to persist multi-card YooKassa method from webhook") except Exception: 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 activation_details = await subscription_service.activate_subscription( session, @@ -224,6 +220,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, raise Exception( 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'] final_end_date_for_user = base_subscription_end_date applied_promo_bonus_days = activation_details.get( diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index 87150ec..0ae6ec3 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -60,17 +60,18 @@ async def ensure_payment_with_provider_id( """Idempotently create a payment record for a provider event. 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) if existing: return existing + pending_status = f"pending_{provider}" if provider else "pending" payment_payload: Dict[str, Any] = { "user_id": user_id, "amount": float(amount), "currency": currency, - "status": "succeeded", + "status": pending_status, "description": description, "subscription_duration_months": months, "provider_payment_id": provider_payment_id, diff --git a/docker-compose-removed-server.yml b/docker-compose-remote-server.yml similarity index 100% rename from docker-compose-removed-server.yml rename to docker-compose-remote-server.yml From 475a30caf49e3903cec0c1933eb9e4d6a0ef4c26 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Mon, 5 Jan 2026 19:43:29 +0300 Subject: [PATCH 02/11] added my nalog management --- .env.example | 4 ++ README.md | 2 + bot/app/factories/build_services.py | 6 ++ bot/app/web/web_server.py | 1 + bot/handlers/user/payment.py | 39 ++++++++++- bot/main_bot.py | 1 + bot/services/nalogo_service.py | 103 ++++++++++++++++++++++++++++ config/settings.py | 19 +++++ requirements.txt | 4 +- 9 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 bot/services/nalogo_service.py diff --git a/.env.example b/.env.example index 850a61c..6bb5c5e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 5f10ac1..d2effe3 100644 --- a/README.md +++ b/README.md @@ -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`). | diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index af034b0..4de903c 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -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, } diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index f3cfdbe..cfac1c8 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -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", diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index d976d7d..4cfbd5c 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -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") @@ -144,6 +146,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, 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") @@ -253,6 +267,25 @@ 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}" ) + 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 ) @@ -429,6 +462,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: @@ -521,7 +555,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( diff --git a/bot/main_bot.py b/bot/main_bot.py index 02be218..6094c9f 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -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", diff --git a/bot/services/nalogo_service.py b/bot/services/nalogo_service.py new file mode 100644 index 0000000..9f7c116 --- /dev/null +++ b/bot/services/nalogo_service.py @@ -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 diff --git a/config/settings.py b/config/settings.py index 4e029ea..08eebb2 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 diff --git a/requirements.txt b/requirements.txt index c44cf19..6000d84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,8 @@ python-dotenv==1.0.1 aiohttp==3.12.14 pydantic==2.7.1 yookassa==3.5.0 -pycountry==23.12.11 +nalogo==1.0.0 pydantic_settings sqlalchemy[asyncio]==2.0.29 asyncpg==0.29.0 -alembic==1.13.1 aiocryptopay==0.4.8 -cryptography==42.0.8 From 62e8bc08d5a1488f424a1465c7eabd2c1fa263bd Mon Sep 17 00:00:00 2001 From: machka pasla Date: Tue, 6 Jan 2026 12:22:30 +0300 Subject: [PATCH 03/11] version update --- Dockerfile | 6 +++--- README.md | 2 +- requirements.txt | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 205277c..37a374a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . . diff --git a/README.md b/README.md index d2effe3..5312e02 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ## 🚀 Технологии -- **Python 3.11** +- **Python 3.12** - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **aiohttp:** Для запуска веб-сервера (вебхуки). - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. diff --git a/requirements.txt b/requirements.txt index 6000d84..e784bb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -aiogram==3.21.0 -python-dotenv==1.0.1 -aiohttp==3.12.14 -pydantic==2.7.1 -yookassa==3.5.0 +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 -sqlalchemy[asyncio]==2.0.29 -asyncpg==0.29.0 +pydantic_settings==2.12.0 +sqlalchemy[asyncio]==2.0.45 +asyncpg==0.31.0 aiocryptopay==0.4.8 From 08aca3b28d7dfbc112bafe1ef8f3826d21d2f712 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Wed, 14 Jan 2026 16:03:21 +0300 Subject: [PATCH 04/11] Add LOG_LEVEL env config and fix Nalogo receipt trigger --- .env.example | 1 + bot/handlers/user/payment.py | 4 +++- config/settings.py | 13 +++++++++++++ main.py | 19 ++++++++++++++++++- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 6bb5c5e..09c9a0e 100644 --- a/.env.example +++ b/.env.example @@ -164,6 +164,7 @@ WEB_SERVER_PORT=8080 # Admin Panel Log Pagination LOGS_PAGE_SIZE=10 # Number of events in the log +LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) # Admin Logging Configuration LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index d6567d3..05fc6de 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -164,8 +164,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, should_send_nalogo_receipt = bool( nalogo_service and nalogo_service.configured + and payment_info_from_webhook.get("paid") is True + and payment_info_from_webhook.get("status") == "succeeded" and payment_before_update - and not payment_before_update.yookassa_payment_id + and payment_before_update.status != "succeeded" ) # Try to capture and save payment method for future charges if available try: diff --git a/config/settings.py b/config/settings.py index 08eebb2..12fa831 100644 --- a/config/settings.py +++ b/config/settings.py @@ -497,9 +497,22 @@ class Settings(BaseSettings): return methods or default_order # 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_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') @classmethod def validate_optional_int_fields(cls, v): diff --git a/main.py b/main.py index c2744ed..9bff597 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ import asyncio import logging +import os import sys 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 +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(): load_dotenv() settings = get_settings() @@ -25,8 +41,9 @@ async def main(): if __name__ == "__main__": + load_dotenv() logging.basicConfig( - level=logging.INFO, + level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")), stream=sys.stdout, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') try: From 29222be1faced33c3c1107da3cb67d0e79de4c27 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Sat, 17 Jan 2026 15:57:17 +0300 Subject: [PATCH 05/11] .env.example and docker-compose upd --- .env.example | 2 +- docker-compose-remote-server.yml | 2 +- docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 09c9a0e..6962ce7 100644 --- a/.env.example +++ b/.env.example @@ -164,7 +164,7 @@ WEB_SERVER_PORT=8080 # Admin Panel Log Pagination LOGS_PAGE_SIZE=10 # Number of events in the log -LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) +LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) # Admin Logging Configuration LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications diff --git a/docker-compose-remote-server.yml b/docker-compose-remote-server.yml index 2feca9a..439b8b3 100644 --- a/docker-compose-remote-server.yml +++ b/docker-compose-remote-server.yml @@ -1,6 +1,6 @@ services: remnawave-tg-shop: - image: ghcr.io/machka-pasla/remnawave-tg-shop:latest + image: kavore/remnawave-tg-shop:latest container_name: remnawave-tg-shop hostname: remnawave-tg-shop env_file: diff --git a/docker-compose.yml b/docker-compose.yml index 4fbdbaf..1fb2208 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: remnawave-tg-shop: - image: ghcr.io/machka-pasla/remnawave-tg-shop:latest + image: kavore/remnawave-tg-shop:latest # build: . container_name: remnawave-tg-shop hostname: remnawave-tg-shop From c385a1466c03827df5c767663a9544160e62aff9 Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:06:58 +0300 Subject: [PATCH 06/11] added custom label --- .env.example | 2 ++ bot/handlers/user/payment.py | 4 ++-- config/settings.py | 8 ++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 6962ce7..e4459c4 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,8 @@ YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # # 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_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 05fc6de..89ff564 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -282,9 +282,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, 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" + receipt_item_name = settings.NALOGO_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label) else: - receipt_item_name = f"Remnawave subscription {int(subscription_months)} months" + receipt_item_name = settings.NALOGO_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months)) try: await nalogo_service.create_income_receipt( item_name=receipt_item_name, diff --git a/config/settings.py b/config/settings.py index 12fa831..2d70618 100644 --- a/config/settings.py +++ b/config/settings.py @@ -54,6 +54,14 @@ class Settings(BaseSettings): default=None, description="Password for nalog.ru (self-employed) authentication" ) + NALOGO_RECEIPT_NAME_SUBSCRIPTION: str = Field( + default="subscription {months} months", + description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration." + ) + NALOGO_RECEIPT_NAME_TRAFFIC: str = Field( + default="traffic package {gb} GB", + description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount." + ) WEBHOOK_BASE_URL: Optional[str] = None From 8d8fdce519bedd0195f2eb6dc8c6400d850fd0b3 Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:10:46 +0300 Subject: [PATCH 07/11] bugfix --- bot/services/nalogo_service.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/services/nalogo_service.py b/bot/services/nalogo_service.py index 9f7c116..c9438eb 100644 --- a/bot/services/nalogo_service.py +++ b/bot/services/nalogo_service.py @@ -61,10 +61,12 @@ class NalogoService: return None try: + # API expects quantity as integer when it's a whole number + qty = int(quantity) if float(quantity).is_integer() else Decimal(str(quantity)) service_item = IncomeServiceItem( name=item_name, amount=Decimal(str(amount)), - quantity=Decimal(str(quantity)), + quantity=qty, ) total_amount = service_item.get_total_amount() request = IncomeRequest( From 8fb2a76698ca1adbf7720ca61bbb5216e0da499e Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:25:02 +0300 Subject: [PATCH 08/11] bugfix --- bot/services/nalogo_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/services/nalogo_service.py b/bot/services/nalogo_service.py index c9438eb..38c6a44 100644 --- a/bot/services/nalogo_service.py +++ b/bot/services/nalogo_service.py @@ -79,7 +79,7 @@ class NalogoService: services=[service_item], total_amount=str(total_amount), client=client or IncomeClient(), - payment_type=PaymentType.ACCOUNT, + payment_type=PaymentType.WIRE, ignore_max_total_income_restriction=False, ) response = await self._client.http_client.post( From 0dd6beebb2d47f6e4b401f0430c8e266fc59f534 Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:58:12 +0300 Subject: [PATCH 09/11] remove nalogo and use custom client --- bot/app/factories/build_services.py | 11 +- bot/app/web/web_server.py | 2 +- bot/handlers/user/payment.py | 24 +-- bot/main_bot.py | 2 +- bot/services/lknpd_client.py | 314 ++++++++++++++++++++++++++++ bot/services/lknpd_service.py | 69 ++++++ bot/services/nalogo_service.py | 105 ---------- config/settings.py | 31 ++- requirements.txt | 2 +- 9 files changed, 424 insertions(+), 136 deletions(-) create mode 100644 bot/services/lknpd_client.py create mode 100644 bot/services/lknpd_service.py delete mode 100644 bot/services/nalogo_service.py diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index 4de903c..620313a 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -14,7 +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 +from bot.services.lknpd_service import LknpdService def build_core_services( @@ -73,9 +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, + lknpd_service = LknpdService( + settings.LKNPD_INN, + settings.LKNPD_PASSWORD, + api_url=settings.LKNPD_API_URL, ) # Wire services that depend on each other @@ -97,7 +98,7 @@ def build_core_services( "freekassa_service": freekassa_service, "panel_webhook_service": panel_webhook_service, "yookassa_service": yookassa_service, - "nalogo_service": nalogo_service, + "lknpd_service": lknpd_service, "platega_service": platega_service, "severpay_service": severpay_service, } diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index cfac1c8..54e2077 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -23,7 +23,7 @@ async def build_and_start_web_app( app["i18n"] = dp.get("i18n_instance") for key in ( "yookassa_service", - "nalogo_service", + "lknpd_service", "subscription_service", "referral_service", "panel_service", diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 89ff564..d489379 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -18,7 +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.services.lknpd_service import LknpdService from bot.middlewares.i18n import JsonI18n from config.settings import Settings from bot.services.notification_service import NotificationService @@ -39,7 +39,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, panel_service: PanelApiService, subscription_service: SubscriptionService, referral_service: ReferralService, - nalogo_service: Optional[NalogoService] = None): + lknpd_service: Optional[LknpdService] = None): metadata = payment_info_from_webhook.get("metadata", {}) user_id_str = metadata.get("user_id") subscription_months_str = metadata.get("subscription_months") @@ -161,9 +161,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, session, payment_db_id, ) - should_send_nalogo_receipt = bool( - nalogo_service - and nalogo_service.configured + 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 @@ -278,15 +278,15 @@ 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}" ) - if should_send_nalogo_receipt: + 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.NALOGO_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label) + receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label) else: - receipt_item_name = settings.NALOGO_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months)) + receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months)) try: - await nalogo_service.create_income_receipt( + await lknpd_service.create_income_receipt( item_name=receipt_item_name, amount=payment_value, quantity=1.0, @@ -294,7 +294,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, ) except Exception: logging.exception( - "Failed to send Nalogo receipt for payment %s", + "Failed to send LKNPD receipt for payment %s", yk_payment_id_from_hook, ) config_link_display, connect_button_url = await prepare_config_links( @@ -473,7 +473,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') + lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service') async_session_factory: sessionmaker = request.app[ 'async_session_factory'] except KeyError as e_app_ctx: @@ -567,7 +567,7 @@ async def yookassa_webhook_route(request: web.Request): session, bot, payment_dict_for_processing, i18n_instance, settings, panel_service, subscription_service, referral_service, - nalogo_service) + lknpd_service) await session.commit() else: logging.warning( diff --git a/bot/main_bot.py b/bot/main_bot.py index 6094c9f..b236e53 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -201,7 +201,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher): "freekassa_service", "panel_webhook_service", "yookassa_service", - "nalogo_service", + "lknpd_service", "promo_code_service", "stars_service", "subscription_service", diff --git a/bot/services/lknpd_client.py b/bot/services/lknpd_client.py new file mode 100644 index 0000000..5d1ec25 --- /dev/null +++ b/bot/services/lknpd_client.py @@ -0,0 +1,314 @@ +""" +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 + + # Build request + request_data = { + "operationTime": _format_datetime(op_time), + "requestTime": _format_datetime(now), + "services": [ + { + "name": name, + "amount": str(amount_decimal), + "quantity": str(qty_decimal), + } + ], + "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}") diff --git a/bot/services/lknpd_service.py b/bot/services/lknpd_service.py new file mode 100644 index 0000000..6ed7da1 --- /dev/null +++ b/bot/services/lknpd_service.py @@ -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 diff --git a/bot/services/nalogo_service.py b/bot/services/nalogo_service.py deleted file mode 100644 index c9438eb..0000000 --- a/bot/services/nalogo_service.py +++ /dev/null @@ -1,105 +0,0 @@ -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: - # API expects quantity as integer when it's a whole number - qty = int(quantity) if float(quantity).is_integer() else Decimal(str(quantity)) - service_item = IncomeServiceItem( - name=item_name, - amount=Decimal(str(amount)), - quantity=qty, - ) - 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 diff --git a/config/settings.py b/config/settings.py index 2d70618..49d0fb4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -46,20 +46,29 @@ class Settings(BaseSettings): description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox." ) - NALOGO_INN: Optional[str] = Field( + LKNPD_INN: Optional[str] = Field( default=None, - description="INN for nalog.ru (self-employed) authentication" + alias="NALOGO_INN", + description="INN for lknpd.nalog.ru (self-employed) authentication" ) - NALOGO_PASSWORD: Optional[str] = Field( + LKNPD_PASSWORD: Optional[str] = Field( default=None, - description="Password for nalog.ru (self-employed) authentication" + alias="NALOGO_PASSWORD", + description="Password for lknpd.nalog.ru (self-employed) authentication" ) - NALOGO_RECEIPT_NAME_SUBSCRIPTION: str = Field( + 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." ) - NALOGO_RECEIPT_NAME_TRAFFIC: str = Field( + 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." ) @@ -587,14 +596,14 @@ def get_settings() -> Settings: "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 + _settings_instance.LKNPD_INN + or _settings_instance.LKNPD_PASSWORD ) and not ( - _settings_instance.NALOGO_INN - and _settings_instance.NALOGO_PASSWORD + _settings_instance.LKNPD_INN + and _settings_instance.LKNPD_PASSWORD ): logging.warning( - "WARNING: Nalogo credentials are incomplete. Receipt sending will be disabled." + "WARNING: LKNPD credentials are incomplete. Receipt sending will be disabled." ) if _settings_instance.FREEKASSA_ENABLED: if ( diff --git a/requirements.txt b/requirements.txt index e784bb4..9207699 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ python-dotenv==1.2.1 aiohttp==3.13.3 pydantic==2.12.5 yookassa==3.9.0 -nalogo==1.0.0 +httpx>=0.27.0 pydantic_settings==2.12.0 sqlalchemy[asyncio]==2.0.45 asyncpg==0.31.0 From cce3fd4f5812202d84f9a1c094362349bd00f029 Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:23:06 +0300 Subject: [PATCH 10/11] bugfix #4 --- bot/services/lknpd_client.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/services/lknpd_client.py b/bot/services/lknpd_client.py index 5d1ec25..3d28fcc 100644 --- a/bot/services/lknpd_client.py +++ b/bot/services/lknpd_client.py @@ -257,6 +257,13 @@ class LknpdClient: 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), @@ -265,7 +272,7 @@ class LknpdClient: { "name": name, "amount": str(amount_decimal), - "quantity": str(qty_decimal), + "quantity": qty_value, } ], "totalAmount": str(total), From 0701af0f3523b708c0e1d5d3a554def28f14ea6c Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Mon, 19 Jan 2026 01:06:44 +0500 Subject: [PATCH 11/11] =?UTF-8?q?feat(docs):=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D1=8F=20=D0=B2=20README.md=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B5=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5312e02..2368874 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ 1. **Клонируйте репозиторий:** ```bash - git clone https://github.com/machka-pasla/remnawave-tg-shop + git clone https://github.com/kavore/remnawave-tg-shop cd remnawave-tg-shop ``` @@ -177,6 +177,152 @@ > 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут. +## Подробная инструкция для развертывания на сервере с панелью Remnawave + +### 1. Клонирование репозитория + +```bash +git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop +``` + +### 2. Настройка переменных окружения + +```bash +cp .env.example .env && nano .env +``` + +**Обязательные поля для заполнения:** +- `BOT_TOKEN` - токен телеграмм бота, например, `234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` +- `ADMIN_IDS` - TG ID администраторов, например, `12345678,98765432` и т.д. (через запятую без пробелов) +- `WEBHOOK_BASE_URL` - Обязательно. Базовый URL для вебхуков, например `https://webhook.domain.com` +- `PANEL_API_URL` - URL API вашей панели Remnawave (например, `http://remnawave:3000/api` или `https://panel.domain.com/api`) +- `PANEL_API_KEY` - API ключ для доступа к панели (генерируется из UI-интерфейса панели) +- `PANEL_WEBHOOK_SECRET` - Секретный ключ для проверки вебхуков от панели (берётся из `.env` самой панели) +- `USER_SQUAD_UUIDS` - ID отрядов для новых пользователей + +### 3. Настройка Reverse Proxy (Nginx) + +Перейдите в директорию конфигурации Nginx панели Remnawave: + +```bash +cd /opt/remnawave/nginx && nano nginx.conf +``` + +Добавьте в `nginx.conf` следующую конфигурацию: + +```nginx +upstream remnawave-tg-shop { + server remnawave-tg-shop:8080; +} + +map $http_upgrade $connection_upgrade { + default upgrade; + "" close; +} + +server { + server_name webhook.domain.com; # Домен для отправки Webhook'ов + listen 443 ssl; + http2 on; + + ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem"; + ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key"; + ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem"; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_intercept_errors on; + error_page 400 404 500 502 @redirect; + + location / { + proxy_pass http://remnawave-tg-shop$request_uri; + } + + location @redirect { + return 404; + } +} +``` + +### 4. Выпуск SSL-сертификата для домена webhook + +Убедитесь, что установлены необходимые компоненты, а также откройте 80 порт: + +```bash +sudo apt-get install cron socat +curl https://get.acme.sh | sh -s email=EMAIL && source ~/.bashrc +ufw allow 80/tcp && ufw reload +``` + +Выпустите сертификат: + +```bash +acme.sh --set-default-ca --server letsencrypt +acme.sh --issue --standalone -d 'webhook.domain.com' \ + --key-file /opt/remnawave/nginx/webhook_privkey.key \ + --fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem +``` + +### 5. Добавление сертификатов в Docker Compose Nginx + +Отредактируйте `docker-compose.yml` панели Nginx: + +```bash +cd /opt/remnawave/nginx && nano docker-compose.yml +``` + +Добавьте две строки в секцию `volumes`: + +```yaml +services: + remnawave-nginx: + image: nginx:1.26 + container_name: remnawave-nginx + hostname: remnawave-nginx + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro + - ./privkey.key:/etc/nginx/ssl/privkey.key:ro + - ./subdomain_fullchain.pem:/etc/nginx/ssl/subdomain_fullchain.pem:ro + - ./subdomain_privkey.key:/etc/nginx/ssl/subdomain_privkey.key:ro + - ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro # Добавьте эту строку + - ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro # Добавьте эту строку + restart: always + ports: + - '0.0.0.0:443:443' + networks: + - remnawave-network + +networks: + remnawave-network: + name: remnawave-network + driver: bridge + external: true +``` + +### 6. Запуск бота и перезапуск Nginx + +Запустите бота: + +```bash +cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t +``` + +Перезапустите Nginx: + +```bash +cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker compose logs -f -t +``` + ## 🐳 Docker Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.