From 9400050ade5c2e37f43f5fdc211817b499648f39 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 13:24:53 +0200 Subject: [PATCH 1/4] feat(subscription): return to main menu after payment/renewal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handle_successful_payment: after status, reset FSM to lobby and send main menu keyboard (state injected by dispatcher) - confirm_development_payment (dev path): same — edit status then reply with main menu; add state param - tests: assert second message carries main-menu markup; _StubState clear/set_state --- VERSION | 2 +- bot.py | 5 ++++- payments.py | 14 +++++++++++++- tests/test_payments.py | 20 ++++++++++++++------ 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/VERSION b/VERSION index 1aa75a0..6e24410 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+79 +0.1.0+80 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_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() From 261b2b2d02308b72233b6379b71bec34d8d16309 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 13:30:27 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(subscription):=20ceil=20day=20count=20s?= =?UTF-8?q?o=20bought=20N=20days=20shows=20'N=20=D0=B4=D0=BD.'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seconds // 86400 floored to N-1 (bought 10 -> showed 9); use ceil instead - concrete MSK expiry time stays only for the last day (< 24h remaining) --- VERSION | 2 +- user_store.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 6e24410..29631e9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+80 +0.1.0+81 diff --git a/user_store.py b/user_store.py index 0d49063..5e27399 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") + " (МСК)", "" From 6f525993424a81109673a1eb22c01a2bbae07bf5 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 13:37:17 +0200 Subject: [PATCH 3/4] fix(subscription): extend by absolute expiry timestamp, not floored days - activate_subscription_days now adds timedelta to the real expires_at (max(now, current_expires)) instead of _days_until() floor value; prevents losing fractional time on each renewal (10d + 1d -> was 10d) - return ceil day count for display consistency - keep _days_until only for is_subscribed gate and support stats --- VERSION | 2 +- user_store.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/VERSION b/VERSION index 29631e9..c8308ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+81 +0.1.0+82 diff --git a/user_store.py b/user_store.py index 5e27399..98498a6 100644 --- a/user_store.py +++ b/user_store.py @@ -238,7 +238,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: @@ -246,12 +250,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 @@ -260,7 +261,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: From a9e8c2e3bab60b4260cbf65da618e7f41916d995 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 13:44:54 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(subscription):=20reword=20expiry=20to?= =?UTF-8?q?=20'=D0=97=D0=B0=D0=BA=D0=BE=D0=BD=D1=87=D0=B8=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20<=D0=B4=D0=B0=D1=82=D0=B0>=20=D0=B2=20<=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D1=8F>=20(=D0=9C=D0=A1=D0=9A)'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- tests/test_keyboards.py | 2 +- user_store.py | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index c8308ef..1f44f48 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+82 +0.1.0+83 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/user_store.py b/user_store.py index 98498a6..476db9a 100644 --- a/user_store.py +++ b/user_store.py @@ -219,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: