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,6 +1,7 @@
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.order import Order
from src.infrastructure.database.models.payment import Payment
from src.infrastructure.database.models.user import UserModel
from src.infrastructure.database.models.sessions import Session
__all__ = ['Base', 'UserModel', 'Session']
__all__ = ['Base','Order','Payment','UserModel']

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from src.application.domain.enums import OrderStatus
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class Order(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'orders'
user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('users.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
usdt_exchange_rate: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
gas_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
total_price: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
service_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
status: Mapped[OrderStatus] = mapped_column(
SAEnum(OrderStatus,name='order_status_enum',values_callable=lambda x:[e.value for e in x]),
nullable=False,
index=True,
default=OrderStatus.PENDING,
)
client_payment_id: Mapped[str] = mapped_column(
String(26),
nullable=False,
unique=True,
index=True
)
itpay_payment_qr_url_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_url_android: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_url_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_image_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_image_android: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_image_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
itpay_qr_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
itpay_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
itpay_created_at: Mapped[DateTime] = mapped_column(
DateTime(timezone=True),
nullable=False
)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime,Enum as SAEnum,ForeignKey,Numeric,String,UniqueConstraint,Text
from sqlalchemy.orm import Mapped, mapped_column
from src.application.domain.enums import PaymentStatus
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class Payment(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'payments'
__table_args__ = (
UniqueConstraint('order_id', name='uq_payments_order_id'),
)
user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('users.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
order_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('orders.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
status: Mapped[PaymentStatus] = mapped_column(
SAEnum(PaymentStatus,name='payment_status_enum',values_callable=lambda x:[e.value for e in x]),
nullable=False,
index=True,
default=PaymentStatus.PENDING,
)
receipt_cloudekassir_link: Mapped[str] = mapped_column(nullable=True)
itpay_payment_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
itpay_paid_amount: Mapped[Decimal | None] = mapped_column(Numeric(38, 2), nullable=True)
transaction_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
web3_transaction_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
expired_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -1,50 +0,0 @@
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from ulid import ULID
from src.infrastructure.database.models import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin, AuditTimestampsMixin
class Session(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = "sessions"
sid: Mapped[str] = mapped_column(
String(26),
unique=True,
index=True,
nullable=False,
default=lambda: str(ULID()),
)
user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey("users.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
device_id: Mapped[str] = mapped_column(
String(26),
nullable=False,
index=True,
)
user_agent: Mapped[str | None] = mapped_column(String(500))
first_ip: Mapped[str | None] = mapped_column(String(64))
last_ip: Mapped[str | None] = mapped_column(String(64))
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
refresh_jti_hash: Mapped[str | None] = mapped_column(String(255))
refresh_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
Index("ux_sessions_user_device", Session.user_id, Session.device_id, unique=True)
Index("ix_sessions_user_active", Session.user_id, Session.revoked_at)

View File

@@ -1,28 +1,30 @@
from __future__ import annotations
from sqlalchemy import Boolean, Date, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean,Date,DateTime,String
from sqlalchemy.orm import Mapped,mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin
from src.infrastructure.database.models.mixins import AuditTimestampsMixin,SoftDeleteMixin,UlidPrimaryKeyMixin
class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin):
__tablename__ = 'users'
class UserModel(Base,UlidPrimaryKeyMixin,AuditTimestampsMixin,SoftDeleteMixin):
__tablename__='users'
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str]=mapped_column(String(255),nullable=False,unique=True,index=True)
password_hash: Mapped[str]=mapped_column(String(255),nullable=False)
last_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
middle_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
birth_date: Mapped[Date | None] = mapped_column(Date, nullable=True)
last_name: Mapped[str|None]=mapped_column(String(128),nullable=True)
first_name: Mapped[str|None]=mapped_column(String(128),nullable=True)
middle_name: Mapped[str|None]=mapped_column(String(128),nullable=True)
birth_date: Mapped[Date|None]=mapped_column(Date,nullable=True)
crypto_wallet: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
crypto_wallet: Mapped[str|None]=mapped_column(String(255),nullable=True)
phone: Mapped[str|None]=mapped_column(String(16),nullable=True)
bik: Mapped[str | None] = mapped_column(String(9), nullable=True)
account_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
card_number: Mapped[str | None] = mapped_column(String(19), nullable=True)
inn: Mapped[str | None] = mapped_column(String(12), nullable=True)
bik: Mapped[str|None]=mapped_column(String(9),nullable=True)
account_number: Mapped[str|None]=mapped_column(String(20),nullable=True)
card_number: Mapped[str|None]=mapped_column(String(19),nullable=True)
inn: Mapped[str|None]=mapped_column(String(12),nullable=True)
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)
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)

View File

@@ -0,0 +1,134 @@
from __future__ import annotations
from dataclasses import replace
from datetime import datetime,timezone
from decimal import Decimal
from sqlalchemy import select,update
from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
from src.application.contracts import ILogger
from src.application.domain.entities.order import OrderEntity
from src.infrastructure.database.models.order import Order
class OrderRepository(IOrderRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
@staticmethod
def _to_entity(model: Order) -> OrderEntity:
return OrderEntity(
id=model.id,
created_at=model.created_at,
updated_at=model.updated_at,
user_id=model.user_id,
usdt_amount=model.usdt_amount,
usdt_exchange_rate=model.usdt_exchange_rate,
gas_fee=model.gas_fee,
total_price=model.total_price,
service_fee=model.service_fee,
status=model.status,
client_payment_id=model.client_payment_id,
itpay_payment_qr_url_desktop=model.itpay_payment_qr_url_desktop,
itpay_payment_qr_url_android=model.itpay_payment_qr_url_android,
itpay_payment_qr_url_ios=model.itpay_payment_qr_url_ios,
itpay_payment_qr_image_desktop=model.itpay_payment_qr_image_desktop,
itpay_payment_qr_image_android=model.itpay_payment_qr_image_android,
itpay_payment_qr_image_ios=model.itpay_payment_qr_image_ios,
itpay_id=model.itpay_id,
itpay_qr_id=model.itpay_qr_id,
itpay_amount=model.itpay_amount,
itpay_created_at=model.itpay_created_at,
)
async def create(self,order: OrderEntity) -> OrderEntity:
model = Order(
user_id=order.user_id,
usdt_amount=order.usdt_amount,
usdt_exchange_rate=order.usdt_exchange_rate,
gas_fee=order.gas_fee,
total_price=order.total_price,
service_fee=order.service_fee,
status=order.status,
client_payment_id=order.client_payment_id,
itpay_payment_qr_url_desktop=None,
itpay_payment_qr_url_android=None,
itpay_payment_qr_url_ios=None,
itpay_payment_qr_image_desktop=None,
itpay_payment_qr_image_android=None,
itpay_payment_qr_image_ios=None,
itpay_id=None,
itpay_qr_id=None,
itpay_amount=Decimal('0.00'),
itpay_created_at=datetime.now(timezone.utc),
)
self._session.add(model)
await self._session.flush()
return replace(order,id=model.id)
async def get_by_client_payment_id(self,client_payment_id: str) -> OrderEntity | None:
stmt=select(Order).where(Order.client_payment_id==client_payment_id)
model=await self._session.scalar(stmt)
if model is None:
return None
return self._to_entity(model)
async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity:
if not order.id:
raise ValueError('OrderEntity.id is required')
itpay_amount: Decimal
if order.itpay_amount is None:
itpay_amount = Decimal('0.00')
else:
itpay_amount = Decimal(str(order.itpay_amount))
itpay_created_at = order.itpay_created_at or datetime.now(timezone.utc)
stmt = (
update(Order)
.where(Order.id == order.id)
.values(
itpay_payment_qr_url_desktop=order.itpay_payment_qr_url_desktop,
itpay_payment_qr_url_android=order.itpay_payment_qr_url_android,
itpay_payment_qr_url_ios=order.itpay_payment_qr_url_ios,
itpay_payment_qr_image_desktop=order.itpay_payment_qr_image_desktop,
itpay_payment_qr_image_android=order.itpay_payment_qr_image_android,
itpay_payment_qr_image_ios=order.itpay_payment_qr_image_ios,
itpay_id=order.itpay_id,
itpay_qr_id=order.itpay_qr_id,
itpay_amount=itpay_amount,
itpay_created_at=itpay_created_at,
)
)
await self._session.execute(stmt)
await self._session.flush()
return order
async def update_after_itpay_failure(self,order: OrderEntity) -> OrderEntity:
if not order.id:
raise ValueError('OrderEntity.id is required')
if order.status is None:
raise ValueError('OrderEntity.status is required')
itpay_amount: Decimal
if order.itpay_amount is None:
itpay_amount = Decimal('0.00')
else:
itpay_amount = Decimal(str(order.itpay_amount))
itpay_created_at = order.itpay_created_at or datetime.now(timezone.utc)
stmt = (
update(Order)
.where(Order.id == order.id)
.values(
status=order.status,
itpay_id=order.itpay_id,
itpay_qr_id=order.itpay_qr_id,
itpay_amount=itpay_amount,
itpay_created_at=itpay_created_at,
)
)
await self._session.execute(stmt)
await self._session.flush()
return order

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository
from src.application.contracts import ILogger
from src.application.domain.enums import PaymentStatus
from src.infrastructure.database.models.payment import Payment
class PaymentRepository(IPaymentRepository):
def __init__(self,session: AsyncSession,logger: ILogger):
self._session=session
self._logger=logger
async def create_completed(self,*,user_id:str,order_id:str,itpay_payment_id:str,itpay_paid_amount:str|None,transaction_id:str|None,paid_at:str|None,expired_date:str|None) -> Payment:
stmt=select(Payment).where(Payment.order_id==order_id)
existing=await self._session.scalar(stmt)
if existing is not None:
return existing
paid_at_dt=datetime.fromisoformat(paid_at.replace('Z','+00:00')) if paid_at else None
expired_dt=datetime.fromisoformat(expired_date.replace('Z','+00:00')) if expired_date else None
paid_amount_dec=Decimal(str(itpay_paid_amount)) if itpay_paid_amount is not None else None
model=Payment(
user_id=user_id,
order_id=order_id,
status=PaymentStatus.PENDING,
receipt_cloudekassir_link=None,
itpay_payment_id=itpay_payment_id,
itpay_paid_amount=paid_amount_dec,
transaction_id=transaction_id,
paid_at=paid_at_dt,
expired_date=expired_dt,
)
self._session.add(model)
await self._session.flush()
return model

View File

@@ -1,7 +1,9 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.abstractions import IUnitOfWork
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository
from src.application.contracts import ILogger
# from src.application.abstractions.repositories import IUserRepository, ISessionRepository
from src.infrastructure.database.repositories.order_repository import OrderRepository
from src.infrastructure.database.repositories.payment_repository import PaymentRepository
# from src.infrastructure.database.repositories import UserRepository, SessionRepository
@@ -10,12 +12,16 @@ class UnitOfWork(IUnitOfWork):
def __init__(self, session_factory: async_sessionmaker[AsyncSession], logger: ILogger):
self.session_factory = session_factory
self._session: AsyncSession = None
self._order_repository: IOrderRepository | None = None
self._payment_repository: IPaymentRepository | None = None
# self._user_repository: IUserRepository = None
# self._session_repository: ISessionRepository = None
self._logger: ILogger = logger
async def __aenter__(self):
self._session = self.session_factory()
self._order_repository = None
self._payment_repository = None
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -29,6 +35,19 @@ class UnitOfWork(IUnitOfWork):
self._logger.debug('Commit')
await self._session.close()
@property
def order_repository(self) -> IOrderRepository:
if self._order_repository is None:
self._order_repository = OrderRepository(session=self._session, logger=self._logger)
return self._order_repository
@property
def payment_repository(self) -> IPaymentRepository:
if self._payment_repository is None:
self._payment_repository = PaymentRepository(session=self._session, logger=self._logger)
return self._payment_repository
# @property
# def user_repository(self) -> IUserRepository:
# if self._user_repository is None: