Merge dev into main (subscription: MSK time, ceil days, extend by timestamp, menu return)

This commit is contained in:
admin committed 2026-10-30 13:46:48 +02:00
commit a01195a1c0
6 files changed
+49 -21

No files matched your search

+1 -1
View File
@@ -1 +1 @@
0.1.0+79
0.1.0+83
+4 -1
View File
@@ -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())
+13 -1
View File
@@ -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:
+1 -1
View File
@@ -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):
+14 -6
View File
@@ -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()
+16 -11
View File
@@ -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: