Files

242 lines
8.0 KiB
Python

import logging
import math
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.client import deposit_kb, devices_selector, duration_selector
from bot.keyboards.common import return_to_menu
from bot.states.buy import SubscriptionStorage
from bot.texts import (
CALLBACK_FALLBACK,
CONTEXT_REINITIATED,
INSUFFICIENT_FUNDS,
NOTHING_PLACEHOLDER,
STANDARD_FALLBACK,
SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_SELECTOR,
SUCCESSFULLY_CREATED_SUBSCRIPTION,
)
from config import settings
from db.models.transactions import BalanceTxType
from misc.utils import (
calculate_price,
convert_duration_to_int,
convert_int_to_duration,
get_discount,
)
from schemas.di import DependenciesDTO
from schemas.subscriptions import SubscriptionPlan
from services.admin_notifications import notify_subscription_purchase
router = Router()
logger = logging.getLogger(__name__)
@router.callback_query(F.data == "buy")
async def buy_init(cb: CallbackQuery, state: FSMContext):
await state.clear()
ctx = SubscriptionPlan()
await cb.message.edit_text(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(settings.min_devices),
)
await state.set_state(SubscriptionStorage.devices)
await state.set_data({"ctx": ctx})
@router.callback_query(F.data.startswith("devices:"))
async def device_count_select(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: SubscriptionPlan | None = data.get("ctx", SubscriptionPlan())
cb_data = cb.data.split(":")
devices = int(cb_data[1]) if cb_data[1].isdigit() else settings.min_devices
ctx.devices = devices
ctx.whitelists = ctx.whitelists or devices >= settings.whitelist_device_threshold
await cb.message.edit_text(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(current=ctx.devices, whitelists=ctx.whitelists),
)
await state.set_state(SubscriptionStorage.devices)
await state.set_data({"ctx": ctx})
@router.callback_query(SubscriptionStorage.devices, F.data == "whitelist:toggle")
async def toggle_whitelists(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: SubscriptionPlan | None = data.get("ctx", SubscriptionPlan())
ctx.whitelists = not ctx.whitelists or ctx.devices >= settings.whitelist_device_threshold
try:
await cb.message.edit_text(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(current=ctx.devices, whitelists=ctx.whitelists),
)
except Exception as exc:
if "message is not modified:" in str(exc):
await cb.answer(NOTHING_PLACEHOLDER)
else:
await cb.answer(CALLBACK_FALLBACK)
await state.set_state(SubscriptionStorage.devices)
await state.set_data({"ctx": ctx})
@router.callback_query(SubscriptionStorage.devices, F.data == "confirm")
async def select_duration_init(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: SubscriptionPlan | None = data.get("ctx")
if not ctx:
await cb.answer(CALLBACK_FALLBACK)
return
try:
discount = get_discount(convert_duration_to_int(ctx.duration))
ctx.discount = 1 - discount / 100
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(
price=calculate_price(ctx),
discount=math.floor(discount),
),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
)
except Exception as exc:
if "message is not modified:" in str(exc):
await cb.answer(NOTHING_PLACEHOLDER)
else:
await cb.answer(CALLBACK_FALLBACK)
raise
await state.set_state(SubscriptionStorage.duration)
await state.set_data({"ctx": ctx})
@router.callback_query(F.data.startswith("duration:"))
async def select_duration(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: SubscriptionPlan | None = data.get("ctx")
if not ctx:
await cb.answer(CONTEXT_REINITIATED)
ctx = SubscriptionPlan()
cb_data = cb.data.split(":")
duration = int(cb_data[1]) if cb_data[1].isdigit() else 1
ctx.duration = convert_int_to_duration(duration)
try:
discount = get_discount(convert_duration_to_int(ctx.duration))
ctx.discount = 1 - discount / 100
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(
price=calculate_price(ctx),
discount=math.floor(discount),
),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
)
except Exception as exc:
if "message is not modified:" in str(exc):
await cb.answer(NOTHING_PLACEHOLDER)
else:
await cb.answer(CALLBACK_FALLBACK)
raise
await state.set_state(SubscriptionStorage.duration)
await state.set_data({"ctx": ctx})
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
async def proceed_to_checkout(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data()
await state.clear()
ctx: SubscriptionPlan | None = data.get("ctx")
if not ctx:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
amount = calculate_price(ctx)
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
credit = await deps.user_service.calculate_plan_credit(
session, cb.from_user.id, ctx, deps.rw_sdk
)
effective_balance = user.balance + credit
if effective_balance < amount:
await cb.message.edit_text(
INSUFFICIENT_FUNDS.format(
balance=user.balance, amount=amount, diff=abs(amount - user.balance)
),
reply_markup=deposit_kb,
)
return
if not user:
await cb.message.edit_text(CALLBACK_FALLBACK)
return
try:
await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user)
except Exception:
logger.exception(
"buy_subscription caught an exception on user_id=%s, amount=%s. balance was NOT affected, transaction is rejected.",
user.id,
amount,
)
await cb.message.edit_text(CALLBACK_FALLBACK)
return
if credit > 0:
await deps.user_repository.increase_user_balance(
session,
user.id,
credit,
tx_type=BalanceTxType.REFUND,
description="refund for unused subscription days",
)
deducted_user = await deps.user_repository.decrease_user_balance(
session,
user.id,
amount,
BalanceTxType.PURCHASE,
description=f"subscription purchase: devices={ctx.devices}, duration={ctx.duration}, whitelists={ctx.whitelists}",
)
if not deducted_user:
logger.critical(
"RECONCILIATION NEEDED: subscription created for user_id=%s "
"but balance deduction FAILED. amount=%s.",
cb.from_user.id,
amount,
)
if deducted_user:
await notify_subscription_purchase(
cb.bot,
user_id=user.id,
username=cb.from_user.username,
amount=amount,
balance_before=deducted_user.balance + amount,
balance_after=deducted_user.balance,
devices=ctx.devices,
duration_days=convert_duration_to_int(ctx.duration) * 30,
whitelists=ctx.whitelists,
credit=credit,
)
await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu)