fix(subscription): show expiry clock time (MSK) when under a day
- 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
This commit is contained in:
1 parent
140f2f7263
commit
87f22fb249
6 files changed
+52
-17
No files matched your search
@@ -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"]))
|
||||
|
||||
+5
-4
@@ -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:
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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)
|
||||
|
||||
+27
-4
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user