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..3d28fcc --- /dev/null +++ b/bot/services/lknpd_client.py @@ -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}") 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 38c6a44..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.WIRE, - 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