diff --git a/VERSION b/VERSION index 1aa75a0..1f44f48 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+79 +0.1.0+83 diff --git a/bot.py b/bot.py index 7df4507..ffab1a2 100644 --- a/bot.py +++ b/bot.py @@ -415,7 +415,7 @@ async def help_navigation(callback: CallbackQuery, state: FSMContext) -> None: @router.callback_query(F.data.startswith("dev_paid:")) -async def confirm_development_payment(callback: CallbackQuery) -> None: +async def confirm_development_payment(callback: CallbackQuery, state: FSMContext) -> None: if not is_development_environment(): await callback.answer() return @@ -444,6 +444,9 @@ async def confirm_development_payment(callback: CallbackQuery) -> None: await callback.message.edit_text( user_store.subscription_status_text(sub) ) + await callback.message.answer("Выбери действие.", reply_markup=main_menu_keyboard()) + await state.clear() + await state.set_state(BotMode.lobby) @router.message(CommandStart()) diff --git a/payments.py b/payments.py index 5710e18..55d577f 100644 --- a/payments.py +++ b/payments.py @@ -15,8 +15,11 @@ import os from contextlib import closing from aiogram import F, Router +from aiogram.fsm.context import FSMContext from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment +from keyboards import main_menu_keyboard + from user_store import ( SUBSCRIPTION_PLANS, activate_subscription_days, @@ -135,8 +138,16 @@ async def handle_pre_checkout(query: PreCheckoutQuery) -> None: await query.answer(ok=True) +async def _return_to_main_menu(message: Message, state: FSMContext | None) -> None: + """После успешной оплаты/продления возвращает пользователя в главное меню.""" + if state is not None: + await state.clear() + await state.set_state("BotMode:lobby") + await message.answer("Оплата принята.", reply_markup=main_menu_keyboard()) + + @router.message(F.successful_payment) -async def handle_successful_payment(message: Message) -> None: +async def handle_successful_payment(message: Message, state: FSMContext | None = None) -> None: payment = message.successful_payment if payment is None or message.from_user is None: return @@ -169,6 +180,7 @@ async def handle_successful_payment(message: Message) -> None: db_path, ) await message.answer(subscription_status_text(sub)) + await _return_to_main_menu(message, state) async def _reject(message: Message, charge_id: str, reason: str) -> None: diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index f15f43b..49d5f6a 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -259,7 +259,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): await bot._show_subscription_info(cast(Any, message), cast(Any, sub)) text = message.answers[0][0] self.assertIn("Подписка активна", text) - self.assertIn("Закончится в 31.10.2026 12:51 (МСК)", text) + self.assertIn("Закончится 31.10.2026 в 12:51 (МСК)", text) self.assertNotIn("ч.", text) async def test_subscription_action_button_shows_inline_plan_choices(self): diff --git a/tests/test_payments.py b/tests/test_payments.py index 65b8ad3..ca7d53a 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -20,6 +20,12 @@ class _StubState: async def get_state(self): return self.value + async def clear(self): + self.value = None + + async def set_state(self, state): + self.value = getattr(state, "state", state) + class PaymentStoreTests(unittest.TestCase): def setUp(self): @@ -166,8 +172,10 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase): user_store.ensure_subscription(42, self.db) with patch.dict(os.environ, {"BOT_DB_PATH": self.db}): await payments.handle_successful_payment(message) - self.assertEqual(len(message.answers), 1) + self.assertEqual(len(message.answers), 2) self.assertIn("Подписка активна", message.answers[0]) + # Второе сообщение — возврат в главное меню с его клавиатурой. + self.assertIsNotNone(message.answer_kwargs[1].get("reply_markup")) self.assertTrue(payments.payment_recorded("charge1", self.db)) row = user_store.subscription_row(42, self.db) self.assertIsNotNone(row["last_paid_at"]) @@ -257,7 +265,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): patch.object(bot, "DEPLOY_ENV", "development"), patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, ): - await bot.confirm_development_payment(callback) + await bot.confirm_development_payment(callback, _StubState()) to_thread.assert_not_awaited() callback.answer.assert_awaited_once_with("Dev-оплата подтверждена. Срок подписки не изменён.") @@ -289,7 +297,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): patch.dict(os.environ, {"BOT_DB_PATH": db}), patch.object(bot.asyncio, "to_thread", new=_direct_thread_mock()) as to_thread, ): - await bot.confirm_development_payment(callback) + await bot.confirm_development_payment(callback, _StubState()) db_row = user_store.subscription_row(42, db) patch_calls = [call for call in to_thread.await_args_list if call.args[0] is user_store.activate_subscription_days] self.assertEqual(patch_calls[0].args[1:], (42, 10)) @@ -304,7 +312,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): patch.object(bot, "DEPLOY_ENV", "development"), patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, ): - await bot.confirm_development_payment(callback) + await bot.confirm_development_payment(callback, _StubState()) to_thread.assert_not_awaited() callback.answer.assert_awaited_once() @@ -316,7 +324,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): patch.object(bot, "DEPLOY_ENV", "production"), patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, ): - await bot.confirm_development_payment(callback) + await bot.confirm_development_payment(callback, _StubState()) to_thread.assert_not_awaited() callback.answer.assert_awaited_once_with() @@ -328,7 +336,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): patch.object(bot, "DEPLOY_ENV", "staging"), patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, ): - await bot.confirm_development_payment(callback) + await bot.confirm_development_payment(callback, _StubState()) to_thread.assert_not_awaited() callback.answer.assert_awaited_once_with() diff --git a/user_store.py b/user_store.py index 0d49063..476db9a 100644 --- a/user_store.py +++ b/user_store.py @@ -199,8 +199,9 @@ def subscription_time_left( if seconds <= 0: return False, 0, "" if seconds >= 86400: - return True, seconds // 86400, "дн." - # Меньше суток — показываем конкретное время окончания вместо "N ч.". + # Округляем вверх: купленные N дней показываем как "N дн.", а не "N-1". + return True, -(-seconds // 86400), "дн." + # Меньше суток (последний день) — показываем конкретное время окончания вместо "N ч.". display_tz = display_tz or MSK_TIMEZONE stamp = (expires + datetime.timedelta(microseconds=1)).replace(tzinfo=datetime.timezone.utc) return True, stamp.astimezone(display_tz).strftime("%d.%m.%Y %H:%M") + " (МСК)", "" @@ -218,7 +219,9 @@ def subscription_status_text( if not active: return "Подписка закончилась" if unit == "": - return f"Подписка активна\nЗакончится в {value}" + # value = "31.10.2026 13:32 (МСК)" -> "Закончится 31.10.2026 в 13:32 (МСК)". + date_part, _, rest = str(value).partition(" ") + return f"Подписка активна\nЗакончится {date_part} в {rest}" return f"Подписка активна\nЗакончится через {value} {unit}" def subscription_row(user_id: int, db_path: str | None = None) -> sqlite3.Row | None: @@ -237,7 +240,11 @@ def ensure_subscription(user_id: int, db_path: str | None = None) -> None: def activate_subscription_days(user_id: int, days: int, db_path: str | None = None) -> int: - """Добавляет срок к текущей подписке и возвращает оставшиеся дни.""" + """Добавляет срок к подписке и возвращает оставшиеся дни (ceil). + + Сложение ведётся по абсолютному UTC-моменту истечения, без округления дней: + это исключает потерю дроби при каждой продлировке (было 10д + 1д -> 10д). + """ if days <= 0: raise ValueError("Subscription days must be positive") with closing(connect(db_path)) as connection, connection: @@ -245,12 +252,9 @@ def activate_subscription_days(user_id: int, days: int, db_path: str | None = No if not sub: raise RuntimeError("Subscription record not found") now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - current_days = _days_until(sub, now) - if current_days > 0: - current_expires = now + datetime.timedelta(days=current_days, seconds=1) - new_expires = current_expires + datetime.timedelta(days=days) - else: - new_expires = now + datetime.timedelta(days=days) + current_expires = _subscription_expires_at(sub) + base = max(now, current_expires) if current_expires is not None else now + new_expires = base + datetime.timedelta(days=days) connection.execute( """ UPDATE subscriptions @@ -259,7 +263,8 @@ def activate_subscription_days(user_id: int, days: int, db_path: str | None = No """, (new_expires.isoformat(), days, user_id), ) - return max(0, (new_expires - now).days) + remaining_seconds = int((new_expires - now).total_seconds()) + return -(-remaining_seconds // 86400) if remaining_seconds > 0 else 0 def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int: