remove nalogo and use custom client

This commit is contained in:
kavore
2026-01-17 21:58:12 +03:00
parent 8d8fdce519
commit 0dd6beebb2
9 changed files with 424 additions and 136 deletions
+6 -5
View File
@@ -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,
}
+1 -1
View File
@@ -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",
+12 -12
View File
@@ -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(
+1 -1
View File
@@ -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",
+314
View File
@@ -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}")
+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
-105
View File
@@ -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
+20 -11
View File
@@ -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 (
+1 -1
View File
@@ -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