Compare commits

...

9 Commits

Author SHA1 Message Date
Codex
67b65cbdaf Add healthcheck endpoint 2026-07-01 12:21:48 +03:00
73b5922c97 fix: harden wallet balances and service startup 2026-06-28 14:12:37 +03:00
bf8d778a9f feat: add upload docs endpoint 2026-06-25 23:09:58 +03:00
e85889eff8 fix: delete debug logs 2026-06-25 22:43:56 +03:00
37881a9da1 fix: delete mocks 2026-06-25 22:41:48 +03:00
d3f961f1d5 feat: add health check endpoint 2026-06-16 11:42:09 +03:00
61a9e47f4b refactor: change title 2026-06-15 14:06:09 +03:00
3c36ab2de0 refactor: remove comments 2026-06-15 13:58:47 +03:00
5a0f1c8fd9 refactor: swap currency 2026-06-15 13:54:06 +03:00
15 changed files with 111 additions and 37 deletions

View File

@@ -10,3 +10,6 @@ from src.application.commands.purchase_request_commands import (
GetPurchaseRequestCommand,
ListPurchaseRequestsCommand,
)
from src.application.commands.upload_docs_button import (
UploadDocsCommand,
)

View File

@@ -26,7 +26,7 @@ class CreatePurchaseRequestCommand:
self,
user_id: str,
*,
usdt_amount: Decimal,
rub_amount: Decimal,
comment: str | None = None,
target_wallet_chain: str | None = None,
target_wallet_address: str | None = None,
@@ -34,7 +34,7 @@ class CreatePurchaseRequestCommand:
legal_entity = await require_legal_entity(user_id, self._unit_of_work)
item = await self._unit_of_work.purchase_request_repository.create(
organization_id=legal_entity.id,
usdt_amount=usdt_amount,
rub_amount=rub_amount,
comment=comment,
target_wallet_chain=target_wallet_chain,
target_wallet_address=target_wallet_address,
@@ -52,14 +52,14 @@ class CreatePurchaseRequestCommand:
}
payload = {
'id_client': legal_entity.id,
'usdt_amount': usdt_amount,
'rub_amount': rub_amount,
}
message = {
'event': 'purchase_created',
'payload': payload,
'metadata': metadata,
}
self._logger.info(f'Create purchase request for organization_id={legal_entity.id } with usdt_amount={usdt_amount}')
self._logger.info(f'Create purchase request for organization_id={legal_entity.id } with rub_amount={rub_amount}')
try:
await self._messanger.publish_to_queue(
queue=settings.RABBIT_TELEGRAM_NOTIFY_QUEUE,

View File

@@ -22,11 +22,9 @@ class UploadDocsCommand:
@transactional
async def __call__(
self,
user_id: str,
*,
comment: str | None = None,
) -> None:
legal_entity = await require_legal_entity(user_id, self._unit_of_work)
message_id = str(ULID())
now = datetime.now(timezone.utc).isoformat()
metadata = {
@@ -34,15 +32,13 @@ class UploadDocsCommand:
'timestamp': now,
'message_id': message_id,
}
payload = {
'id_client': legal_entity.id,
}
payload = {}
message = {
'event': 'docs_upload',
'payload': payload,
'metadata': metadata,
}
self._logger.info(f'Upload docs for organization_id={legal_entity.id}')
self._logger.info(f'New client uploaded docs, sending notification to telegram')
try:
await self._messanger.publish_to_queue(
queue=settings.RABBIT_TELEGRAM_NOTIFY_QUEUE,

View File

@@ -10,8 +10,8 @@ class PurchaseRequestEntity:
id: str
organization_id: str
status: str
usdt_amount: Decimal
rub_amount: Decimal | None
rub_amount: Decimal
usdt_amount: Decimal | None
exchange_rate: Decimal | None
service_fee_percent: Decimal | None
comment: str | None

View File

@@ -65,6 +65,8 @@ class Settings(BaseSettings):
CSRF_COOKIE_PATH: str = '/'
CSRF_COOKIE_DOMAIN: str | None = None
CORS_ALLOW_ORIGIN_REGEX: str = r'https?://([a-z0-9-]+\.)*elcsa\.ru(:\d+)?$'
DOCS_USERNAME: str = 'admin'
DOCS_PASSWORD: str = 'admin'
@@ -237,7 +239,7 @@ class Settings(BaseSettings):
@property
def EXCLUDED_PATHS(self) -> List[str]:
return ['/docs', '/redoc', '/openapi.json', '/ping', '/health']
return ['/docs', '/redoc', '/openapi.json', '/ping', '/health', '/healthcheck']
@lru_cache(maxsize=1)

View File

@@ -20,8 +20,8 @@ class PurchaseRequestModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
index=True,
)
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted')
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
rub_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
rub_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
usdt_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
comment: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -49,7 +49,7 @@ class PurchaseRequestRepository(IPurchaseRequestRepository):
self,
*,
organization_id: str,
usdt_amount: Decimal,
rub_amount: Decimal,
comment: str | None,
target_wallet_chain: str | None,
target_wallet_address: str | None,
@@ -59,7 +59,7 @@ class PurchaseRequestRepository(IPurchaseRequestRepository):
id=str(ULID()),
organization_id=organization_id,
status='submitted',
usdt_amount=usdt_amount,
rub_amount=rub_amount,
comment=comment,
target_wallet_chain=target_wallet_chain or 'ETH',
target_wallet_address=target_wallet_address,

View File

@@ -37,9 +37,7 @@ class RabbitClient(IQueueMessanger):
correlation_id: str | None = None,
message_id: str | None = None,
) -> None:
print("check2")
await self._ensure_connected()
print("check3")
await self._broker.publish(
message,
queue=queue,

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import hashlib
import time
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any
@@ -19,6 +20,8 @@ from src.infrastructure.wallet_balances.token_registry import (
)
TIMEOUT_SECONDS = 15
RETRY_ATTEMPTS = 2
PRICE_CACHE_SECONDS = 30
BLOCKSTREAM_URL = 'https://blockstream.info/api'
TRONGRID_URL = 'https://api.trongrid.io'
COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price'
@@ -49,6 +52,10 @@ class WalletBalance:
class WalletBalanceService:
def __init__(self) -> None:
self._prices_cache: dict[tuple[tuple[str,str],...],tuple[float,dict[str,Decimal | None]]] = {}
async def get_wallet_balances(self, wallets) -> list[WalletBalance]:
prices = await self._get_prices(wallets)
results = await asyncio.gather(
@@ -193,10 +200,17 @@ class WalletBalanceService:
for token in self._tokens_for_chain(chain):
coin_id = get_coingecko_id(chain, token['symbol'])
if coin_id:
pairs[f'{chain}:{token['symbol']}'] = coin_id
symbol = token['symbol']
pairs[f'{chain}:{symbol}'] = coin_id
if not pairs:
return {}
cache_key = tuple(sorted(pairs.items()))
cached = self._prices_cache.get(cache_key)
now = time.monotonic()
if cached is not None and now - cached[0] < PRICE_CACHE_SECONDS:
return dict(cached[1])
try:
response = await self._get_json(
COINGECKO_URL,
@@ -210,10 +224,12 @@ class WalletBalanceService:
except Exception:
return {key: None for key in pairs}
return {
prices = {
key: self._decimal_or_none((response.get(coin_id) or {}).get('usd'))
for key, coin_id in pairs.items()
}
self._prices_cache[cache_key] = (now,prices)
return prices
def _token_amounts(
self,
@@ -287,9 +303,16 @@ class WalletBalanceService:
timeout: int = TIMEOUT_SECONDS,
) -> Any:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
for attempt in range(RETRY_ATTEMPTS + 1):
try:
response = await client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
except (httpx.RequestError,httpx.HTTPStatusError,ValueError):
if attempt >= RETRY_ATTEMPTS:
raise
await asyncio.sleep(0.2 * (attempt + 1))
raise RuntimeError('HTTP GET failed')
async def _post_json(
self,
@@ -300,9 +323,16 @@ class WalletBalanceService:
timeout: int = TIMEOUT_SECONDS,
) -> Any:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
return response.json()
for attempt in range(RETRY_ATTEMPTS + 1):
try:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
return response.json()
except (httpx.RequestError,httpx.HTTPStatusError,ValueError):
if attempt >= RETRY_ATTEMPTS:
raise
await asyncio.sleep(0.2 * (attempt + 1))
raise RuntimeError('HTTP POST failed')
def _tokens_for_chain(self, chain: str) -> list[dict]:
if chain in {'ETH', 'BSC'}:

View File

@@ -74,15 +74,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
path=settings.VAULT_CRYPTO_MASTER_KEY_PATH,
)
logger.info('Crypto master key loaded from Vault')
yield
logger.info(f'B2B service instance ended with id {instance_id}')
try:
yield
finally:
sched = getattr(app.state,'jwt_keys_scheduler',None)
if sched:
sched.shutdown(wait=False)
logger.info(f'B2B service instance ended with id {instance_id}')
app: FastAPI = FastAPI(
redoc_url=None,
docs_url=None,
lifespan=lifespan,
title='B2B Service',
title='Elcsa. B2B Service',
version='1.0.0',
description='Purchase requests API for legal entity client users.',
license_info={
@@ -111,7 +116,7 @@ app.add_middleware(
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_origin_regex='.*',
allow_origin_regex=settings.CORS_ALLOW_ORIGIN_REGEX,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
@@ -146,3 +151,10 @@ async def ping() -> dict[str, str]:
'message': 'pong',
'status': 'ok',
}
@app.get('/healthcheck')
@app.get('/health')
async def health() -> dict[str, str]:
return {
'status': 'ok',
}

View File

@@ -1,3 +1,4 @@
from functools import lru_cache
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
from src.application.commands import (
@@ -9,6 +10,7 @@ from src.application.commands import (
GetPurchaseRequestCommand,
ListOrganizationWalletsCommand,
ListPurchaseRequestsCommand,
UploadDocsCommand,
)
from src.application.contracts import ILogger, IQueueMessanger
from src.infrastructure.wallet_balances import WalletBalanceService
@@ -53,13 +55,18 @@ def get_list_organization_wallets_command(
return ListOrganizationWalletsCommand(unit_of_work=unit_of_work, logger=logger)
@lru_cache(maxsize=1)
def _wallet_balance_service() -> WalletBalanceService:
return WalletBalanceService()
def get_get_organization_wallet_balances_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> GetOrganizationWalletBalancesCommand:
return GetOrganizationWalletBalancesCommand(
unit_of_work=unit_of_work,
balance_service=WalletBalanceService(),
balance_service=_wallet_balance_service(),
logger=logger,
)
@@ -76,3 +83,10 @@ def get_get_organization_secret_keys_command(
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> GetOrganizationSecretKeysCommand:
return GetOrganizationSecretKeysCommand(unit_of_work=unit_of_work, logger=logger)
def get_upload_docs_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
messanger: IQueueMessanger = Depends(get_queue_messanger),
) -> UploadDocsCommand:
return UploadDocsCommand(unit_of_work=unit_of_work, logger=logger, messanger=messanger)

View File

@@ -2,8 +2,10 @@ from fastapi import APIRouter
from src.presentation.routing.organizations import organizations_router
from src.presentation.routing.purchase_requests import purchase_requests_router
from src.presentation.routing.documents import upload_docs_router
v1_router = APIRouter(prefix='/v1')
v1_router.include_router(organizations_router)
v1_router.include_router(upload_docs_router)
__all__ = ['purchase_requests_router', 'organizations_router', 'v1_router']
__all__ = ['purchase_requests_router', 'organizations_router', 'upload_docs_router', 'v1_router']

View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
from src.application.commands import (
UploadDocsCommand,
)
from src.presentation.dependencies.commands import (
get_upload_docs_command,
)
upload_docs_router = APIRouter(prefix='/upload-docs', tags=['upload-docs'])
@upload_docs_router.post('')
async def upload_docs(
command: UploadDocsCommand = Depends(get_upload_docs_command),
):
await command()
return {'message': 'Documents uploaded successfully.'}

View File

@@ -33,7 +33,7 @@ async def create_purchase_request(
):
item = await command(
auth.user_id,
usdt_amount=body.usdt_amount,
rub_amount=body.rub_amount,
comment=body.comment,
target_wallet_chain=body.target_wallet_chain,
target_wallet_address=body.target_wallet_address,

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
class CreatePurchaseRequestBody(BaseModel):
usdt_amount: Decimal = Field(gt=0)
rub_amount: Decimal = Field(gt=0)
comment: str | None = None
target_wallet_chain: str | None = Field(default='ETH', max_length=16)
target_wallet_address: str | None = Field(default=None, max_length=128)
@@ -16,8 +16,8 @@ class PurchaseRequestResponse(BaseModel):
id: str
organization_id: str
status: str
usdt_amount: str
rub_amount: str | None
rub_amount: str
usdt_amount: str | None
exchange_rate: str | None
service_fee_percent: str | None
comment: str | None