d10ccc4844
Сохранение времени оплаты:
- Добавлен параметр 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)
- Кнопка "Без чеков" → "Сверка чеков" с прямым показом сверки
- Исправлена навигация кнопок "Назад"
240 lines
7.5 KiB
Python
240 lines
7.5 KiB
Python
"""
|
|
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]
|