feat(sub): handling upgrade/downgrade

rw integration and /me endpoints are left
This commit is contained in:
2026-08-02 14:16:57 +07:00
parent 24f857ef1b
commit 97a4e819d6
10 changed files with 401 additions and 58 deletions

View File

@@ -1,8 +1,12 @@
from datetime import UTC, datetime
import math
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from config import cfg
from core.deps import get_auth_context, get_pally_client
from db.models.orders import OrderStatus
from db.session import get_db
from external.pally import PallyClient
from repositories import AddonsRepository, PricingRepository
@@ -13,6 +17,12 @@ from schemas.dto import AuthContext
from schemas.invoices import InvoiceStatus
from schemas.plans import OrderDetails
from services.plans import calculate_price, get_pricing_model
from services.subscriptions import (
apply_order_now,
deduct_order_balance,
queue_order_for_later,
should_apply_immediately,
)
router = APIRouter(prefix="/orders")
@@ -29,32 +39,68 @@ async def checkout(
invoices_repo = InvoiceRepository(session)
orders_repo = OrderRepository(session)
pricing = await get_pricing_model(addons_repo, pricing_repo)
price = math.ceil(await calculate_price(addons_repo=addons_repo, order=order, pricing=pricing))
bonus_covered = min(ctx.user.balance, price)
amount_to_pay = round(max(price - bonus_covered, 0), 2)
now = datetime.now(UTC)
order_entry = await orders_repo.create(
ctx.user.id,
order.devices,
order.duration_days,
user_id=ctx.user.id,
devices=order.devices,
duration_days=order.duration_days,
total_amount=price,
balance_amount=bonus_covered,
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
if amount_to_pay > 0:
invoice = await invoices_repo.create(
creator_id=ctx.user.id, amount=amount_to_pay, status=InvoiceStatus.ACTIVE
await invoices_repo.create(
creator_id=ctx.user.id,
order_id=order_entry.id,
amount=amount_to_pay,
status=InvoiceStatus.ACTIVE,
)
with open("log.txt", "w") as f:
f.write(
" ".join((str(price), str(amount_to_pay), cfg.pally_shop_id, str(order_entry.id)))
)
bill = await pally.bills.create(
float(amount_to_pay), cfg.pally_shop_id, order_id=str(order_entry.id)
)
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:
await deduct_order_balance(
session,
user=ctx.user,
order=order_entry,
description=f"order {order_entry.id} paid from balance",
)
order_entry.status = OrderStatus.PAID
if should_apply_immediately(
subscription=ctx.user.subscription,
order=order_entry,
pricing=pricing,
now=now,
):
await apply_order_now(
session,
user=ctx.user,
order=order_entry,
pricing=pricing,
now=now,
)
else:
await queue_order_for_later(
order=order_entry, subscription=ctx.user.subscription, now=now
)
await session.commit()
payment_link = None
return CheckoutResponse(
order_id=order_entry.id,
order_id=str(order_entry.id),
total_amount=price,
bonus_paid=bonus_covered,
amount_to_pay=amount_to_pay,

View File

@@ -1,26 +1,32 @@
# ruff: noqa: N803
from datetime import UTC, datetime, timedelta
import hashlib
import hmac
import logging
import math
from datetime import UTC, datetime
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 db.models.transactions import BalanceTransaction, BalanceTxType
from external.pally import BillStatus
from repositories import AddonsRepository
from repositories.invoices import InvoiceRepository
from repositories.orders import OrderRepository
from schemas.enums import SubscriptionStatus
from repositories.pricing import PricingRepository
from repositories.users import UserRepository
from schemas.invoices import InvoiceStatus
from services.plans import get_pricing_model
from services.subscriptions import (
apply_order_now,
deduct_order_balance,
queue_order_for_later,
should_apply_immediately,
)
router = APIRouter(prefix="/payments/pally")
@@ -28,7 +34,7 @@ logger = logging.getLogger(__name__)
@router.post("/result")
async def pally_callback( # noqa: PLR0911
async def pally_callback( # noqa: PLR0911, PLR0912, PLR0915
*,
InvId: str = Form(...),
OutSum: str = Form(...),
@@ -53,7 +59,11 @@ async def pally_callback( # noqa: PLR0911
):
invoice_repo = InvoiceRepository(session)
orders_repo = OrderRepository(session)
users_repo = UserRepository(session)
pricing_repo = PricingRepository(session)
invoice_id_str = InvId
invoice_id: int | None = None
invoice_creator_id: int | None = None
logger.info(
"Pally webhook received - InvId: %s, OutSum: %s, Commission: %s, TrsId: %s, Status: %s, "
@@ -100,7 +110,7 @@ async def pally_callback( # noqa: PLR0911
logger.info("Processing successfully paid bill %s", TrsId)
# Validate bill ID (from InvId field, which contains the order_id from bill creation)
# Validate invoice ID
if not invoice_id_str or not invoice_id_str.isdigit():
logger.critical(
"Invalid or non-numeric bill ID in InvId field for TrsId %s: '%s'",
@@ -116,6 +126,9 @@ async def pally_callback( # noqa: PLR0911
logger.critical("Bill %s not found in database for TrsId %s", invoice_id_str, TrsId)
return "OK"
invoice_id = invoice.id
invoice_creator_id = invoice.creator_id
# Check if already processed
if invoice.status != InvoiceStatus.ACTIVE:
logger.warning(
@@ -177,20 +190,38 @@ 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:
if invoice.order_id is None:
logger.critical("Invoice %s has no linked order for TrsId %s", invoice.id, TrsId)
return "OK"
order = await orders_repo.get_by_id(invoice.order_id)
if order is None:
logger.critical(
"Active order not found for bill %s (TrsId: %s, user_id=%s)",
"Order not found for bill %s (TrsId: %s, user_id=%s, order_id=%s)",
invoice.id,
TrsId,
invoice.creator_id,
invoice.order_id,
)
return "OK"
order = active_orders[0]
user = invoice.creator
if order.user_id != invoice.creator_id:
logger.critical(
"Order %s does not belong to invoice creator %s for TrsId %s",
order.id,
invoice.creator_id,
TrsId,
)
return "OK"
user = await users_repo.get_user_by_id(invoice.creator_id)
if user is None:
logger.critical("User %s not found for TrsId %s", invoice.creator_id, TrsId)
return "OK"
subscription = user.subscription
now = datetime.now(UTC)
pricing = await get_pricing_model(AddonsRepository(session), pricing_repo)
logger.info(
"Processing payment: bill_id=%s, order_id=%s, user_id=%s, amount=%s",
@@ -203,32 +234,28 @@ async def pally_callback( # noqa: PLR0911
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)
if order.balance_amount > 0:
await deduct_order_balance(
session,
user=user,
order=order,
description=f"order {order.id} partial payment from balance",
)
for addon in order.addons:
session.add(SubscriptionAddon(subscription_id=subscription.id, addon_id=addon.addon_id))
if should_apply_immediately(
subscription=subscription,
order=order,
pricing=pricing,
now=now,
):
await apply_order_now(session, user=user, order=order, pricing=pricing, now=now)
else:
if subscription is None:
logger.critical(
"Cannot queue order %s without subscription for user %s", order.id, user.id
)
return "OK"
await queue_order_for_later(order=order, subscription=subscription, now=now)
# Process referral bonus
referal_id = user.referal_id
@@ -271,12 +298,12 @@ async def pally_callback( # noqa: PLR0911
logger.exception(
"CRITICAL ERROR processing payment for TrsId %s, bill_id %s, user_id %s: %s",
TrsId,
invoice.id,
invoice.creator_id,
invoice_id,
invoice_creator_id,
str(e),
)
raise HTTPException(500, detail="Payment processing failed.") from e
logger.info("Bill %s marked as PAID for TrsId %s", invoice.id, TrsId)
logger.info("Bill %s marked as PAID for TrsId %s", invoice_id, TrsId)
return "OK"