Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0851c5339 | ||
|
|
785b6c2d41 | ||
|
|
cce3fd4f58 | ||
|
|
0dd6beebb2 | ||
|
|
8fb2a76698 | ||
|
|
3d6713caa9 | ||
|
|
8d8fdce519 | ||
|
|
c385a1466c | ||
|
|
8e1fdb33d2 | ||
|
|
29222be1fa | ||
|
|
08aca3b28d | ||
|
|
658139d607 | ||
|
|
1d4abf7977 | ||
|
|
62e8bc08d5 | ||
|
|
475a30caf4 | ||
|
|
c1593d7868 | ||
|
|
0dbcfe3770 | ||
|
|
e3ffb87cfc | ||
|
|
b4218ec6a7 |
@@ -50,6 +50,12 @@ YOOKASSA_VAT_CODE=1 #
|
|||||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||||
|
|
||||||
|
# Nalogo (self-employed receipts)
|
||||||
|
NALOGO_INN=your_inn # INN for nalog.ru
|
||||||
|
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
|
||||||
|
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
|
||||||
|
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
|
||||||
|
|
||||||
# FreeKassa Payment Gateway Configuration
|
# FreeKassa Payment Gateway Configuration
|
||||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||||
@@ -160,6 +166,7 @@ WEB_SERVER_PORT=8080
|
|||||||
|
|
||||||
# Admin Panel Log Pagination
|
# Admin Panel Log Pagination
|
||||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||||
|
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
|
||||||
# Admin Logging Configuration
|
# Admin Logging Configuration
|
||||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11-slim AS builder
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,11 +7,11 @@ COPY requirements.txt .
|
|||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
pip install --no-cache-dir -r requirements.txt
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
FROM python:3.11-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
## 🚀 Технологии
|
## 🚀 Технологии
|
||||||
|
|
||||||
- **Python 3.11**
|
- **Python 3.12**
|
||||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||||
@@ -86,6 +86,8 @@
|
|||||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||||
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
||||||
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
||||||
|
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
|
||||||
|
| `NALOGO_PASSWORD` | Пароль для авторизации в nalog.ru (самозанятый). |
|
||||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from bot.services.panel_webhook_service import PanelWebhookService
|
|||||||
from bot.services.freekassa_service import FreeKassaService
|
from bot.services.freekassa_service import FreeKassaService
|
||||||
from bot.services.platega_service import PlategaService
|
from bot.services.platega_service import PlategaService
|
||||||
from bot.services.severpay_service import SeverPayService
|
from bot.services.severpay_service import SeverPayService
|
||||||
|
from bot.services.lknpd_service import LknpdService
|
||||||
|
|
||||||
|
|
||||||
def build_core_services(
|
def build_core_services(
|
||||||
@@ -72,6 +73,11 @@ def build_core_services(
|
|||||||
bot_username_for_default_return=bot_username_for_default_return,
|
bot_username_for_default_return=bot_username_for_default_return,
|
||||||
settings_obj=settings,
|
settings_obj=settings,
|
||||||
)
|
)
|
||||||
|
lknpd_service = LknpdService(
|
||||||
|
settings.LKNPD_INN,
|
||||||
|
settings.LKNPD_PASSWORD,
|
||||||
|
api_url=settings.LKNPD_API_URL,
|
||||||
|
)
|
||||||
|
|
||||||
# Wire services that depend on each other
|
# Wire services that depend on each other
|
||||||
try:
|
try:
|
||||||
@@ -92,6 +98,7 @@ def build_core_services(
|
|||||||
"freekassa_service": freekassa_service,
|
"freekassa_service": freekassa_service,
|
||||||
"panel_webhook_service": panel_webhook_service,
|
"panel_webhook_service": panel_webhook_service,
|
||||||
"yookassa_service": yookassa_service,
|
"yookassa_service": yookassa_service,
|
||||||
|
"lknpd_service": lknpd_service,
|
||||||
"platega_service": platega_service,
|
"platega_service": platega_service,
|
||||||
"severpay_service": severpay_service,
|
"severpay_service": severpay_service,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ async def build_and_start_web_app(
|
|||||||
app["i18n"] = dp.get("i18n_instance")
|
app["i18n"] = dp.get("i18n_instance")
|
||||||
for key in (
|
for key in (
|
||||||
"yookassa_service",
|
"yookassa_service",
|
||||||
|
"lknpd_service",
|
||||||
"subscription_service",
|
"subscription_service",
|
||||||
"referral_service",
|
"referral_service",
|
||||||
"panel_service",
|
"panel_service",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from bot.services.subscription_service import SubscriptionService
|
|||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.yookassa_service import YooKassaService
|
from bot.services.yookassa_service import YooKassaService
|
||||||
|
from bot.services.lknpd_service import LknpdService
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
@@ -37,7 +38,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
i18n: JsonI18n, settings: Settings,
|
i18n: JsonI18n, settings: Settings,
|
||||||
panel_service: PanelApiService,
|
panel_service: PanelApiService,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
referral_service: ReferralService):
|
referral_service: ReferralService,
|
||||||
|
lknpd_service: Optional[LknpdService] = None):
|
||||||
metadata = payment_info_from_webhook.get("metadata", {})
|
metadata = payment_info_from_webhook.get("metadata", {})
|
||||||
user_id_str = metadata.get("user_id")
|
user_id_str = metadata.get("user_id")
|
||||||
subscription_months_str = metadata.get("subscription_months")
|
subscription_months_str = metadata.get("subscription_months")
|
||||||
@@ -75,44 +77,53 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
amount_data = payment_info_from_webhook.get("amount", {})
|
amount_data = payment_info_from_webhook.get("amount", {})
|
||||||
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
|
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
|
||||||
payment_value = float(amount_data.get("value", 0.0))
|
payment_value = float(amount_data.get("value", 0.0))
|
||||||
|
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||||
|
|
||||||
|
payment_record = None
|
||||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
if payment_db_id is None and auto_renew_subscription_id_str:
|
||||||
try:
|
try:
|
||||||
# Create/ensure provider payment by YooKassa payment id for idempotency
|
if not yk_payment_id_from_hook:
|
||||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
logging.error(
|
||||||
|
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record."
|
||||||
|
)
|
||||||
|
return
|
||||||
from db.dal import payment_dal as _payment_dal
|
from db.dal import payment_dal as _payment_dal
|
||||||
ensured_payment = await _payment_dal.ensure_payment_with_provider_id(
|
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
|
||||||
session,
|
session, yk_payment_id_from_hook
|
||||||
user_id=user_id,
|
|
||||||
amount=payment_value,
|
|
||||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
|
||||||
months=months_for_record or 1,
|
|
||||||
description=payment_info_from_webhook.get(
|
|
||||||
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
|
|
||||||
provider="yookassa",
|
|
||||||
provider_payment_id=yk_payment_id_from_hook,
|
|
||||||
)
|
)
|
||||||
payment_db_id = ensured_payment.payment_id
|
if not payment_record:
|
||||||
# Also persist yookassa_payment_id field if not set yet
|
payment_record = await _payment_dal.ensure_payment_with_provider_id(
|
||||||
try:
|
|
||||||
await _payment_dal.update_payment_status_by_db_id(
|
|
||||||
session,
|
session,
|
||||||
payment_db_id,
|
user_id=user_id,
|
||||||
payment_info_from_webhook.get("status", "succeeded"),
|
amount=payment_value,
|
||||||
yk_payment_id_from_hook,
|
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||||
)
|
months=months_for_record or 1,
|
||||||
except Exception:
|
description=payment_info_from_webhook.get(
|
||||||
# Non-fatal; continue processing
|
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||||
logging.exception(
|
provider="yookassa",
|
||||||
"Failed to backfill yookassa_payment_id for ensured auto-renew payment"
|
provider_payment_id=yk_payment_id_from_hook,
|
||||||
)
|
)
|
||||||
|
payment_db_id = payment_record.payment_id
|
||||||
except Exception as e_ensure:
|
except Exception as e_ensure:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
|
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
elif payment_db_id is not None:
|
||||||
|
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||||
|
if not payment_record:
|
||||||
|
logging.error(
|
||||||
|
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if payment_record and payment_record.status == "succeeded":
|
||||||
|
logging.info(
|
||||||
|
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
@@ -144,6 +155,20 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||||
|
payment_before_update = None
|
||||||
|
if payment_db_id is not None:
|
||||||
|
payment_before_update = await payment_dal.get_payment_by_db_id(
|
||||||
|
session,
|
||||||
|
payment_db_id,
|
||||||
|
)
|
||||||
|
should_send_lknpd_receipt = bool(
|
||||||
|
lknpd_service
|
||||||
|
and lknpd_service.configured
|
||||||
|
and payment_info_from_webhook.get("paid") is True
|
||||||
|
and payment_info_from_webhook.get("status") == "succeeded"
|
||||||
|
and payment_before_update
|
||||||
|
and payment_before_update.status != "succeeded"
|
||||||
|
)
|
||||||
# Try to capture and save payment method for future charges if available
|
# Try to capture and save payment method for future charges if available
|
||||||
try:
|
try:
|
||||||
payment_method = payment_info_from_webhook.get("payment_method")
|
payment_method = payment_info_from_webhook.get("payment_method")
|
||||||
@@ -192,18 +217,6 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
|
||||||
session,
|
|
||||||
payment_db_id=payment_db_id,
|
|
||||||
new_status=payment_info_from_webhook.get("status", "succeeded"),
|
|
||||||
yk_payment_id=yk_payment_id_from_hook)
|
|
||||||
if not updated_payment_record:
|
|
||||||
logging.error(
|
|
||||||
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
|
|
||||||
)
|
|
||||||
raise Exception(
|
|
||||||
f"DB Error: Could not update payment record {payment_db_id}")
|
|
||||||
|
|
||||||
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
|
months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0
|
||||||
activation_details = await subscription_service.activate_subscription(
|
activation_details = await subscription_service.activate_subscription(
|
||||||
session,
|
session,
|
||||||
@@ -224,6 +237,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
raise Exception(
|
raise Exception(
|
||||||
f"Subscription Error: Failed to activate for user {user_id}")
|
f"Subscription Error: Failed to activate for user {user_id}")
|
||||||
|
|
||||||
|
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||||
|
session,
|
||||||
|
payment_db_id=payment_db_id,
|
||||||
|
new_status=payment_info_from_webhook.get("status", "succeeded"),
|
||||||
|
yk_payment_id=yk_payment_id_from_hook)
|
||||||
|
if not updated_payment_record:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
|
||||||
|
)
|
||||||
|
raise Exception(
|
||||||
|
f"DB Error: Could not update payment record {payment_db_id}")
|
||||||
|
|
||||||
base_subscription_end_date = activation_details['end_date']
|
base_subscription_end_date = activation_details['end_date']
|
||||||
final_end_date_for_user = base_subscription_end_date
|
final_end_date_for_user = base_subscription_end_date
|
||||||
applied_promo_bonus_days = activation_details.get(
|
applied_promo_bonus_days = activation_details.get(
|
||||||
@@ -253,7 +278,26 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
traffic_label = (
|
traffic_label = (
|
||||||
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
|
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
|
||||||
)
|
)
|
||||||
config_link_display, connect_button_url = prepare_config_links(
|
if should_send_lknpd_receipt:
|
||||||
|
receipt_item_name = payment_info_from_webhook.get("description")
|
||||||
|
if not receipt_item_name:
|
||||||
|
if sale_mode == "traffic":
|
||||||
|
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
|
||||||
|
else:
|
||||||
|
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
|
||||||
|
try:
|
||||||
|
await lknpd_service.create_income_receipt(
|
||||||
|
item_name=receipt_item_name,
|
||||||
|
amount=payment_value,
|
||||||
|
quantity=1.0,
|
||||||
|
operation_time=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send LKNPD receipt for payment %s",
|
||||||
|
yk_payment_id_from_hook,
|
||||||
|
)
|
||||||
|
config_link_display, connect_button_url = await prepare_config_links(
|
||||||
settings, activation_details.get("subscription_url") if activation_details else None
|
settings, activation_details.get("subscription_url") if activation_details else None
|
||||||
)
|
)
|
||||||
config_link_text = config_link_display or _("config_link_not_available")
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
@@ -429,6 +473,7 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
subscription_service: SubscriptionService = request.app[
|
subscription_service: SubscriptionService = request.app[
|
||||||
'subscription_service']
|
'subscription_service']
|
||||||
referral_service: ReferralService = request.app['referral_service']
|
referral_service: ReferralService = request.app['referral_service']
|
||||||
|
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
|
||||||
async_session_factory: sessionmaker = request.app[
|
async_session_factory: sessionmaker = request.app[
|
||||||
'async_session_factory']
|
'async_session_factory']
|
||||||
except KeyError as e_app_ctx:
|
except KeyError as e_app_ctx:
|
||||||
@@ -521,7 +566,8 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
await process_successful_payment(
|
await process_successful_payment(
|
||||||
session, bot, payment_dict_for_processing,
|
session, bot, payment_dict_for_processing,
|
||||||
i18n_instance, settings, panel_service,
|
i18n_instance, settings, panel_service,
|
||||||
subscription_service, referral_service)
|
subscription_service, referral_service,
|
||||||
|
lknpd_service)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
else:
|
else:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async def request_trial_confirmation_handler(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
end_date_obj = activation_result.get("end_date")
|
end_date_obj = activation_result.get("end_date")
|
||||||
config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links(
|
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||||
settings, activation_result.get("subscription_url")
|
settings, activation_result.get("subscription_url")
|
||||||
)
|
)
|
||||||
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||||
@@ -240,7 +240,7 @@ async def confirm_activate_trial_handler(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
end_date_obj = activation_result.get("end_date")
|
end_date_obj = activation_result.get("end_date")
|
||||||
config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links(
|
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||||
settings, activation_result.get("subscription_url")
|
settings, activation_result.get("subscription_url")
|
||||||
)
|
)
|
||||||
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
|||||||
"freekassa_service",
|
"freekassa_service",
|
||||||
"panel_webhook_service",
|
"panel_webhook_service",
|
||||||
"yookassa_service",
|
"yookassa_service",
|
||||||
|
"lknpd_service",
|
||||||
"promo_code_service",
|
"promo_code_service",
|
||||||
"stars_service",
|
"stars_service",
|
||||||
"subscription_service",
|
"subscription_service",
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class CryptoPayService:
|
|||||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||||
|
|
||||||
raw_config_link = activation.get("subscription_url") if activation else None
|
raw_config_link = activation.get("subscription_url") if activation else None
|
||||||
display_link, button_link = prepare_config_links(settings, raw_config_link)
|
display_link, button_link = await prepare_config_links(settings, raw_config_link)
|
||||||
config_link_text = display_link or _("config_link_not_available")
|
config_link_text = display_link or _("config_link_not_available")
|
||||||
final_end = activation.get("end_date")
|
final_end = activation.get("end_date")
|
||||||
applied_days = 0
|
applied_days = 0
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ class FreeKassaService:
|
|||||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
raw_config_link = activation.get("subscription_url") if activation else None
|
raw_config_link = activation.get("subscription_url") if activation else None
|
||||||
config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link)
|
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||||
config_link_text = config_link_display or _("config_link_not_available")
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
final_end = activation.get("end_date") if activation else None
|
final_end = activation.get("end_date") if activation else None
|
||||||
months = payment.subscription_duration_months or 1
|
months = payment.subscription_duration_months or 1
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -562,3 +562,20 @@ class PanelApiService:
|
|||||||
if response_data and not response_data.get("error") and "response" in response_data:
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
return response_data.get("response")
|
return response_data.get("response")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def encrypt_happ_link(self, link_to_encrypt: str) -> Optional[str]:
|
||||||
|
"""Encrypt a subscription link using the panel's happ crypt4 API.
|
||||||
|
|
||||||
|
Returns the encrypted link string or None if encryption failed.
|
||||||
|
"""
|
||||||
|
payload = {"linkToEncrypt": link_to_encrypt}
|
||||||
|
response_data = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/system/tools/happ/encrypt",
|
||||||
|
json=payload,
|
||||||
|
log_full_response=False
|
||||||
|
)
|
||||||
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
|
return response_data.get("response", {}).get("encryptedLink")
|
||||||
|
logging.error(f"Failed to encrypt happ link. Response: {response_data}")
|
||||||
|
return None
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class PlategaService:
|
|||||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
raw_config_link = activation.get("subscription_url") if activation else None
|
raw_config_link = activation.get("subscription_url") if activation else None
|
||||||
config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link)
|
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||||
config_link_text = config_link_display or _("config_link_not_available")
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
final_end = activation.get("end_date") if activation else None
|
final_end = activation.get("end_date") if activation else None
|
||||||
applied_days = 0
|
applied_days = 0
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ class SeverPayService:
|
|||||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
|
|
||||||
raw_config_link = activation.get("subscription_url") if activation else None
|
raw_config_link = activation.get("subscription_url") if activation else None
|
||||||
config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link)
|
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||||
config_link_text = config_link_display or _("config_link_not_available")
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
final_end = activation.get("end_date") if activation else None
|
final_end = activation.get("end_date") if activation else None
|
||||||
applied_days = 0
|
applied_days = 0
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class StarsService:
|
|||||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||||
|
|
||||||
raw_config_link = activation_details.get("subscription_url") if activation_details else None
|
raw_config_link = activation_details.get("subscription_url") if activation_details else None
|
||||||
config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link)
|
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
||||||
config_link_text = config_link_display or _("config_link_not_available")
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
|
|
||||||
if sale_mode == "traffic":
|
if sale_mode == "traffic":
|
||||||
|
|||||||
@@ -902,7 +902,7 @@ class SubscriptionService:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
config_link_raw = panel_user_data.get("subscriptionUrl")
|
config_link_raw = panel_user_data.get("subscriptionUrl")
|
||||||
display_link, connect_button_url = prepare_config_links(self.settings, config_link_raw)
|
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
|
||||||
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||||
if hwid_limit is None:
|
if hwid_limit is None:
|
||||||
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
||||||
|
|||||||
+11
-39
@@ -1,53 +1,25 @@
|
|||||||
import base64
|
|
||||||
import logging
|
import logging
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import padding
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from bot.services.panel_api_service import PanelApiService
|
||||||
CRYPT4_PREFIX = "happ://crypt4/"
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
async def _encrypt_raw_link(settings: Settings, raw_link: str) -> Optional[str]:
|
||||||
def _load_crypt4_public_key() -> Optional[RSAPublicKey]:
|
"""Encrypt the raw subscription URL using the panel's happ crypt4 API."""
|
||||||
"""Load and cache the happ crypt4 public key from the project root."""
|
async with PanelApiService(settings) as panel_service:
|
||||||
pem_path = Path(__file__).resolve().parent.parent.parent / "happ-crypt4.pem"
|
encrypted_link = await panel_service.encrypt_happ_link(raw_link)
|
||||||
try:
|
if encrypted_link:
|
||||||
pem_bytes = pem_path.read_bytes()
|
return encrypted_link
|
||||||
key = serialization.load_pem_public_key(pem_bytes)
|
|
||||||
return key if isinstance(key, RSAPublicKey) else None
|
|
||||||
except FileNotFoundError:
|
|
||||||
logging.error("Crypt4 public key file not found at %s", pem_path)
|
|
||||||
except Exception as exc:
|
|
||||||
logging.error("Failed to load crypt4 public key: %s", exc, exc_info=True)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _encrypt_raw_link(raw_link: str) -> Optional[str]:
|
async def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
||||||
"""Encrypt the raw subscription URL with RSA PKCS#1 v1.5 and return base64 payload."""
|
|
||||||
public_key = _load_crypt4_public_key()
|
|
||||||
if not public_key:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
encrypted = public_key.encrypt(raw_link.encode("utf-8"), padding.PKCS1v15())
|
|
||||||
return base64.b64encode(encrypted).decode("utf-8")
|
|
||||||
except Exception as exc:
|
|
||||||
logging.error("Failed to encrypt config link with crypt4: %s", exc, exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
|
||||||
"""
|
"""
|
||||||
Build the user-facing connection key and the URL for the connect button.
|
Build the user-facing connection key and the URL for the connect button.
|
||||||
|
|
||||||
Returns (display_link, button_link). When CRYPT4 is enabled the display link
|
Returns (display_link, button_link). When CRYPT4 is enabled the display link
|
||||||
is encrypted and prefixed with happ://crypt4/, and the button link is wrapped
|
is encrypted and prefixed with happ://crypt4/ by panel API, and the button link is wrapped
|
||||||
with CRYPT4_REDIRECT_URL if provided.
|
with CRYPT4_REDIRECT_URL if provided.
|
||||||
"""
|
"""
|
||||||
if not raw_link:
|
if not raw_link:
|
||||||
@@ -61,9 +33,9 @@ def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[O
|
|||||||
button_link = cleaned
|
button_link = cleaned
|
||||||
|
|
||||||
if settings.CRYPT4_ENABLED:
|
if settings.CRYPT4_ENABLED:
|
||||||
encrypted_payload = _encrypt_raw_link(cleaned)
|
encrypted_payload = await _encrypt_raw_link(settings, cleaned)
|
||||||
if encrypted_payload:
|
if encrypted_payload:
|
||||||
display_link = f"{CRYPT4_PREFIX}{encrypted_payload}"
|
display_link = encrypted_payload
|
||||||
button_link = display_link
|
button_link = display_link
|
||||||
else:
|
else:
|
||||||
logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.")
|
logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.")
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ class Settings(BaseSettings):
|
|||||||
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LKNPD_INN: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="NALOGO_INN",
|
||||||
|
description="INN for lknpd.nalog.ru (self-employed) authentication"
|
||||||
|
)
|
||||||
|
LKNPD_PASSWORD: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="NALOGO_PASSWORD",
|
||||||
|
description="Password for lknpd.nalog.ru (self-employed) authentication"
|
||||||
|
)
|
||||||
|
LKNPD_API_URL: str = Field(
|
||||||
|
default="https://lknpd.nalog.ru/api",
|
||||||
|
alias="NALOGO_API_URL",
|
||||||
|
description="Base URL for LKNPD API (can be overridden for proxies)"
|
||||||
|
)
|
||||||
|
LKNPD_RECEIPT_NAME_SUBSCRIPTION: str = Field(
|
||||||
|
default="subscription {months} months",
|
||||||
|
alias="NALOGO_RECEIPT_NAME_SUBSCRIPTION",
|
||||||
|
description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration."
|
||||||
|
)
|
||||||
|
LKNPD_RECEIPT_NAME_TRAFFIC: str = Field(
|
||||||
|
default="traffic package {gb} GB",
|
||||||
|
alias="NALOGO_RECEIPT_NAME_TRAFFIC",
|
||||||
|
description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount."
|
||||||
|
)
|
||||||
|
|
||||||
WEBHOOK_BASE_URL: Optional[str] = None
|
WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
|
|
||||||
CRYPTOPAY_TOKEN: Optional[str] = None
|
CRYPTOPAY_TOKEN: Optional[str] = None
|
||||||
@@ -488,9 +514,22 @@ class Settings(BaseSettings):
|
|||||||
return methods or default_order
|
return methods or default_order
|
||||||
|
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
|
LOG_LEVEL: str = Field(
|
||||||
|
default="INFO",
|
||||||
|
description="Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||||
|
)
|
||||||
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
|
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
|
||||||
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
|
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
|
||||||
|
|
||||||
|
@field_validator('LOG_LEVEL', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_log_level(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.strip().upper()
|
||||||
|
if not v:
|
||||||
|
return "INFO"
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
|
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_optional_int_fields(cls, v):
|
def validate_optional_int_fields(cls, v):
|
||||||
@@ -556,6 +595,16 @@ def get_settings() -> Settings:
|
|||||||
logging.warning(
|
logging.warning(
|
||||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
_settings_instance.LKNPD_INN
|
||||||
|
or _settings_instance.LKNPD_PASSWORD
|
||||||
|
) and not (
|
||||||
|
_settings_instance.LKNPD_INN
|
||||||
|
and _settings_instance.LKNPD_PASSWORD
|
||||||
|
):
|
||||||
|
logging.warning(
|
||||||
|
"WARNING: LKNPD credentials are incomplete. Receipt sending will be disabled."
|
||||||
|
)
|
||||||
if _settings_instance.FREEKASSA_ENABLED:
|
if _settings_instance.FREEKASSA_ENABLED:
|
||||||
if (
|
if (
|
||||||
not _settings_instance.FREEKASSA_MERCHANT_ID
|
not _settings_instance.FREEKASSA_MERCHANT_ID
|
||||||
|
|||||||
@@ -60,17 +60,18 @@ async def ensure_payment_with_provider_id(
|
|||||||
"""Idempotently create a payment record for a provider event.
|
"""Idempotently create a payment record for a provider event.
|
||||||
|
|
||||||
If a payment with the same provider_payment_id already exists, returns it.
|
If a payment with the same provider_payment_id already exists, returns it.
|
||||||
Otherwise creates a new succeeded payment with provided data.
|
Otherwise creates a new pending payment with provided data.
|
||||||
"""
|
"""
|
||||||
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
|
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
|
||||||
if existing:
|
if existing:
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
|
pending_status = f"pending_{provider}" if provider else "pending"
|
||||||
payment_payload: Dict[str, Any] = {
|
payment_payload: Dict[str, Any] = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"amount": float(amount),
|
"amount": float(amount),
|
||||||
"currency": currency,
|
"currency": currency,
|
||||||
"status": "succeeded",
|
"status": pending_status,
|
||||||
"description": description,
|
"description": description,
|
||||||
"subscription_duration_months": months,
|
"subscription_duration_months": months,
|
||||||
"provider_payment_id": provider_payment_id,
|
"provider_payment_id": provider_payment_id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
remnawave-tg-shop:
|
remnawave-tg-shop:
|
||||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
image: kavore/remnawave-tg-shop:latest
|
||||||
container_name: remnawave-tg-shop
|
container_name: remnawave-tg-shop
|
||||||
hostname: remnawave-tg-shop
|
hostname: remnawave-tg-shop
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
remnawave-tg-shop:
|
remnawave-tg-shop:
|
||||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
image: kavore/remnawave-tg-shop:latest
|
||||||
# build: .
|
# build: .
|
||||||
container_name: remnawave-tg-shop
|
container_name: remnawave-tg-shop
|
||||||
hostname: remnawave-tg-shop
|
hostname: remnawave-tg-shop
|
||||||
|
|||||||
@@ -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-----
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -9,6 +10,21 @@ from config.settings import get_settings, Settings
|
|||||||
from db.database_setup import init_db, init_db_connection
|
from db.database_setup import init_db, init_db_connection
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_log_level(value: str) -> int:
|
||||||
|
if not value:
|
||||||
|
return logging.INFO
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
return logging.INFO
|
||||||
|
if normalized.isdigit():
|
||||||
|
return int(normalized)
|
||||||
|
level = getattr(logging, normalized.upper(), None)
|
||||||
|
if isinstance(level, int):
|
||||||
|
return level
|
||||||
|
return logging.INFO
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -25,8 +41,9 @@ async def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
load_dotenv()
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")),
|
||||||
stream=sys.stdout,
|
stream=sys.stdout,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
try:
|
try:
|
||||||
|
|||||||
+9
-11
@@ -1,12 +1,10 @@
|
|||||||
aiogram==3.21.0
|
aiogram==3.24.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.2.1
|
||||||
aiohttp==3.12.14
|
aiohttp==3.13.3
|
||||||
pydantic==2.7.1
|
pydantic==2.12.5
|
||||||
yookassa==3.5.0
|
yookassa==3.9.0
|
||||||
pycountry==23.12.11
|
httpx>=0.27.0
|
||||||
pydantic_settings
|
pydantic_settings==2.12.0
|
||||||
sqlalchemy[asyncio]==2.0.29
|
sqlalchemy[asyncio]==2.0.45
|
||||||
asyncpg==0.29.0
|
asyncpg==0.31.0
|
||||||
alembic==1.13.1
|
|
||||||
aiocryptopay==0.4.8
|
aiocryptopay==0.4.8
|
||||||
cryptography==42.0.8
|
|
||||||
|
|||||||
Reference in New Issue
Block a user