feat: add full pay path

This commit is contained in:
2026-05-01 13:10:13 +03:00
parent d1ac7e8e84
commit bf68aca4fa
53 changed files with 1436 additions and 334 deletions

View File

@@ -1,141 +1,103 @@
import json
import os
from decimal import Decimal
from urllib.parse import parse_qs
import aiohttp
import orjson
from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse
from ulid import ULID
from src.application.commands import CreateOrderCommand
from src.application.commands import CreatePaymentCommand
from src.application.contracts import ILogger
from src.application.domain.dto import AuthContext
from src.application.domain.exceptions import ApplicationException
from src.presentation.decorators import csrf_protect, require_access_token
from src.application.domain.enums import OrderStatus
from src.presentation.decorators import require_access_token, csrf_protect
from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_command
from src.presentation.dependencies.logger import get_logger
from src.presentation.schemas.order import CreateOrder
from src.infrastructure.config import settings
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
order_router = APIRouter(prefix='/order', tags=['orders'])
ITPAY_API_BASE = 'https://api.gw.itpay.ru'
HARDCODED_USDT_TO_RUB = Decimal('10')
HARDCODED_GAS_RUB = Decimal('5')
HARDCODED_OUR_COMMISSION_RUB = Decimal('5')
HARDCODED_ITPAY_TEST_AMOUNT_RUB = Decimal('20.00')
def _amount_rub_for_itpay(amount_usdt: Decimal) -> Decimal:
return (amount_usdt * HARDCODED_USDT_TO_RUB + HARDCODED_GAS_RUB + HARDCODED_OUR_COMMISSION_RUB).quantize(Decimal('0.01'))
@order_router.post('/create')
#@csrf_protect()
async def create_order(
request: Request,
body: CreateOrder,
#auth: AuthContext = Depends(require_access_token),
logger: ILogger = Depends(get_logger),
payment_data: CreateOrder,
#auth: AuthContext = Depends(require_access_token),
command: CreateOrderCommand = Depends(get_create_order_command),
logger: ILogger = Depends(get_logger),
) -> ORJSONResponse:
amount_rub = _amount_rub_for_itpay(body.amount_usdt)
if (os.getenv('ITPAY_TEST_FORCE_20_RUB') or '').strip() == '1':
amount_rub = HARDCODED_ITPAY_TEST_AMOUNT_RUB
amount_str = str(amount_rub)
client_payment_id = str(ULID())
payload = {
'amount': amount_str,
'client_payment_id': client_payment_id,
'description': f'USDT {body.amount_usdt}',
'metadata': {
'user_id': '01KPSYW27JZ26HBDR3QS5J6VMS',
'amount_usdt': str(body.amount_usdt),
'rate': str(HARDCODED_USDT_TO_RUB),
'gas_rub': str(HARDCODED_GAS_RUB),
'commission_rub': str(HARDCODED_OUR_COMMISSION_RUB),
},
}
url = f'{ITPAY_API_BASE}/v1/payments'
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
logger.info(json.dumps({
'event': 'itpay_payment_create_request',
'client_payment_id': client_payment_id,
'amount_usdt': str(body.amount_usdt),
'amount_rub': amount_str,
'url': url,
'payload': payload,
}, ensure_ascii=False, default=str))
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
auth = aiohttp.BasicAuth(settings.ITPAY_PUBLIC_ID, settings.ITPAY_API_SECRET)
async with session.post(url, json=payload, headers=headers, auth=auth) as resp:
response_text = await resp.text()
try:
response_json = json.loads(response_text)
except json.JSONDecodeError:
response_json = {'raw': response_text}
logger.info(json.dumps({
'event': 'itpay_payment_create_response',
'client_payment_id': client_payment_id,
'status': resp.status,
'response': response_json,
}, ensure_ascii=False, default=str))
if resp.status >= 400:
logger.warning(f'itpay payments POST {resp.status} {response_text}')
raise ApplicationException(status_code=502, message='Payment provider error')
except ApplicationException:
raise
except aiohttp.ClientError as e:
logger.error(str(e))
raise ApplicationException(status_code=502, message='Payment provider unreachable')
return ORJSONResponse(
content={
'itpay': response_json,
'client_payment_id': client_payment_id,
'amount_usdt': str(body.amount_usdt),
'amount_rub': amount_str,
'hardcoded': {
'usdt_to_rub': str(HARDCODED_USDT_TO_RUB),
'gas_rub': str(HARDCODED_GAS_RUB),
'commission_rub': str(HARDCODED_OUR_COMMISSION_RUB),
},
}
#o = await command(payment_data, auth.user_id)
o = await command(payment_data, '01KPKAFN6J1NJBY15DX8JE2QYB')
itpay_error = o.status in (
OrderStatus.CANCELLED,
OrderStatus.REJECTED,
OrderStatus.ERROR,
)
http_code = 409 if itpay_error else 201
content: dict = {
'status_code': http_code,
'order': {
'id': o.id,
'created_at': o.created_at.isoformat() if o.created_at is not None else None,
'updated_at': o.updated_at.isoformat() if o.updated_at is not None else None,
'user_id': o.user_id,
'usdt_amount': str(o.usdt_amount) if o.usdt_amount is not None else None,
'usdt_exchange_rate': str(o.usdt_exchange_rate) if o.usdt_exchange_rate is not None else None,
'gas_fee': str(o.gas_fee) if o.gas_fee is not None else None,
'total_price': str(o.total_price) if o.total_price is not None else None,
'service_fee': str(o.service_fee) if o.service_fee is not None else None,
'status': o.status.value if o.status is not None else None,
'client_payment_id': o.client_payment_id,
'itpay_payment_qr_url_desktop': o.itpay_payment_qr_url_desktop,
'itpay_payment_qr_url_android': o.itpay_payment_qr_url_android,
'itpay_payment_qr_url_ios': o.itpay_payment_qr_url_ios,
'itpay_payment_qr_image_desktop': o.itpay_payment_qr_image_desktop,
'itpay_payment_qr_image_android': o.itpay_payment_qr_image_android,
'itpay_payment_qr_image_ios': o.itpay_payment_qr_image_ios,
'itpay_id': o.itpay_id,
'itpay_qr_id': o.itpay_qr_id,
'itpay_amount': str(o.itpay_amount) if o.itpay_amount is not None else None,
'itpay_created_at': o.itpay_created_at.isoformat() if o.itpay_created_at is not None else None,
}
}
log_ids = {
'event': 'order_create_itpay_failed' if itpay_error else 'order_created',
'order_id': o.id,
'user_id': o.user_id,
'client_payment_id': o.client_payment_id,
'itpay_id': o.itpay_id,
'order_status': o.status.value if o.status is not None else None,
}
logger.info(orjson.dumps(log_ids, default=str).decode())
return ORJSONResponse(content=content, status_code=http_code)
@order_router.post('/webhook/itpay')
async def itpay_webhook(request: Request, logger: ILogger = Depends(get_logger)) -> ORJSONResponse:
async def itpay_webhook(
request: Request,
payment_command: CreatePaymentCommand = Depends(get_create_payment_command),
logger: ILogger = Depends(get_logger)
) -> ORJSONResponse:
raw = await request.body()
ct = (request.headers.get('content-type') or '').lower()
logger.info(json.dumps({
'event': 'itpay_webhook_received',
'method': request.method,
'url': str(request.url),
'content_type': ct,
'body_size': len(raw),
}, ensure_ascii=False, default=str))
if 'application/json' in ct:
try:
parsed = json.loads(raw.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError):
parsed = raw.decode('utf-8', errors='replace')
payload = orjson.loads(raw)
elif 'application/x-www-form-urlencoded' in ct:
decoded = raw.decode('utf-8', errors='replace')
qs = parse_qs(decoded, keep_blank_values=True)
parsed = {k: (vals[0] if len(vals) == 1 else vals) for k, vals in qs.items()}
payload = {k: (vals[0] if len(vals) == 1 else vals) for k, vals in qs.items()}
else:
parsed = raw.decode('utf-8', errors='replace')
headers = {k: v for k, v in request.headers.items() if k.lower() not in {'authorization', 'cookie'}}
payload = orjson.loads(raw)
data = payload.get('data') if isinstance(payload.get('data'), dict) else {}
status = str(data.get('status') or '').strip().lower()
log_payload = {
'event': 'itpay_webhook_payload',
'method': request.method,
'url': str(request.url),
'headers': headers,
'body': parsed,
'event': 'itpay_webhook_received',
'webhook_id': payload.get('id'),
'webhook_type': payload.get('type'),
'payment_id': data.get('id'),
'client_payment_id': data.get('client_payment_id'),
'payment_status': status,
}
logger.info(json.dumps(log_payload, ensure_ascii=False, default=str))
logger.info(orjson.dumps(log_payload, default=str).decode())
if status == 'completed':
payment = ItpayPaymentData.model_validate(data)
await payment_command(payment)
return ORJSONResponse(content={'status': 0})