diff --git a/alembic/versions/0ea5625b4913_order_durationdays.py b/alembic/versions/0ea5625b4913_order_durationdays.py new file mode 100644 index 0000000..12007e9 --- /dev/null +++ b/alembic/versions/0ea5625b4913_order_durationdays.py @@ -0,0 +1,46 @@ +"""+order.durationdays + +Revision ID: 0ea5625b4913 +Revises: b97ce5b7d663 +Create Date: 2026-08-02 13:17:18.429961 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = '0ea5625b4913' +down_revision: Union[str, Sequence[str], None] = 'b97ce5b7d663' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + orderstatus = postgresql.ENUM('PENDING', 'PAID', 'DISCARDED', name='orderstatus') + orderstatus.create(op.get_bind()) + + op.create_unique_constraint(None, 'order_addons', ['id']) + op.add_column('orders', sa.Column('duration_days', sa.INTEGER(), nullable=False)) + op.add_column('orders', sa.Column('status', sa.Enum('PENDING', 'PAID', 'DISCARDED', name='orderstatus'), nullable=False)) + op.create_unique_constraint(None, 'orders', ['id']) + op.create_unique_constraint(None, 'subscription_addons', ['id']) + op.create_unique_constraint(None, 'subscriptions', ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'subscriptions', type_='unique') + op.drop_constraint(None, 'subscription_addons', type_='unique') + op.drop_constraint(None, 'orders', type_='unique') + op.drop_column('orders', 'status') + op.drop_column('orders', 'duration_days') + op.drop_constraint(None, 'order_addons', type_='unique') + # ### end Alembic commands ### diff --git a/alembic/versions/b97ce5b7d663_subscriptions_infra.py b/alembic/versions/b97ce5b7d663_subscriptions_infra.py new file mode 100644 index 0000000..265c841 --- /dev/null +++ b/alembic/versions/b97ce5b7d663_subscriptions_infra.py @@ -0,0 +1,72 @@ +"""subscriptions infra + +Revision ID: b97ce5b7d663 +Revises: 3d767875ec7d +Create Date: 2026-08-02 12:38:25.357158 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b97ce5b7d663' +down_revision: Union[str, Sequence[str], None] = '3d767875ec7d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('orders', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), nullable=False), + sa.Column('devices', sa.INTEGER(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('user_id') + ) + op.create_table('subscriptions', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), nullable=False), + sa.Column('devices', sa.INTEGER(), nullable=False), + sa.Column('status', sa.Enum('ACTIVE', 'EXPIRED', name='subscriptionstatus'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('user_id') + ) + op.create_table('order_addons', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('order_id', sa.INTEGER(), nullable=False), + sa.Column('addon_id', sa.TEXT(), nullable=False), + sa.ForeignKeyConstraint(['addon_id'], ['addons.id'], ), + sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_table('subscription_addons', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('subscription_id', sa.INTEGER(), nullable=False), + sa.Column('addon_id', sa.TEXT(), nullable=False), + sa.ForeignKeyConstraint(['addon_id'], ['addons.id'], ), + sa.ForeignKeyConstraint(['subscription_id'], ['subscriptions.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('subscription_addons') + op.drop_table('order_addons') + op.drop_table('subscriptions') + op.drop_table('orders') + # ### end Alembic commands ### diff --git a/alembic/versions/bc424721d767_order_user_id_not_unique.py b/alembic/versions/bc424721d767_order_user_id_not_unique.py new file mode 100644 index 0000000..ffd2bcf --- /dev/null +++ b/alembic/versions/bc424721d767_order_user_id_not_unique.py @@ -0,0 +1,32 @@ +"""order.user_id -> NOT UNIQUE + +Revision ID: bc424721d767 +Revises: 0ea5625b4913 +Create Date: 2026-08-02 13:23:23.626697 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bc424721d767' +down_revision: Union[str, Sequence[str], None] = '0ea5625b4913' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('orders_user_id_key'), 'orders', type_='unique') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(op.f('orders_user_id_key'), 'orders', ['user_id'], postgresql_nulls_not_distinct=False) + # ### end Alembic commands ### diff --git a/config.py b/config.py index 8b6f9bd..fcea463 100644 --- a/config.py +++ b/config.py @@ -21,6 +21,8 @@ class Settings(BaseSettings): pally_shop_id: str = Field() pally_token: str = Field() + minimal_deposit: int = Field() + @computed_field @property def db_url(self) -> str: diff --git a/db/models/__init__.py b/db/models/__init__.py index efbffdb..0b9ee52 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -1,8 +1,22 @@ from .addons import Addon from .invoice import Invoice +from .orders import Order, OrderAddon from .pricing import PricingConfig from .sessions import Session +from .subscription_addons import SubscriptionAddon +from .subscriptions import Subscription from .transactions import BalanceTransaction from .users import User -__all__ = ["Addon", "BalanceTransaction", "Invoice", "PricingConfig", "Session", "User"] +__all__ = [ + "Addon", + "BalanceTransaction", + "Invoice", + "Order", + "OrderAddon", + "PricingConfig", + "Session", + "Subscription", + "SubscriptionAddon", + "User", +] diff --git a/db/models/orders.py b/db/models/orders.py new file mode 100644 index 0000000..de493c0 --- /dev/null +++ b/db/models/orders.py @@ -0,0 +1,48 @@ +from enum import StrEnum +from typing import TYPE_CHECKING + +from sqlalchemy import INTEGER, Enum, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from db.base import Base + +if TYPE_CHECKING: + from db.models import User + + +class OrderStatus(StrEnum): + PENDING = "pending" + PAID = "paid" + DISCARDED = "discarded" + + +class OrderAddon(Base): + __tablename__ = "order_addons" + + id: Mapped[int] = mapped_column( + INTEGER, nullable=False, unique=True, autoincrement=True, primary_key=True + ) + order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), nullable=False) + addon_id: Mapped[str] = mapped_column(ForeignKey("addons.id"), nullable=False) + + order: Mapped["Order"] = relationship("Order", back_populates="addons", lazy="selectin") + + +class Order(Base): + __tablename__ = "orders" + + id: Mapped[int] = mapped_column( + INTEGER, nullable=False, autoincrement=True, unique=True, primary_key=True + ) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + + devices: Mapped[int] = mapped_column(INTEGER, nullable=False) + duration_days: Mapped[int] = mapped_column(INTEGER, nullable=False) + status: Mapped[OrderStatus] = mapped_column( + Enum(OrderStatus, name="orderstatus"), nullable=False, default=OrderStatus.PENDING + ) + + user: Mapped["User"] = relationship("User", back_populates="orders", lazy="selectin") + addons: Mapped[list["OrderAddon"]] = relationship( + "OrderAddon", back_populates="order", lazy="selectin" + ) diff --git a/db/models/subscription_addons.py b/db/models/subscription_addons.py new file mode 100644 index 0000000..aa3413d --- /dev/null +++ b/db/models/subscription_addons.py @@ -0,0 +1,23 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import INTEGER, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from db.base import Base + +if TYPE_CHECKING: + from db.models import Subscription + + +class SubscriptionAddon(Base): + __tablename__ = "subscription_addons" + + id: Mapped[int] = mapped_column( + INTEGER, nullable=False, unique=True, autoincrement=True, primary_key=True + ) + subscription_id: Mapped[int] = mapped_column(ForeignKey("subscriptions.id"), nullable=False) + addon_id: Mapped[str] = mapped_column(ForeignKey("addons.id"), nullable=False) + + subscription: Mapped["Subscription"] = relationship( + "Subscription", back_populates="addons", lazy="selectin" + ) diff --git a/db/models/subscriptions.py b/db/models/subscriptions.py new file mode 100644 index 0000000..44a0b8c --- /dev/null +++ b/db/models/subscriptions.py @@ -0,0 +1,33 @@ +import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import INTEGER, DateTime, Enum, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from db.base import Base +from schemas.enums import SubscriptionStatus + +if TYPE_CHECKING: + from db.models import SubscriptionAddon, User + + +class Subscription(Base): + __tablename__ = "subscriptions" + + id: Mapped[int] = mapped_column( + INTEGER, nullable=False, autoincrement=True, unique=True, primary_key=True + ) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, unique=True) + + devices: Mapped[int] = mapped_column(INTEGER, nullable=False) + status: Mapped[SubscriptionStatus] = mapped_column( + Enum(SubscriptionStatus, name="subscriptionstatus"), + nullable=False, + default=SubscriptionStatus.EXPIRED, + ) + expires_at: Mapped[datetime.datetime] = mapped_column(DateTime(True), nullable=False) + + user: Mapped["User"] = relationship("User", back_populates="subscription", lazy="selectin") + addons: Mapped[list["SubscriptionAddon"]] = relationship( + "SubscriptionAddon", back_populates="subscription", lazy="selectin" + ) diff --git a/db/models/users.py b/db/models/users.py index 4295f58..3aa68c5 100644 --- a/db/models/users.py +++ b/db/models/users.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from db.base import Base if TYPE_CHECKING: - from db.models.sessions import Session + from db.models import Order, Session, Subscription class User(Base): @@ -22,6 +22,10 @@ class User(Base): balance: Mapped[float] = mapped_column(REAL, nullable=False, default=0) + orders: Mapped[list["Order"]] = relationship("Order", back_populates="user", lazy="selectin") + subscription: Mapped["Subscription"] = relationship( + "Subscription", back_populates="user", lazy="selectin" + ) sessions: Mapped[list["Session"]] = relationship(back_populates="user", lazy="selectin") referal: Mapped["User | None"] = relationship( "User", diff --git a/external/pally.py b/external/pally.py index b673594..75c4080 100644 --- a/external/pally.py +++ b/external/pally.py @@ -224,7 +224,7 @@ class BillService(BaseService): super().__init__(session) self._payer_pays_commission = payer_pays_commission - async def create( + async def create( # noqa: PLR0917 self, amount: float, shop_id: str, diff --git a/repositories/orders.py b/repositories/orders.py new file mode 100644 index 0000000..54f8883 --- /dev/null +++ b/repositories/orders.py @@ -0,0 +1,51 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models.orders import Order, OrderAddon, OrderStatus + + +class OrderRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def create( + self, + user_id: int, + devices: int, + duration_days: int, + addons: list[str] | None = None, + status: OrderStatus = OrderStatus.PENDING, + ) -> Order: + if not addons: + addons = [] + + order = Order( + user_id=user_id, + devices=devices, + duration_days=duration_days, + status=status, + ) + self.session.add(order) + await self.session.flush() + + for addon_id in addons: + addon = OrderAddon(order_id=order.id, addon_id=addon_id) + self.session.add(addon) + + await self.session.commit() + + return order + + async def get_order_by_user_id(self, user_id: int) -> list[Order]: + stmt = select(Order).where(Order.user_id == user_id) + r = await self.session.execute(stmt) + + return list(r.scalars().all()) + + async def get_active_by_user_id(self, user_id: int) -> list[Order]: + stmt = ( + select(Order).where(Order.user_id == user_id).where(Order.status == OrderStatus.PENDING) + ) + r = await self.session.execute(stmt) + + return list(r.scalars().all()) diff --git a/routes/orders.py b/routes/orders.py index 3e26fb8..2937926 100644 --- a/routes/orders.py +++ b/routes/orders.py @@ -7,15 +7,17 @@ from db.session import get_db from external.pally import PallyClient from repositories import AddonsRepository, PricingRepository from repositories.invoices import InvoiceRepository +from repositories.orders import OrderRepository +from schemas.checkout import CheckoutResponse from schemas.dto import AuthContext -from schemas.invoices import InvoiceResponse, InvoiceStatus +from schemas.invoices import InvoiceStatus from schemas.plans import OrderDetails from services.plans import calculate_price, get_pricing_model router = APIRouter(prefix="/orders") -@router.post("/checkout", response_model=InvoiceResponse, status_code=201) +@router.post("/checkout", response_model=CheckoutResponse, status_code=201) async def checkout( order: OrderDetails, ctx: AuthContext = Depends(get_auth_context), @@ -25,17 +27,36 @@ async def checkout( addons_repo = AddonsRepository(session) pricing_repo = PricingRepository(session) invoices_repo = InvoiceRepository(session) + orders_repo = OrderRepository(session) + + order_entry = await orders_repo.create( + ctx.user.id, + order.devices, + order.duration_days, + addons=order.addons, + ) pricing = await get_pricing_model(addons_repo, pricing_repo) price = await calculate_price(addons_repo=addons_repo, order=order, pricing=pricing) + bonus_covered = ctx.user.balance + amount_to_pay = max(price - bonus_covered, cfg.minimal_deposit) if bonus_covered < price else 0 - invoice = await invoices_repo.create( - creator_id=ctx.user.id, amount=price, status=InvoiceStatus.ACTIVE + if amount_to_pay > 0: + invoice = await invoices_repo.create( + creator_id=ctx.user.id, amount=amount_to_pay, status=InvoiceStatus.ACTIVE + ) + + bill = await pally.bills.create(amount_to_pay, cfg.pally_shop_id, order_id=invoice.id) + payment_link = bill.link_page_url + if not payment_link: + raise HTTPException(500, detail="failed to create invoice") + else: + payment_link = None + + return CheckoutResponse( + order_id=order_entry.id, + total_amount=price, + bonus_paid=bonus_covered, + amount_to_pay=amount_to_pay, + payment_link=payment_link, ) - - bill = await pally.bills.create(price, cfg.pally_shop_id, order_id=invoice.id) - - if not (bill.success and bill.link_page_url): - raise HTTPException(500, detail="Failed to create an invoice.") - - return InvoiceResponse(success=True, payment_link=bill.link_page_url, amount=float(price)) diff --git a/routes/payments/pally.py b/routes/payments/pally.py index 3d6a82b..90be1a1 100644 --- a/routes/payments/pally.py +++ b/routes/payments/pally.py @@ -1,4 +1,5 @@ # ruff: noqa: N803 +from datetime import UTC, datetime, timedelta import hashlib import hmac import logging @@ -6,14 +7,19 @@ import math from fastapi import Depends, Form, HTTPException from fastapi.routing import APIRouter +from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession from config import cfg from core.deps import get_db +from db.models import Subscription, SubscriptionAddon +from db.models.orders import OrderStatus +from db.models.transactions import BalanceTransaction from db.models.transactions import BalanceTxType from external.pally import BillStatus from repositories.invoices import InvoiceRepository -from repositories.users import UserRepository +from repositories.orders import OrderRepository +from schemas.enums import SubscriptionStatus from schemas.invoices import InvoiceStatus router = APIRouter(prefix="/payments/pally") @@ -45,8 +51,8 @@ async def pally_callback( # noqa: PLR0911 ErrorMessage: str | None = Form(None), session: AsyncSession = Depends(get_db), ): - users_repo = UserRepository(session) invoice_repo = InvoiceRepository(session) + orders_repo = OrderRepository(session) invoice_id_str = InvId logger.info( @@ -171,39 +177,97 @@ async def pally_callback( # noqa: PLR0911 ) return "OK" + active_orders = await orders_repo.get_active_by_user_id(invoice.creator_id) + if not active_orders: + logger.critical( + "Active order not found for bill %s (TrsId: %s, user_id=%s)", + invoice.id, + TrsId, + invoice.creator_id, + ) + return "OK" + + order = active_orders[0] + user = invoice.creator + subscription = user.subscription + now = datetime.now(UTC) + logger.info( - "Processing payment: bill_id=%s, user_id=%s, amount=%s", + "Processing payment: bill_id=%s, order_id=%s, user_id=%s, amount=%s", invoice.id, + order.id, invoice.creator_id, amount, ) - # Credit user balance - await users_repo.increase_balance( - invoice.creator_id, - amount=amount, - tx_type=BalanceTxType.DEPOSIT, - description=f"payment via PALLY (TrsId: {TrsId})", - ) + order.status = OrderStatus.PAID + invoice.status = InvoiceStatus.PAID + + if subscription is None: + subscription = Subscription( + user_id=invoice.creator_id, + devices=order.devices, + status=SubscriptionStatus.ACTIVE, + expires_at=now + timedelta(days=order.duration_days), + ) + session.add(subscription) + await session.flush() + else: + logger.warning( + "Subscription already exists for user_id=%s, updating existing subscription %s", + invoice.creator_id, + subscription.id, + ) + subscription.devices = order.devices + subscription.status = SubscriptionStatus.ACTIVE + subscription.expires_at = max(subscription.expires_at, now) + timedelta( + days=order.duration_days + ) + await session.execute( + delete(SubscriptionAddon).where(SubscriptionAddon.subscription_id == subscription.id) + ) + + for addon in order.addons: + session.add(SubscriptionAddon(subscription_id=subscription.id, addon_id=addon.addon_id)) # Process referral bonus - user = invoice.creator - referal = user.referal - if referal is not None: + referal_id = user.referal_id + if referal_id is not None: referal_amount = math.floor(amount * (cfg.referal_bonus / 100)) - await users_repo.increase_balance( - referal, - referal_amount, - tx_type=BalanceTxType.REFERRAL_BONUS, - description=f"referral reward for user {invoice.creator_id} (TrsId: {TrsId})", - ) - logger.info( - "Referral bonus processed: referrer_id=%s, amount=%s", referal, referal_amount - ) + if referal_amount > 0: + referal_user = await session.get(type(user), referal_id) + if referal_user is not None: + session.add( + BalanceTransaction( + user_id=referal_id, + amount=referal_amount, + tx_type=BalanceTxType.REFERRAL_BONUS, + balance_before=referal_user.balance, + balance_after=referal_user.balance + referal_amount, + description=f"referral reward for user {invoice.creator_id} (TrsId: {TrsId})", + ) + ) + referal_user.balance += referal_amount + + logger.info( + "Referral bonus processed: referrer_id=%s, amount=%s", + referal_id, + referal_amount, + ) + else: + logger.warning( + "Referrer %s not found for user_id=%s while processing TrsId %s", + referal_id, + invoice.creator_id, + TrsId, + ) + + await session.commit() logger.info("Payment processing completed successfully for TrsId %s", TrsId) except Exception as e: + await session.rollback() logger.exception( "CRITICAL ERROR processing payment for TrsId %s, bill_id %s, user_id %s: %s", TrsId, @@ -211,12 +275,8 @@ async def pally_callback( # noqa: PLR0911 invoice.creator_id, str(e), ) - # Don't return early - still mark as success to prevent retries - # The balance operation might have partially succeeded + raise HTTPException(500, detail="Payment processing failed.") from e - # Update bill status to success - await invoice_repo.update_status_by_id(int(invoice.id), status=InvoiceStatus.PAID) - - logger.info("Bill %s marked as SUCCESS for TrsId %s", invoice.id, TrsId) + logger.info("Bill %s marked as PAID for TrsId %s", invoice.id, TrsId) return "OK" diff --git a/schemas/checkout.py b/schemas/checkout.py new file mode 100644 index 0000000..f847d90 --- /dev/null +++ b/schemas/checkout.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, Field, computed_field + + +class CheckoutResponse(BaseModel): + order_id: str = Field() + total_amount: float = Field() + bonus_paid: float = Field() + amount_to_pay: float = Field() + + payment_link: str | None = Field(None) + + @property + @computed_field + def is_fully_paid(self): + return self.total_amount <= self.bonus_paid and self.amount_to_pay <= 0 diff --git a/schemas/enums.py b/schemas/enums.py new file mode 100644 index 0000000..dd598f5 --- /dev/null +++ b/schemas/enums.py @@ -0,0 +1,6 @@ +from enum import StrEnum + + +class SubscriptionStatus(StrEnum): + ACTIVE = "active" + EXPIRED = "expired"