feat(sub): subscription logic (pre release)
This commit is contained in:
46
alembic/versions/0ea5625b4913_order_durationdays.py
Normal file
46
alembic/versions/0ea5625b4913_order_durationdays.py
Normal 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 ###
|
||||
72
alembic/versions/b97ce5b7d663_subscriptions_infra.py
Normal file
72
alembic/versions/b97ce5b7d663_subscriptions_infra.py
Normal 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 ###
|
||||
32
alembic/versions/bc424721d767_order_user_id_not_unique.py
Normal file
32
alembic/versions/bc424721d767_order_user_id_not_unique.py
Normal 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 ###
|
||||
@@ -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:
|
||||
|
||||
@@ -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
48
db/models/orders.py
Normal 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"
|
||||
)
|
||||
23
db/models/subscription_addons.py
Normal file
23
db/models/subscription_addons.py
Normal 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"
|
||||
)
|
||||
33
db/models/subscriptions.py
Normal file
33
db/models/subscriptions.py
Normal 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"
|
||||
)
|
||||
@@ -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
2
external/pally.py
vendored
@@ -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
51
repositories/orders.py
Normal 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())
|
||||
@@ -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))
|
||||
|
||||
@@ -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
15
schemas/checkout.py
Normal 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
6
schemas/enums.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class SubscriptionStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
Reference in New Issue
Block a user