Files
remnawave-bedolaga-telegram…/app/lib/nalogo/income.py
gy9vin d10ccc4844 refactor(nalogo): улучшение системы чеков NaloGO
Сохранение времени оплаты:
  - Добавлен параметр operation_time в create_receipt()
  - Чеки из очереди создаются с оригинальным временем платежа
  - Парсинг created_at из Redis очереди

  Защита от дублей (3 уровня):
  - Проверка transaction.receipt_uuid перед созданием
  - Redis ключ nalogo:created:{payment_id} с TTL 30 дней
  - Сохранение receipt_uuid в транзакцию после создания

  Бесконечные повторы:
  - Убрано удаление чеков после 10 попыток
  - Чеки остаются в очереди до успешной отправки

  Обработка ошибок:
  - Добавлена обработка 500 и "внутренняя ошибка" как временной недоступности

  Сверка чеков:
  - Заменена API сверка на сверку по логам (logs/current/payments.log)
  - Кнопка "Без чеков" → "Сверка чеков" с прямым показом сверки
  - Исправлена навигация кнопок "Назад"
2025-12-30 00:39:00 +03:00

240 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Income API implementation.
Based on PHP library's Api\\Income class.
"""
from datetime import datetime, date
from decimal import Decimal
from typing import Any, Optional
from ._http import AsyncHTTPClient
from .dto.income import (
AtomDateTime,
CancelCommentType,
CancelRequest,
IncomeClient,
IncomeRequest,
IncomeServiceItem,
IncomeType,
PaymentType,
)
class IncomeAPI:
"""
Income API for creating and managing receipts.
Provides async methods for:
- Creating income receipts (single or multiple items)
- Cancelling income receipts
Maps to PHP Api\\Income functionality.
"""
def __init__(self, http_client: AsyncHTTPClient):
self.http = http_client
async def create(
self,
name: str,
amount: Decimal | float | int | str,
quantity: Decimal | float | int | str = 1,
operation_time: datetime | None = None,
client: IncomeClient | None = None,
) -> dict[str, Any]:
"""
Create income receipt with single service item.
Maps to PHP Income::create() method.
Args:
name: Service name/description
amount: Service amount (converted to Decimal)
quantity: Service quantity (converted to Decimal, default: 1)
operation_time: Operation datetime (default: now)
client: Client information (default: individual client)
Returns:
Dictionary with response data including approvedReceiptUuid
Raises:
ValidationException: For validation errors
DomainException: For other API errors
"""
# Convert to IncomeServiceItem
service_item = IncomeServiceItem(
name=name,
amount=Decimal(str(amount)),
quantity=Decimal(str(quantity)),
)
return await self.create_multiple_items([service_item], operation_time, client)
async def create_multiple_items(
self,
services: list[IncomeServiceItem],
operation_time: datetime | None = None,
client: IncomeClient | None = None,
) -> dict[str, Any]:
"""
Create income receipt with multiple service items.
Maps to PHP Income::createMultipleItems() method.
Args:
services: List of service items
operation_time: Operation datetime (default: now)
client: Client information (default: individual client)
Returns:
Dictionary with response data including approvedReceiptUuid
Raises:
ValidationException: For validation errors (empty items, invalid amounts, etc.)
DomainException: For other API errors
"""
if not services:
raise ValueError("Services cannot be empty")
# Validate client for legal entity (mirrors PHP validation)
if client and client.income_type == IncomeType.FROM_LEGAL_ENTITY:
if not client.inn:
raise ValueError("Client INN cannot be empty for legal entity")
if not client.display_name:
raise ValueError("Client DisplayName cannot be empty for legal entity")
# Calculate total amount (mirrors PHP BigDecimal logic)
total_amount = sum(item.get_total_amount() for item in services)
# Create request object
request = IncomeRequest(
operation_time=(
AtomDateTime.from_datetime(operation_time)
if operation_time
else AtomDateTime.now()
),
request_time=AtomDateTime.now(),
services=services,
total_amount=str(total_amount),
client=client or IncomeClient(),
payment_type=PaymentType.CASH,
ignore_max_total_income_restriction=False,
)
# Make API request
response = await self.http.post("/income", json_data=request.model_dump())
return response.json() # type: ignore[no-any-return]
async def cancel(
self,
receipt_uuid: str,
comment: CancelCommentType | str,
operation_time: datetime | None = None,
request_time: datetime | None = None,
partner_code: str | None = None,
) -> dict[str, Any]:
"""
Cancel income receipt.
Maps to PHP Income::cancel() method.
Args:
receipt_uuid: Receipt UUID to cancel
comment: Cancellation reason (enum or string)
operation_time: Operation datetime (default: now)
request_time: Request datetime (default: now)
partner_code: Partner code (optional)
Returns:
Dictionary with cancellation response data
Raises:
ValidationException: For validation errors (empty UUID, invalid comment)
DomainException: For other API errors
"""
# Validate receipt UUID
if not receipt_uuid.strip():
raise ValueError("Receipt UUID cannot be empty")
# Convert comment to enum if string
if isinstance(comment, str):
# Try to find matching enum value
comment_enum = None
for enum_val in CancelCommentType:
if enum_val.value == comment:
comment_enum = enum_val
break
if comment_enum is None:
valid_comments = [e.value for e in CancelCommentType]
raise ValueError(
f"Comment is invalid. Must be one of: {valid_comments}"
)
comment = comment_enum
# Create request object
request = CancelRequest(
operation_time=(
AtomDateTime.from_datetime(operation_time)
if operation_time
else AtomDateTime.now()
),
request_time=(
AtomDateTime.from_datetime(request_time)
if request_time
else AtomDateTime.now()
),
comment=comment,
receipt_uuid=receipt_uuid.strip(),
partner_code=partner_code,
)
# Make API request
response = await self.http.post("/cancel", json_data=request.model_dump())
return response.json() # type: ignore[no-any-return]
async def get_list(
self,
from_date: Optional[date] = None,
to_date: Optional[date] = None,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
"""
Get list of income records for a period.
Args:
from_date: Start date (default: 30 days ago)
to_date: End date (default: today)
limit: Maximum number of records (default: 100)
offset: Offset for pagination (default: 0)
Returns:
Dictionary with income records list
Raises:
DomainException: For API errors
"""
from datetime import timedelta
if from_date is None:
from_date = date.today() - timedelta(days=30)
if to_date is None:
to_date = date.today()
# API использует GET с query параметрами
params = {
"from": from_date.isoformat(),
"to": to_date.isoformat(),
"limit": str(limit),
"offset": str(offset),
"sortBy": "OPERATION_TIME",
"sortOrder": "DESC",
}
# Формируем query string
query = "&".join(f"{k}={v}" for k, v in params.items())
response = await self.http.get(f"/incomes?{query}")
return response.json() # type: ignore[no-any-return]