From 87f22fb249550f8a9bbd2140c54ba40c6b60e873 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 30 Oct 2026 13:04:38 +0200 Subject: [PATCH] fix(subscription): show expiry clock time (MSK) when under a day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subscription_time_left: replace floor-div '23 ч.'/'мин.' branches with the concrete expiry timestamp when less than a full day remains - add MSK_TIMEZONE (UTC+3) as display default; label time as '(МСК)' - add shared subscription_status_text helper, use it in status menu, post-payment reply and dev confirmation instead of floor-rounded days - tests: cover under-a-day clock format; fix stale to_thread mock --- VERSION | 2 +- bot.py | 13 ++++++------- payments.py | 9 +++++---- tests/test_keyboards.py | 12 ++++++++++++ tests/test_payments.py | 2 +- user_store.py | 31 +++++++++++++++++++++++++++---- 6 files changed, 52 insertions(+), 17 deletions(-) diff --git a/VERSION b/VERSION index 7b96df7..1aa75a0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+78 +0.1.0+79 diff --git a/bot.py b/bot.py index 1417be9..7df4507 100644 --- a/bot.py +++ b/bot.py @@ -436,11 +436,14 @@ async def confirm_development_payment(callback: CallbackQuery) -> None: await callback.answer("Dev-оплата подтверждена. Срок подписки не изменён.") return await asyncio.to_thread(ensure_subscription, user_id) - days_left = await asyncio.to_thread(user_store.activate_subscription_days, user_id, days) + await asyncio.to_thread(user_store.activate_subscription_days, user_id, days) + sub = await asyncio.to_thread(subscription_row, user_id) await callback.answer("Dev-оплата подтверждена.") if isinstance(callback.message, Message): await callback.message.edit_reply_markup(reply_markup=None) - await callback.message.edit_text(f"Подписка активна\nЗакончится через {days_left} дн.") + await callback.message.edit_text( + user_store.subscription_status_text(sub) + ) @router.message(CommandStart()) @@ -470,11 +473,7 @@ async def _show_subscription_info( if not sub: text = "Подписка закончилась" else: - active, value, unit = user_store.subscription_time_left(sub) - text = ( - f"Подписка активна\nЗакончится через {value} {unit}" - if active else "Подписка закончилась" - ) + text = user_store.subscription_status_text(sub) if mode == "subscription": markup = mode_keyboard("subscription", subscription_purchased=bool(sub and sub["last_paid_at"])) diff --git a/payments.py b/payments.py index f57cc85..5710e18 100644 --- a/payments.py +++ b/payments.py @@ -23,6 +23,8 @@ from user_store import ( activate_subscription, connect, ensure_subscription, + subscription_row, + subscription_status_text, ) logger = logging.getLogger(__name__) @@ -155,7 +157,8 @@ async def handle_successful_payment(message: Message) -> None: days = 1 await asyncio.to_thread(ensure_subscription, message.from_user.id) - days_left = await asyncio.to_thread(activate_subscription_days, message.from_user.id, days) + await asyncio.to_thread(activate_subscription_days, message.from_user.id, days) + sub = await asyncio.to_thread(subscription_row, message.from_user.id, db_path) await asyncio.to_thread( save_payment, charge_id, @@ -165,9 +168,7 @@ async def handle_successful_payment(message: Message) -> None: payment.total_amount, db_path, ) - await message.answer( - f"Подписка активна\nЗакончится через {days_left} дн." - ) + await message.answer(subscription_status_text(sub)) async def _reject(message: Message, charge_id: str, reason: str) -> None: diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index b35c239..f15f43b 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -250,6 +250,18 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): self.assertIn("Подписка активна", message.answers[0][0]) self.assertIsNotNone(markup) + async def test_subscription_status_shows_clock_time_when_under_a_day(self): + message = _StubMessage() + sub = {"last_paid_at": "2026-01-01T00:00:00", "is_active": 1} + with patch.object( + bot.user_store, "subscription_time_left", return_value=(True, "31.10.2026 12:51 (МСК)", "") + ): + 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.assertNotIn("ч.", text) + async def test_subscription_action_button_shows_inline_plan_choices(self): message = _StubMessage(keyboards.BUY_SUBSCRIPTION_BUTTON) with patch.object(bot, "notify_development_deploy", new=AsyncMock()): diff --git a/tests/test_payments.py b/tests/test_payments.py index 1cfcf24..65b8ad3 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -287,7 +287,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): with ( patch.object(bot, "DEPLOY_ENV", "development"), patch.dict(os.environ, {"BOT_DB_PATH": db}), - patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value=32)) as to_thread, + patch.object(bot.asyncio, "to_thread", new=_direct_thread_mock()) as to_thread, ): await bot.confirm_development_payment(callback) db_row = user_store.subscription_row(42, db) diff --git a/user_store.py b/user_store.py index b275ba9..0d49063 100644 --- a/user_store.py +++ b/user_store.py @@ -145,6 +145,8 @@ def init_db(db_path: str | None = None) -> None: TRIAL_DAYS = 3 SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием. SUBSCRIPTION_PRICE_STARS = 450 +# Время окончания подписки показываем по Москве (MSK = фиксированный UTC+3). +MSK_TIMEZONE = datetime.timezone(datetime.timedelta(hours=3)) SUBSCRIPTION_PLANS = { 0: 1, 1: 15, @@ -183,7 +185,11 @@ def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int: return delta.days if delta.total_seconds() > 0 else -1 -def subscription_time_left(sub: sqlite3.Row, now: datetime.datetime | None = None) -> tuple[bool, int, str]: +def subscription_time_left( + sub: sqlite3.Row, + now: datetime.datetime | None = None, + display_tz: datetime.timezone | None = None, +) -> tuple[bool, int | str, str]: expires = _subscription_expires_at(sub) if expires is None: return False, 0, "" @@ -194,9 +200,26 @@ def subscription_time_left(sub: sqlite3.Row, now: datetime.datetime | None = Non return False, 0, "" if seconds >= 86400: return True, seconds // 86400, "дн." - if seconds >= 3600: - return True, seconds // 3600, "ч." - return True, max(1, seconds // 60), "мин." + # Меньше суток — показываем конкретное время окончания вместо "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") + " (МСК)", "" + + +def subscription_status_text( + sub: sqlite3.Row | None, + now: datetime.datetime | None = None, + display_tz: datetime.timezone | None = None, +) -> str: + """Единый текст статуса подписки: дни (>= суток) или конкретное время (< суток).""" + if not sub: + return "Подписка закончилась" + active, value, unit = subscription_time_left(sub, now=now, display_tz=display_tz) + if not active: + return "Подписка закончилась" + if unit == "": + return f"Подписка активна\nЗакончится в {value}" + return f"Подписка активна\nЗакончится через {value} {unit}" def subscription_row(user_id: int, db_path: str | None = None) -> sqlite3.Row | None: with closing(connect(db_path)) as connection: