feat(sub): subscription logic (pre release)

This commit is contained in:
2026-08-02 13:23:50 +07:00
parent cde8ce28f5
commit 24f857ef1b
15 changed files with 469 additions and 42 deletions

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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:

View File

@@ -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",
]

48
db/models/orders.py Normal file
View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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",

2
external/pally.py vendored
View File

@@ -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,

51
repositories/orders.py Normal file
View File

@@ -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())

View File

@@ -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))

View File

@@ -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"

15
schemas/checkout.py Normal file
View File

@@ -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

6
schemas/enums.py Normal file
View File

@@ -0,0 +1,6 @@
from enum import StrEnum
class SubscriptionStatus(StrEnum):
ACTIVE = "active"
EXPIRED = "expired"