From f6ffe68e6ab2e4b25b0d138047c3b28a600fd20e Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Tue, 2 Jun 2026 23:46:57 +0300 Subject: [PATCH] feat: update for b2b --- .../abstractions/i_unit_of_work.py | 5 +- .../abstractions/repositories/__init__.py | 3 +- .../repositories/i_legal_entity_repository.py | 9 ++++ src/application/commands/get_me.py | 3 ++ .../domain/entities/legal_entity.py | 24 +++++++++ src/application/domain/entities/user.py | 5 ++ src/application/domain/enums/account_type.py | 6 +++ .../database/models/__init__.py | 3 +- .../database/models/legal_entity.py | 32 ++++++++++++ src/infrastructure/database/models/user.py | 4 ++ .../database/repositories/__init__.py | 3 +- .../repositories/legal_entity_repository.py | 49 +++++++++++++++++++ .../database/repositories/user_repository.py | 1 + src/infrastructure/database/unit_of_work.py | 12 ++++- src/presentation/schemas/me_public.py | 48 ++++++++++++++++++ 15 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 src/application/abstractions/repositories/i_legal_entity_repository.py create mode 100644 src/application/domain/entities/legal_entity.py create mode 100644 src/application/domain/enums/account_type.py create mode 100644 src/infrastructure/database/models/legal_entity.py create mode 100644 src/infrastructure/database/repositories/legal_entity_repository.py diff --git a/src/application/abstractions/i_unit_of_work.py b/src/application/abstractions/i_unit_of_work.py index 6a706bc..aafba1a 100644 --- a/src/application/abstractions/i_unit_of_work.py +++ b/src/application/abstractions/i_unit_of_work.py @@ -1,6 +1,6 @@ from __future__ import annotations from typing import Protocol, runtime_checkable -from src.application.abstractions.repositories import IUserRepository, ISessionRepository +from src.application.abstractions.repositories import IUserRepository, ISessionRepository, ILegalEntityRepository @runtime_checkable @@ -17,3 +17,6 @@ class IUnitOfWork(Protocol): @property def session_repository(self) -> ISessionRepository: ... + @property + def legal_entity_repository(self) -> ILegalEntityRepository: ... + diff --git a/src/application/abstractions/repositories/__init__.py b/src/application/abstractions/repositories/__init__.py index 7e142d8..3f8d8fb 100644 --- a/src/application/abstractions/repositories/__init__.py +++ b/src/application/abstractions/repositories/__init__.py @@ -1,2 +1,3 @@ from src.application.abstractions.repositories.i_user_repository import IUserRepository -from src.application.abstractions.repositories.i_session_repository import ISessionRepository \ No newline at end of file +from src.application.abstractions.repositories.i_session_repository import ISessionRepository +from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository \ No newline at end of file diff --git a/src/application/abstractions/repositories/i_legal_entity_repository.py b/src/application/abstractions/repositories/i_legal_entity_repository.py new file mode 100644 index 0000000..bd3ab48 --- /dev/null +++ b/src/application/abstractions/repositories/i_legal_entity_repository.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +from src.application.domain.entities.legal_entity import LegalEntityEntity + + +class ILegalEntityRepository(ABC): + @abstractmethod + async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None: + raise NotImplementedError diff --git a/src/application/commands/get_me.py b/src/application/commands/get_me.py index 7da5eb8..8cc8605 100644 --- a/src/application/commands/get_me.py +++ b/src/application/commands/get_me.py @@ -1,6 +1,7 @@ from src.application.abstractions import IUnitOfWork from src.application.contracts import ILogger, ICache from src.application.domain.entities import UserEntity +from src.application.domain.enums.account_type import AccountType from src.infrastructure.database.decorators import transactional @@ -13,5 +14,7 @@ class GetMeCommand: @transactional async def __call__(self, user_id: str) -> UserEntity: user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) + if user.account_type == AccountType.LEGAL_ENTITY.value: + user.legal_entity = await self._unit_of_work.legal_entity_repository.get_by_user_id(user_id) self._logger.info(f'User ID: {user.id}') return user \ No newline at end of file diff --git a/src/application/domain/entities/legal_entity.py b/src/application/domain/entities/legal_entity.py new file mode 100644 index 0000000..d3bbbba --- /dev/null +++ b/src/application/domain/entities/legal_entity.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass(slots=True) +class LegalEntityEntity: + id: str + user_id: str + name: str + inn: str + status: str + short_name: str | None = None + ogrn: str | None = None + kpp: str | None = None + legal_address: str | None = None + actual_address: str | None = None + bank_details: dict[str, Any] | None = None + contact_person: str | None = None + contact_phone: str | None = None + kyc_verified: bool = True + kyc_verified_at: datetime | None = None diff --git a/src/application/domain/entities/user.py b/src/application/domain/entities/user.py index 861c938..f7397a5 100644 --- a/src/application/domain/entities/user.py +++ b/src/application/domain/entities/user.py @@ -2,6 +2,8 @@ from __future__ import annotations from dataclasses import dataclass from datetime import date, datetime +from src.application.domain.entities.legal_entity import LegalEntityEntity + @dataclass(slots=True) class UserEntity: @@ -28,3 +30,6 @@ class UserEntity: created_at: datetime | None = None updated_at: datetime | None = None kyc_verified_at: datetime | None = None + + account_type: str = 'individual' + legal_entity: LegalEntityEntity | None = None diff --git a/src/application/domain/enums/account_type.py b/src/application/domain/enums/account_type.py new file mode 100644 index 0000000..abc5671 --- /dev/null +++ b/src/application/domain/enums/account_type.py @@ -0,0 +1,6 @@ +from enum import StrEnum + + +class AccountType(StrEnum): + INDIVIDUAL = 'individual' + LEGAL_ENTITY = 'legal_entity' diff --git a/src/infrastructure/database/models/__init__.py b/src/infrastructure/database/models/__init__.py index cf032ec..2e97d58 100644 --- a/src/infrastructure/database/models/__init__.py +++ b/src/infrastructure/database/models/__init__.py @@ -1,6 +1,7 @@ from src.infrastructure.database.models.base import Base from src.infrastructure.database.models.user import UserModel +from src.infrastructure.database.models.legal_entity import LegalEntityModel from src.infrastructure.database.models.sessions import Session -__all__ = ['Base', 'UserModel', 'Session'] +__all__ = ['Base', 'UserModel', 'LegalEntityModel', 'Session'] diff --git a/src/infrastructure/database/models/legal_entity.py b/src/infrastructure/database/models/legal_entity.py new file mode 100644 index 0000000..a5ecbe1 --- /dev/null +++ b/src/infrastructure/database/models/legal_entity.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin + + +class LegalEntityModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin): + __tablename__ = 'legal_entities' + + user_id: Mapped[str] = mapped_column(String(26), ForeignKey('users.id', ondelete='RESTRICT'), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(512), nullable=False) + short_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + inn: Mapped[str] = mapped_column(String(12), nullable=False, index=True) + ogrn: Mapped[str | None] = mapped_column(String(15), nullable=True) + kpp: Mapped[str | None] = mapped_column(String(9), nullable=True) + legal_address: Mapped[str | None] = mapped_column(Text, nullable=True) + actual_address: Mapped[str | None] = mapped_column(Text, nullable=True) + bank_details: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + contact_person: Mapped[str | None] = mapped_column(String(256), nullable=True) + contact_phone: Mapped[str | None] = mapped_column(String(16), nullable=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='active', default='active') + kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True) + kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[str | None] = mapped_column(String(26), nullable=True) diff --git a/src/infrastructure/database/models/user.py b/src/infrastructure/database/models/user.py index b22b5fc..e74aec1 100644 --- a/src/infrastructure/database/models/user.py +++ b/src/infrastructure/database/models/user.py @@ -27,3 +27,7 @@ class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False) kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + account_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default='individual', default='individual') + provisioned_by: Mapped[str | None] = mapped_column(String(26), nullable=True) + provisioned_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/src/infrastructure/database/repositories/__init__.py b/src/infrastructure/database/repositories/__init__.py index a0d771e..eded96d 100644 --- a/src/infrastructure/database/repositories/__init__.py +++ b/src/infrastructure/database/repositories/__init__.py @@ -1,2 +1,3 @@ from src.infrastructure.database.repositories.user_repository import UserRepository -from src.infrastructure.database.repositories.session_repository import SessionRepository \ No newline at end of file +from src.infrastructure.database.repositories.session_repository import SessionRepository +from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository \ No newline at end of file diff --git a/src/infrastructure/database/repositories/legal_entity_repository.py b/src/infrastructure/database/repositories/legal_entity_repository.py new file mode 100644 index 0000000..f663062 --- /dev/null +++ b/src/infrastructure/database/repositories/legal_entity_repository.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository +from src.application.contracts import ILogger +from src.application.domain.entities.legal_entity import LegalEntityEntity +from src.application.domain.exceptions import InternalException +from src.infrastructure.database.models.legal_entity import LegalEntityModel + + +class LegalEntityRepository(ILegalEntityRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + @staticmethod + def _to_entity(model: LegalEntityModel) -> LegalEntityEntity: + return LegalEntityEntity( + id=model.id, + user_id=model.user_id, + name=model.name, + inn=model.inn, + status=model.status, + short_name=model.short_name, + ogrn=model.ogrn, + kpp=model.kpp, + legal_address=model.legal_address, + actual_address=model.actual_address, + bank_details=model.bank_details, + contact_person=model.contact_person, + contact_phone=model.contact_phone, + kyc_verified=model.kyc_verified, + kyc_verified_at=model.kyc_verified_at, + ) + + async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None: + try: + stmt = select(LegalEntityModel).where(LegalEntityModel.user_id == user_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + if model is None: + return None + return self._to_entity(model) + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py index b28543b..5350115 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -50,6 +50,7 @@ class UserRepository(IUserRepository): is_deleted=user.is_deleted, created_at=user.created_at, updated_at=user.updated_at, + account_type=user.account_type, ) async def get_user_by_id(self, user_id: str) -> UserEntity: diff --git a/src/infrastructure/database/unit_of_work.py b/src/infrastructure/database/unit_of_work.py index a2d23e0..cf18db1 100644 --- a/src/infrastructure/database/unit_of_work.py +++ b/src/infrastructure/database/unit_of_work.py @@ -1,8 +1,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.application.abstractions import IUnitOfWork -from src.application.abstractions.repositories import IUserRepository, ISessionRepository +from src.application.abstractions.repositories import IUserRepository, ISessionRepository, ILegalEntityRepository from src.application.contracts import ILogger -from src.infrastructure.database.repositories import UserRepository, SessionRepository +from src.infrastructure.database.repositories import UserRepository, SessionRepository, LegalEntityRepository @@ -12,12 +12,14 @@ class UnitOfWork(IUnitOfWork): self._session: AsyncSession = None self._user_repository: IUserRepository = None self._session_repository: ISessionRepository = None + self._legal_entity_repository: ILegalEntityRepository = None self._logger: ILogger = logger async def __aenter__(self): self._logger.debug('UnitOfWork enter') self._user_repository = None self._session_repository = None + self._legal_entity_repository = None self._session = self.session_factory() return self @@ -44,3 +46,9 @@ class UnitOfWork(IUnitOfWork): if self._session_repository is None: self._session_repository = SessionRepository(session=self._session, logger=self._logger) return self._session_repository + + @property + def legal_entity_repository(self) -> ILegalEntityRepository: + if self._legal_entity_repository is None: + self._legal_entity_repository = LegalEntityRepository(session=self._session, logger=self._logger) + return self._legal_entity_repository diff --git a/src/presentation/schemas/me_public.py b/src/presentation/schemas/me_public.py index 07a6b58..773e09a 100644 --- a/src/presentation/schemas/me_public.py +++ b/src/presentation/schemas/me_public.py @@ -5,6 +5,45 @@ from datetime import date, datetime from pydantic import BaseModel, ConfigDict, Field from src.application.domain.entities import UserEntity +from src.application.domain.entities.legal_entity import LegalEntityEntity + + +class LegalEntityPublicResponse(BaseModel): + model_config = ConfigDict(from_attributes=False) + + id: str + name: str + inn: str + status: str + short_name: str | None = None + ogrn: str | None = None + kpp: str | None = None + legal_address: str | None = None + actual_address: str | None = None + bank_details: dict | None = None + contact_person: str | None = None + contact_phone: str | None = None + kyc_verified: bool | None = None + kyc_verified_at: datetime | None = None + + @classmethod + def from_entity(cls, entity: LegalEntityEntity) -> LegalEntityPublicResponse: + return cls( + id=entity.id, + name=entity.name, + inn=entity.inn, + status=entity.status, + short_name=entity.short_name, + ogrn=entity.ogrn, + kpp=entity.kpp, + legal_address=entity.legal_address, + actual_address=entity.actual_address, + bank_details=entity.bank_details, + contact_person=entity.contact_person, + contact_phone=entity.contact_phone, + kyc_verified=entity.kyc_verified, + kyc_verified_at=entity.kyc_verified_at, + ) class MeUserPublicResponse(BaseModel): @@ -27,9 +66,16 @@ class MeUserPublicResponse(BaseModel): created_at: datetime | None = Field(None, description='Время создания записи') updated_at: datetime | None = Field(None, description='Время последнего обновления') kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC') + account_type: str | None = Field(None, description='individual | legal_entity') + legal_entity: LegalEntityPublicResponse | None = Field(None, description='Профиль юрлица') @classmethod def from_user(cls, user: UserEntity) -> MeUserPublicResponse: + legal_entity = ( + LegalEntityPublicResponse.from_entity(user.legal_entity) + if user.legal_entity is not None + else None + ) return cls( id=user.id, email=user.email, @@ -48,6 +94,8 @@ class MeUserPublicResponse(BaseModel): created_at=user.created_at, updated_at=user.updated_at, kyc_verified_at=user.kyc_verified_at, + account_type=user.account_type, + legal_entity=legal_entity, )