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, 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 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) if user.balance < amount: await cb.message.edit_text( INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), 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 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, ) await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu)