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, GetPurchaseRequestCommand,
ListPurchaseRequestsCommand, ListPurchaseRequestsCommand,
) )
from src.application.commands.upload_docs_button import (
UploadDocsCommand,
)

View File

@@ -26,7 +26,7 @@ class CreatePurchaseRequestCommand:
self, self,
user_id: str, user_id: str,
*, *,
usdt_amount: Decimal, rub_amount: Decimal,
comment: str | None = None, comment: str | None = None,
target_wallet_chain: str | None = None, target_wallet_chain: str | None = None,
target_wallet_address: 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) legal_entity = await require_legal_entity(user_id, self._unit_of_work)
item = await self._unit_of_work.purchase_request_repository.create( item = await self._unit_of_work.purchase_request_repository.create(
organization_id=legal_entity.id, organization_id=legal_entity.id,
usdt_amount=usdt_amount, rub_amount=rub_amount,
comment=comment, comment=comment,
target_wallet_chain=target_wallet_chain, target_wallet_chain=target_wallet_chain,
target_wallet_address=target_wallet_address, target_wallet_address=target_wallet_address,
@@ -52,14 +52,14 @@ class CreatePurchaseRequestCommand:
} }
payload = { payload = {
'id_client': legal_entity.id, 'id_client': legal_entity.id,
'usdt_amount': usdt_amount, 'rub_amount': rub_amount,
} }
message = { message = {
'event': 'purchase_created', 'event': 'purchase_created',
'payload': payload, 'payload': payload,
'metadata': metadata, '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: try:
await self._messanger.publish_to_queue( await self._messanger.publish_to_queue(
queue=settings.RABBIT_TELEGRAM_NOTIFY_QUEUE, queue=settings.RABBIT_TELEGRAM_NOTIFY_QUEUE,

View File

@@ -22,11 +22,9 @@ class UploadDocsCommand:
@transactional @transactional
async def __call__( async def __call__(
self, self,
user_id: str,
*, *,
comment: str | None = None, comment: str | None = None,
) -> None: ) -> None:
legal_entity = await require_legal_entity(user_id, self._unit_of_work)
message_id = str(ULID()) message_id = str(ULID())
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
metadata = { metadata = {
@@ -34,15 +32,13 @@ class UploadDocsCommand:
'timestamp': now, 'timestamp': now,
'message_id': message_id, 'message_id': message_id,
} }
payload = { payload = {}
'id_client': legal_entity.id,
}
message = { message = {
'event': 'docs_upload', 'event': 'docs_upload',
'payload': payload, 'payload': payload,
'metadata': metadata, '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: try:
await self._messanger.publish_to_queue( await self._messanger.publish_to_queue(
queue=settings.RABBIT_TELEGRAM_NOTIFY_QUEUE, queue=settings.RABBIT_TELEGRAM_NOTIFY_QUEUE,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,7 +74,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
path=settings.VAULT_CRYPTO_MASTER_KEY_PATH, path=settings.VAULT_CRYPTO_MASTER_KEY_PATH,
) )
logger.info('Crypto master key loaded from Vault') logger.info('Crypto master key loaded from Vault')
try:
yield 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}') logger.info(f'B2B service instance ended with id {instance_id}')
@@ -82,7 +87,7 @@ app: FastAPI = FastAPI(
redoc_url=None, redoc_url=None,
docs_url=None, docs_url=None,
lifespan=lifespan, lifespan=lifespan,
title='B2B Service', title='Elcsa. B2B Service',
version='1.0.0', version='1.0.0',
description='Purchase requests API for legal entity client users.', description='Purchase requests API for legal entity client users.',
license_info={ license_info={
@@ -111,7 +116,7 @@ app.add_middleware(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[], allow_origins=[],
allow_origin_regex='.*', allow_origin_regex=settings.CORS_ALLOW_ORIGIN_REGEX,
allow_credentials=True, allow_credentials=True,
allow_methods=['*'], allow_methods=['*'],
allow_headers=['*'], allow_headers=['*'],
@@ -146,3 +151,10 @@ async def ping() -> dict[str, str]:
'message': 'pong', 'message': 'pong',
'status': 'ok', '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 fastapi import Depends
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.commands import ( from src.application.commands import (
@@ -9,6 +10,7 @@ from src.application.commands import (
GetPurchaseRequestCommand, GetPurchaseRequestCommand,
ListOrganizationWalletsCommand, ListOrganizationWalletsCommand,
ListPurchaseRequestsCommand, ListPurchaseRequestsCommand,
UploadDocsCommand,
) )
from src.application.contracts import ILogger, IQueueMessanger from src.application.contracts import ILogger, IQueueMessanger
from src.infrastructure.wallet_balances import WalletBalanceService 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) 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( def get_get_organization_wallet_balances_command(
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work), unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> GetOrganizationWalletBalancesCommand: ) -> GetOrganizationWalletBalancesCommand:
return GetOrganizationWalletBalancesCommand( return GetOrganizationWalletBalancesCommand(
unit_of_work=unit_of_work, unit_of_work=unit_of_work,
balance_service=WalletBalanceService(), balance_service=_wallet_balance_service(),
logger=logger, logger=logger,
) )
@@ -76,3 +83,10 @@ def get_get_organization_secret_keys_command(
unit_of_work: IUnitOfWork = Depends(get_unit_of_work), unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> GetOrganizationSecretKeysCommand: ) -> GetOrganizationSecretKeysCommand:
return GetOrganizationSecretKeysCommand(unit_of_work=unit_of_work, logger=logger) 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.organizations import organizations_router
from src.presentation.routing.purchase_requests import purchase_requests_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 = APIRouter(prefix='/v1')
v1_router.include_router(organizations_router) 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( item = await command(
auth.user_id, auth.user_id,
usdt_amount=body.usdt_amount, rub_amount=body.rub_amount,
comment=body.comment, comment=body.comment,
target_wallet_chain=body.target_wallet_chain, target_wallet_chain=body.target_wallet_chain,
target_wallet_address=body.target_wallet_address, target_wallet_address=body.target_wallet_address,

View File

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