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/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 5f10ac1..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. @@ -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 30704c6..d6567d3 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") @@ -152,6 +154,19 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, return try: + yk_payment_id_from_hook = payment_info_from_webhook.get("id") + payment_before_update = None + if payment_db_id is not None: + payment_before_update = await payment_dal.get_payment_by_db_id( + session, + payment_db_id, + ) + should_send_nalogo_receipt = bool( + nalogo_service + and nalogo_service.configured + and payment_before_update + and not payment_before_update.yookassa_payment_id + ) # Try to capture and save payment method for future charges if available try: payment_method = payment_info_from_webhook.get("payment_method") @@ -261,7 +276,26 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, traffic_label = ( str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}" ) - config_link_display, connect_button_url = prepare_config_links( + if should_send_nalogo_receipt: + receipt_item_name = payment_info_from_webhook.get("description") + if not receipt_item_name: + if sale_mode == "traffic": + receipt_item_name = f"Remnawave traffic package {traffic_label} GB" + else: + receipt_item_name = f"Remnawave subscription {int(subscription_months)} months" + try: + await nalogo_service.create_income_receipt( + item_name=receipt_item_name, + amount=payment_value, + quantity=1.0, + operation_time=datetime.now(timezone.utc), + ) + except Exception: + logging.exception( + "Failed to send Nalogo receipt for payment %s", + yk_payment_id_from_hook, + ) + config_link_display, connect_button_url = await prepare_config_links( settings, activation_details.get("subscription_url") if activation_details else None ) config_link_text = config_link_display or _("config_link_not_available") @@ -437,6 +471,7 @@ async def yookassa_webhook_route(request: web.Request): subscription_service: SubscriptionService = request.app[ 'subscription_service'] referral_service: ReferralService = request.app['referral_service'] + nalogo_service: Optional[NalogoService] = request.app.get('nalogo_service') async_session_factory: sessionmaker = request.app[ 'async_session_factory'] except KeyError as e_app_ctx: @@ -529,7 +564,8 @@ async def yookassa_webhook_route(request: web.Request): await process_successful_payment( session, bot, payment_dict_for_processing, i18n_instance, settings, panel_service, - subscription_service, referral_service) + subscription_service, referral_service, + nalogo_service) await session.commit() else: logging.warning( diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py index ab9d2a3..5356ec8 100644 --- a/bot/handlers/user/trial_handler.py +++ b/bot/handlers/user/trial_handler.py @@ -88,7 +88,7 @@ async def request_trial_confirmation_handler( pass end_date_obj = activation_result.get("end_date") - config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links( + config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links( settings, activation_result.get("subscription_url") ) config_link_for_trial = config_link_display_for_trial or _("config_link_not_available") @@ -240,7 +240,7 @@ async def confirm_activate_trial_handler( pass end_date_obj = activation_result.get("end_date") - config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links( + config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links( settings, activation_result.get("subscription_url") ) config_link_for_trial = config_link_display_for_trial or _("config_link_not_available") 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/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 57dbc86..895064f 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -190,7 +190,7 @@ class CryptoPayService: _ = lambda k, **kw: i18n.gettext(lang, k, **kw) raw_config_link = activation.get("subscription_url") if activation else None - display_link, button_link = prepare_config_links(settings, raw_config_link) + display_link, button_link = await prepare_config_links(settings, raw_config_link) config_link_text = display_link or _("config_link_not_available") final_end = activation.get("end_date") applied_days = 0 diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index f899dca..5ca9fe4 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -319,7 +319,7 @@ class FreeKassaService: _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k raw_config_link = activation.get("subscription_url") if activation else None - config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link) + config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link) config_link_text = config_link_display or _("config_link_not_available") final_end = activation.get("end_date") if activation else None months = payment.subscription_duration_months or 1 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/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index b9e75af..a86e594 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -562,3 +562,20 @@ class PanelApiService: if response_data and not response_data.get("error") and "response" in response_data: return response_data.get("response") return None + + async def encrypt_happ_link(self, link_to_encrypt: str) -> Optional[str]: + """Encrypt a subscription link using the panel's happ crypt4 API. + + Returns the encrypted link string or None if encryption failed. + """ + payload = {"linkToEncrypt": link_to_encrypt} + response_data = await self._request( + "POST", + "/system/tools/happ/encrypt", + json=payload, + log_full_response=False + ) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response", {}).get("encryptedLink") + logging.error(f"Failed to encrypt happ link. Response: {response_data}") + return None diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index 1ad2b44..a41651e 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -215,7 +215,7 @@ class PlategaService: _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k raw_config_link = activation.get("subscription_url") if activation else None - config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link) + config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link) config_link_text = config_link_display or _("config_link_not_available") final_end = activation.get("end_date") if activation else None applied_days = 0 diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index f87822e..35c6f49 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -233,7 +233,7 @@ class SeverPayService: _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k raw_config_link = activation.get("subscription_url") if activation else None - config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link) + config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link) config_link_text = config_link_display or _("config_link_not_available") final_end = activation.get("end_date") if activation else None applied_days = 0 diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index efeb151..0dfbe2a 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -123,7 +123,7 @@ class StarsService: _ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k raw_config_link = activation_details.get("subscription_url") if activation_details else None - config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link) + config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link) config_link_text = config_link_display or _("config_link_not_available") if sale_mode == "traffic": diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 1806476..6069228 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -902,7 +902,7 @@ class SubscriptionService: else None ) config_link_raw = panel_user_data.get("subscriptionUrl") - display_link, connect_button_url = prepare_config_links(self.settings, config_link_raw) + display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw) hwid_limit = panel_user_data.get("hwidDeviceLimit") if hwid_limit is None: hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT diff --git a/bot/utils/config_link.py b/bot/utils/config_link.py index 992e42c..c7f100c 100644 --- a/bot/utils/config_link.py +++ b/bot/utils/config_link.py @@ -1,53 +1,25 @@ -import base64 import logging -from functools import lru_cache -from pathlib import Path from typing import Optional, Tuple -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import padding -from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey - from config.settings import Settings - -CRYPT4_PREFIX = "happ://crypt4/" +from bot.services.panel_api_service import PanelApiService -@lru_cache(maxsize=1) -def _load_crypt4_public_key() -> Optional[RSAPublicKey]: - """Load and cache the happ crypt4 public key from the project root.""" - pem_path = Path(__file__).resolve().parent.parent.parent / "happ-crypt4.pem" - try: - pem_bytes = pem_path.read_bytes() - key = serialization.load_pem_public_key(pem_bytes) - return key if isinstance(key, RSAPublicKey) else None - except FileNotFoundError: - logging.error("Crypt4 public key file not found at %s", pem_path) - except Exception as exc: - logging.error("Failed to load crypt4 public key: %s", exc, exc_info=True) +async def _encrypt_raw_link(settings: Settings, raw_link: str) -> Optional[str]: + """Encrypt the raw subscription URL using the panel's happ crypt4 API.""" + async with PanelApiService(settings) as panel_service: + encrypted_link = await panel_service.encrypt_happ_link(raw_link) + if encrypted_link: + return encrypted_link return None -def _encrypt_raw_link(raw_link: str) -> Optional[str]: - """Encrypt the raw subscription URL with RSA PKCS#1 v1.5 and return base64 payload.""" - public_key = _load_crypt4_public_key() - if not public_key: - return None - - try: - encrypted = public_key.encrypt(raw_link.encode("utf-8"), padding.PKCS1v15()) - return base64.b64encode(encrypted).decode("utf-8") - except Exception as exc: - logging.error("Failed to encrypt config link with crypt4: %s", exc, exc_info=True) - return None - - -def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]: +async def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]: """ Build the user-facing connection key and the URL for the connect button. Returns (display_link, button_link). When CRYPT4 is enabled the display link - is encrypted and prefixed with happ://crypt4/, and the button link is wrapped + is encrypted and prefixed with happ://crypt4/ by panel API, and the button link is wrapped with CRYPT4_REDIRECT_URL if provided. """ if not raw_link: @@ -61,9 +33,9 @@ def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[O button_link = cleaned if settings.CRYPT4_ENABLED: - encrypted_payload = _encrypt_raw_link(cleaned) + encrypted_payload = await _encrypt_raw_link(settings, cleaned) if encrypted_payload: - display_link = f"{CRYPT4_PREFIX}{encrypted_payload}" + display_link = encrypted_payload button_link = display_link else: logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.") 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/docker-compose-remote-server.yml b/docker-compose-remote-server.yml index 67e07ae..2feca9a 100644 --- a/docker-compose-remote-server.yml +++ b/docker-compose-remote-server.yml @@ -6,7 +6,7 @@ services: env_file: - .env ports: - - 8080:8080 + - '127.0.0.1:8080:8080' networks: - remnawave-network environment: diff --git a/happ-crypt4.pem b/happ-crypt4.pem deleted file mode 100644 index 08aa083..0000000 --- a/happ-crypt4.pem +++ /dev/null @@ -1,14 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3UZ0M3L4K+WjM3vkbQnz -ozHg/cRbEXvQ6i4A8RVN4OM3rK9kU01FdjyoIgywve8OEKsFnVwERZAQZ1Trv60B -hmaM76QQEE+EUlIOL9EpwKWGtTL5lYC1sT9XJMNP3/CI0gP5wwQI88cY/xedpOEB -W72EmOOShHUm/b/3m+HPmqwc4ugKj5zWV5SyiT829aFA5DxSjmIIFBAms7DafmSq -LFTYIQL5cShDY2u+/sqyAw9yZIOoqW2TFIgIHhLPWek/ocDU7zyOrlu1E0SmcQQb -LFqHq02fsnH6IcqTv3N5Adb/CkZDDQ6HvQVBmqbKZKf7ZdXkqsc/Zw27xhG7OfXC -tUmWsiL7zA+KoTd3avyOh93Q9ju4UQsHthL3Gs4vECYOCS9dsXXSHEY/1ngU/hjO -WFF8QEE/rYV6nA4PTyUvo5RsctSQL/9DJX7XNh3zngvif8LsCN2MPvx6X+zLouBX -zgBkQ9DFfZAGLWf9TR7KVjZC/3NsuUCDoAOcpmN8pENBbeB0puiKMMWSvll36+2M -YR1Xs0MgT8Y9TwhE2+TnnTJOhzmHi/BxiUlY/w2E0s4ax9GHAmX0wyF4zeV7kDkc -vHuEdc0d7vDmdw0oqCqWj0Xwq86HfORu6tm1A8uRATjb4SzjTKclKuoElVAVa5Jo -oh/uZMozC65SmDw+N5p6Su8CAwEAAQ== ------END PUBLIC KEY----- diff --git a/requirements.txt b/requirements.txt index c44cf19..e784bb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,10 @@ -aiogram==3.21.0 -python-dotenv==1.0.1 -aiohttp==3.12.14 -pydantic==2.7.1 -yookassa==3.5.0 -pycountry==23.12.11 -pydantic_settings -sqlalchemy[asyncio]==2.0.29 -asyncpg==0.29.0 -alembic==1.13.1 +aiogram==3.24.0 +python-dotenv==1.2.1 +aiohttp==3.13.3 +pydantic==2.12.5 +yookassa==3.9.0 +nalogo==1.0.0 +pydantic_settings==2.12.0 +sqlalchemy[asyncio]==2.0.45 +asyncpg==0.31.0 aiocryptopay==0.4.8 -cryptography==42.0.8