From c32702c402676d379409aec9e7868afeddfd277b Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 20 Oct 2026 21:13:51 +0200 Subject: [PATCH] fix: simplify subscription payment messages --- bot.py | 70 +++++++++++++------------------- keyboards.py | 4 +- payments.py | 18 ++++++--- tests/test_keyboards.py | 59 +++++++++++++-------------- tests/test_payments.py | 76 +++++++++++++++++------------------ tests/test_writer_features.py | 13 +++--- 6 files changed, 113 insertions(+), 127 deletions(-) diff --git a/bot.py b/bot.py index 72a494d..a0f9fe7 100644 --- a/bot.py +++ b/bot.py @@ -153,7 +153,7 @@ MODE_COPY = { "Генерация", "Напиши, о чём нужен пост, или отправь голосовое.\nДля правки ответь на мой вариант.", ), - "subscription": ("Подписка", "Проверь статус ниже.\nВыбери действие в меню."), + "subscription": ("Подписка", ""), "help": ("Помощь", "Выбери раздел в сообщении выше."), } @@ -176,6 +176,7 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None: await state.set_state(MODE_STATES[mode]) subscription = None if mode == "subscription" and message.from_user is not None: + await asyncio.to_thread(ensure_subscription, message.from_user.id) subscription = await asyncio.to_thread(subscription_row, message.from_user.id) title, description = MODE_COPY[mode] if mode == "help": @@ -186,7 +187,7 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None: reply_markup=help_inline_keyboard(), ) await message.answer( - f"{title}.\n{description}", + title if mode == "subscription" else f"{title}.\n{description}", reply_markup=mode_keyboard( mode, subscription_purchased=bool(subscription and subscription["last_paid_at"]), @@ -415,21 +416,24 @@ async def confirm_development_payment(callback: CallbackQuery) -> None: await callback.answer() return try: - user_id = int((callback.data or "").split(":", 1)[1]) + parts = (callback.data or "").split(":", 2) + user_id = int(parts[1]) + days = int(parts[2]) except (IndexError, ValueError): await callback.answer("Некорректное подтверждение.", show_alert=True) return if callback.from_user is None or callback.from_user.id != user_id: await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True) return + if days not in user_store.SUBSCRIPTION_PLANS: + await callback.answer("Неизвестный срок подписки.", show_alert=True) + return await asyncio.to_thread(ensure_subscription, user_id) - days = await asyncio.to_thread(user_store.activate_subscription, user_id, months=1) - await callback.answer() + days_left = await asyncio.to_thread(user_store.activate_subscription_days, user_id, days) + await callback.answer("Dev-оплата подтверждена.") if isinstance(callback.message, Message): await callback.message.edit_reply_markup(reply_markup=None) - await callback.message.answer( - f"🧪 Dev-платёж подтверждён. Подписка включена на {days} дн. Реальная оплата не проводилась." - ) + await callback.message.answer(f"Подписка активна\nЗакончится через {days_left} дн.") @router.message(CommandStart()) @@ -457,10 +461,7 @@ async def _show_subscription_info( message: Message, sub: sqlite3.Row | None, mode: str = "subscription" ) -> None: if not sub: - text = ( - f"Подписка не активирована. Оформи её за {SUBSCRIPTION_PRICE_STARS} звёзд " - "за 30 дней, чтобы пользоваться генерацией." - ) + text = "Подписка закончилась" else: days_left = user_store._days_until(sub) text = ( @@ -469,9 +470,7 @@ async def _show_subscription_info( ) if mode == "subscription": - markup = mode_keyboard( - "subscription", subscription_purchased=bool(sub and sub["last_paid_at"]) - ) if sub else mode_keyboard("subscription") + markup = mode_keyboard("subscription", subscription_purchased=bool(sub and sub["last_paid_at"])) else: markup = mode_keyboard(mode) await message.answer(text, reply_markup=markup) @@ -499,43 +498,26 @@ async def subscribe_command(message: Message, state: FSMContext) -> None: async def _send_subscription_invoice(message: Message, user_id: int, days: int = 30) -> None: await asyncio.to_thread(ensure_subscription, user_id) try: - if days == 30: - await send_subscription_invoice(message.bot, message.chat.id, message.message_thread_id) - else: - await send_subscription_invoice( - message.bot, message.chat.id, message.message_thread_id, days=days - ) + await send_subscription_invoice( + message.bot, message.chat.id, message.message_thread_id, days=days + ) except Exception as exc: logger.error("Invoice send failed (%s): %s", type(exc).__name__, str(exc)[:180]) - if is_development_environment(): - await report_error(message, "отправка счёта на оплату", exc) - else: - await message.answer( - "Не получилось открыть счёт на оплату. Попробуй ещё раз через минуту " - "или напиши поддержке." - ) + await message.answer( + "Не получилось открыть счёт на оплату. Попробуй ещё раз через минуту " + "или напиши поддержке." + ) return - await message.answer( - f"🧾 Счёт на подписку\n" - f"Срок: {days} дней\n" - f"ID пользователя: {user_id}", - parse_mode="HTML", - ) if is_development_environment(): await message.answer( - "🧪 Dev-режим: для продолжения разработки нажми кнопку ниже. " - "Она включит доступ без реального платежа.", + f"🧪 Dev-режим: кнопка ниже имитирует оплату и выдаёт срок: {days} дн.", reply_markup=InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton( - text="🧪 Оплатил (dev)", - callback_data=f"dev_paid:{user_id}", + text=f"🧪 Оплатил (dev, {days} дн.)", + callback_data=f"dev_paid:{user_id}:{days}", )]] ), ) - else: - await message.answer( - "Подписка активируется сразу после оплаты — отдельного подтверждения не нужно." - ) @router.callback_query(F.data.startswith("subscription:plan:")) @@ -552,6 +534,10 @@ async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None: await callback.answer("Такой тариф недоступен.", show_alert=True) return await callback.answer() + try: + await callback.message.delete() + except Exception as exc: + logger.warning("Subscription plan message delete failed (%s)", type(exc).__name__) await _send_subscription_invoice(callback.message, callback.from_user.id, days) diff --git a/keyboards.py b/keyboards.py index bc6dc12..687ec53 100644 --- a/keyboards.py +++ b/keyboards.py @@ -9,7 +9,7 @@ from aiogram.types import ( ) from help_message import HELP_SECTIONS, HELP_SECTION_ORDER -from user_store import SUBSCRIPTION_PRICE_STARS +from user_store import SUBSCRIPTION_PLANS MODE_BUTTONS: dict[str, str] = { "🎓 Обучение": "/training", @@ -118,4 +118,4 @@ def resolve_button_text(text: str | None) -> str | None: def price_hint() -> str: - return f"Продление подписки — {SUBSCRIPTION_PRICE_STARS} звёзд." + return f"Продление подписки — {SUBSCRIPTION_PLANS[30]} ⭐ за 30 дней." diff --git a/payments.py b/payments.py index 26f241c..d66ebb6 100644 --- a/payments.py +++ b/payments.py @@ -33,12 +33,20 @@ INVOICE_PREFIX = "sub:" def plan_for_payload(payload: str) -> tuple[int, int] | None: - try: - days = int(payload.split(":", 2)[1]) - except (IndexError, ValueError): - return None - if days == 30 and payload.startswith("sub30:"): + if payload.startswith("sub30:"): + try: + timestamp = payload.split(":", 1)[1] + datetime.datetime.strptime(timestamp, "%Y%m%d%H%M%S") + except (ValueError, IndexError): + return None return (30, SUBSCRIPTION_PLANS[30]) + try: + prefix, days_part, timestamp = payload.split(":", 2) + if prefix != "sub" or not timestamp.isdigit(): + return None + days = int(days_part) + except (ValueError, IndexError): + return None price = SUBSCRIPTION_PLANS.get(days) return (days, price) if price is not None else None diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 27f2871..7dbd054 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, patch -from aiogram.types import Chat, Message +from aiogram.types import Chat, InlineKeyboardMarkup, Message import bot import keyboards @@ -115,7 +115,7 @@ class KeyboardLayoutTests(unittest.TestCase): def test_mode_entry_copy_uses_short_paragraphs(self): self.assertIn("\n", bot.MODE_COPY["training"][1]) self.assertIn("\n", bot.MODE_COPY["generation"][1]) - self.assertIn("\n", bot.MODE_COPY["subscription"][1]) + self.assertIn("Подписка", bot.MODE_COPY["subscription"][0]) def test_training_reset_button_routes_to_confirmation_action(self): self.assertEqual( @@ -148,6 +148,7 @@ class _StubMessage: async def answer(self, text, **kwargs): self.answers.append((text, kwargs)) + return SimpleNamespace(message_id=1) class StyleCommandTests(unittest.IsolatedAsyncioTestCase): @@ -233,36 +234,37 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): state = _StubState("lobby") with ( patch.object(bot, "notify_development_deploy", new=AsyncMock()), - patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value=None)), + patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value={"telegram_user_id":42, "trial_started_at":"2026-10-19T00:00:00", "last_paid_at":None, "is_active":1})), ): await bot.handle_text(message, state) - self.assertEqual(await state.get_state(), bot.BotMode.subscription.state) self.assertEqual(len(message.answers), 2) initial_keyboard = message.answers[0][1]["reply_markup"] - self.assertEqual(initial_keyboard.keyboard[0][0].text, keyboards.BUY_SUBSCRIPTION_BUTTON) - self.assertIn("Подписка не активирована", message.answers[1][0]) - buy_keyboard = message.answers[1][1]["reply_markup"] - self.assertFalse(hasattr(buy_keyboard, "inline_keyboard")) - self.assertEqual(buy_keyboard.keyboard[0][0].text, keyboards.BUY_SUBSCRIPTION_BUTTON) + self.assertEqual( + [button.text for row in initial_keyboard.keyboard for button in row], + [keyboards.BUY_SUBSCRIPTION_BUTTON, keyboards.MAIN_MENU_BUTTON], + ) + self.assertIn("Подписка активна", message.answers[1][0]) + self.assertIn("Закончится через", message.answers[1][0]) async def test_subscription_status_uses_renew_button_after_previous_payment(self): message = _StubMessage() sub = {"last_paid_at": "2026-01-01T00:00:00", "expires_at": "2026-02-01T00:00:00", "is_active": 1} - with patch.object(bot.user_store, "_days_until", return_value=0): + with patch.object(bot.user_store, "_days_until", return_value=2): await bot._show_subscription_info(cast(Any, message), cast(Any, sub)) markup = message.answers[0][1]["reply_markup"] self.assertFalse(hasattr(markup, "inline_keyboard")) - self.assertEqual(markup.keyboard[0][0].text, keyboards.RENEW_SUBSCRIPTION_BUTTON) + self.assertIn("Подписка активна", message.answers[0][0]) + self.assertIn("Закончится через", message.answers[0][0]) - async def test_subscription_action_button_sends_invoice_in_subscription_mode(self): + 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()), - patch.object(bot, "_send_subscription_invoice", new=AsyncMock()) as send_invoice, - ): + with patch.object(bot, "notify_development_deploy", new=AsyncMock()): await bot.handle_text(message, _StubState("subscription")) - send_invoice.assert_awaited_once_with(message, 42) + self.assertEqual(len(message.answers), 1) + self.assertEqual(message.answers[0][0], "Выберите срок подписки") + labels = [button.text for row in message.answers[0][1]["reply_markup"].inline_keyboard for button in row] + self.assertEqual(labels, ["1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) async def test_subscription_action_button_is_rejected_outside_subscription_mode(self): message = _StubMessage(keyboards.RENEW_SUBSCRIPTION_BUTTON) @@ -274,24 +276,17 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): send_invoice.assert_not_awaited() self.assertIn("доступна в разделе «Подписка»", message.answers[0][0]) - async def test_renew_callback_sends_invoice_without_intermediate_message(self): + async def test_renew_callback_shows_plan_choices(self): message_bot = AsyncMock() - message = Message( - message_id=91, - date=datetime.now(), - chat=Chat(id=555, type="private"), - ).as_(message_bot) - callback = SimpleNamespace( - message=message, - from_user=SimpleNamespace(id=42), - answer=AsyncMock(), - ) - with patch.object(bot, "_send_subscription_invoice", new=AsyncMock()) as send_invoice: + message = Message(message_id=17, date=datetime.now(), chat=Chat(id=555, type="private")).as_(message_bot) + callback = SimpleNamespace(message=message, from_user=SimpleNamespace(id=42), answer=AsyncMock()) + with patch.object(bot, "current_mode", new=AsyncMock(return_value="subscription")): await bot.subscription_renew(callback, _StubState("subscription")) - callback.answer.assert_awaited_once_with() - send_invoice.assert_awaited_once_with(message, 42) - message_bot.assert_not_awaited() + self.assertEqual(len(message_bot.mock_calls), 2) + sent = message_bot.mock_calls[-1].args[0] + self.assertEqual(sent.text, "Выберите срок подписки") + self.assertEqual([b.text for row in sent.reply_markup.inline_keyboard for b in row], ["1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) async def test_idea_command_generates_only_in_generation_mode(self): message = _StubMessage("/idea") diff --git a/tests/test_payments.py b/tests/test_payments.py index 2710305..a610b80 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -1,3 +1,4 @@ +import asyncio import os import tempfile import unittest @@ -45,16 +46,17 @@ class PaymentStoreTests(unittest.TestCase): class InvoiceTests(unittest.IsolatedAsyncioTestCase): async def test_invoice_uses_stars_and_correct_price(self): - fake_bot = AsyncMock() - await payments.send_subscription_invoice(fake_bot, 555, days=30) - kwargs = fake_bot.send_invoice.await_args.kwargs - self.assertEqual(kwargs["currency"], "XTR") - self.assertEqual(kwargs["provider_token"], "") - self.assertEqual(kwargs["chat_id"], 555) - self.assertTrue(kwargs["payload"].startswith("sub:30:")) - self.assertEqual(kwargs["title"], "Подписка на 30 дней") - self.assertEqual(kwargs["description"], "Доступ к боту по генерации текста постов") - self.assertEqual(kwargs["prices"][0], LabeledPrice(label="30 дней", amount=450)) + for days, price in user_store.SUBSCRIPTION_PLANS.items(): + with self.subTest(days=days): + fake_bot = AsyncMock() + await payments.send_subscription_invoice(fake_bot, 555, days=days) + kwargs = fake_bot.send_invoice.await_args.kwargs + self.assertEqual(kwargs["title"], f"Подписка на {days} дней") + self.assertEqual(kwargs["description"], "Доступ к боту по генерации текста постов") + self.assertEqual(kwargs["prices"][0].amount, price) + self.assertEqual(payments.plan_for_payload(kwargs["payload"]), (days, price)) + self.assertEqual(kwargs["currency"], "XTR") + self.assertEqual(kwargs["provider_token"], "") async def test_invoice_includes_thread_when_given(self): fake_bot = AsyncMock() @@ -68,7 +70,7 @@ class InvoiceTests(unittest.IsolatedAsyncioTestCase): class PreCheckoutTests(unittest.IsolatedAsyncioTestCase): - def _query(self, currency="XTR", amount=450, payload="sub:30:1"): + def _query(self, currency="XTR", amount=450, payload="sub:30:20261001120000"): query = AsyncMock() query.currency = currency query.total_amount = amount @@ -76,7 +78,7 @@ class PreCheckoutTests(unittest.IsolatedAsyncioTestCase): return query async def test_accepts_valid_subscription_invoice(self): - query = self._query(payload="sub:30:1") + query = self._query(payload="sub:30:20261001120000") await payments.handle_pre_checkout(query) query.answer.assert_awaited_once_with(ok=True) @@ -112,6 +114,8 @@ class _StubMessage: self.successful_payment = payment self.from_user = SimpleNamespace(id=user_id) self.chat = SimpleNamespace(id=chat_id) + self.message_thread_id = None + self.bot = AsyncMock() self.answers: list[str] = [] self.answer_kwargs: list[dict] = [] @@ -121,7 +125,7 @@ class _StubMessage: return self -def _paid_message(charge_id="charge1", amount=450, currency="XTR", payload="sub:30:1"): +def _paid_message(charge_id="charge1", amount=450, currency="XTR", payload="sub:30:20261001120000"): payment = SuccessfulPayment( currency=currency, total_amount=amount, @@ -200,53 +204,47 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): return message async def test_subscribe_sends_invoice_and_never_activates_directly(self): - from pathlib import Path - - source = Path(bot.__file__).read_text(encoding="utf-8") - body = source.split("async def subscribe_command")[1].split("\nasync def ")[0] - self.assertNotIn("activate_subscription", body) - message = self._message() with ( patch.object(bot, "DEPLOY_ENV", "production"), patch.object(bot.asyncio, "to_thread", new=AsyncMock()), patch.object(bot, "send_subscription_invoice", new=AsyncMock()) as invoice, ): - await bot.subscribe_command(message, _StubState()) - invoice.assert_awaited_once_with(message.bot, 555, None) - self.assertIn("Счёт на подписку", message.answers[0]) - self.assertIn("ID пользователя: 42", message.answers[0]) - self.assertEqual(len(message.answers), 2) - self.assertNotIn("dev", " ".join(message.answers).lower()) - self.assertNotIn("reply_markup", message.answer_kwargs[-1]) + await bot._send_subscription_invoice(message, 42, days=30) + invoice.assert_awaited_once_with(message.bot, 555, None, days=30) + self.assertEqual(len(message.answers), 0) - async def test_dev_subscribe_shows_manual_confirmation_button(self): + async def test_dev_subscribe_shows_selected_days_and_explains_test_button(self): message = self._message() with ( patch.object(bot, "DEPLOY_ENV", "development"), patch.object(bot.asyncio, "to_thread", new=AsyncMock()), - patch.object(bot, "send_subscription_invoice", new=AsyncMock()), + patch.object(bot, "send_subscription_invoice", new=AsyncMock()) as invoice, ): - await bot.subscribe_command(message, _StubState()) - self.assertIn("без реального платежа", message.answers[-1]) - self.assertEqual(len(message.answers), 2) - self.assertIn("Счёт", message.answers[0]) + await bot._send_subscription_invoice(message, 42, days=10) + invoice.assert_awaited_once_with(message.bot, 555, None, days=10) + self.assertEqual(len(message.answers), 1) + self.assertIn("имитирует оплату", message.answers[0]) markup = message.answer_kwargs[-1]["reply_markup"] - self.assertEqual(markup.inline_keyboard[0][0].text, "🧪 Оплатил (dev)") - self.assertIn("ID пользователя: 42", message.answers[0]) + self.assertEqual(markup.inline_keyboard[0][0].text, "🧪 Оплатил (dev, 10 дн.)") + self.assertEqual(markup.inline_keyboard[0][0].callback_data, "dev_paid:42:10") - async def test_dev_confirmation_activates_only_for_invoice_user(self): + async def test_dev_confirmation_activates_selected_term_and_replies_with_status(self): callback = AsyncMock() - callback.data = "dev_paid:42" + callback.data = "dev_paid:42:10" callback.from_user = SimpleNamespace(id=42) callback.message = None + db = _db() + user_store.ensure_subscription(42, db) with ( patch.object(bot, "DEPLOY_ENV", "development"), - patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value=30)) as to_thread, + patch.dict(os.environ, {"BOT_DB_PATH": db}), + patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value=32)) as to_thread, ): await bot.confirm_development_payment(callback) - self.assertEqual(to_thread.await_args.args[0], user_store.activate_subscription) - callback.answer.assert_awaited_once_with() + os.unlink(db) + self.assertEqual(to_thread.await_args.args, (user_store.activate_subscription_days, 42, 10)) + callback.answer.assert_awaited_once_with("Dev-оплата подтверждена.") async def test_dev_confirmation_rejects_other_user(self): callback = AsyncMock() diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index e0fb5a9..998dd3b 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -9,7 +9,6 @@ import apinex_client import bot import user_store - class _StubState: def __init__(self, mode="lobby"): self.value = bot.MODE_STATES[mode].state @@ -995,8 +994,8 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): # Подписка не активируется до реальной оплаты. sub = user_store.subscription_row(102, self.db_path) self.assertIsNone(sub["last_paid_at"]) - msg_calls = [c.args[0] for c in message.answer.call_args_list] - self.assertTrue(any("Счёт" in m for m in msg_calls)) + invoice.assert_awaited_once_with(message.bot, 555, None, days=30) + message.answer.assert_not_awaited() async def test_balance_command_shows_subscription_info(self): @@ -1012,8 +1011,8 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): await bot.balance_command(message, _StubState("subscription")) - self.assertIn("осталось", message.answer.call_args.args[0].lower()) - self.assertIn("пробный период", message.answer.call_args.args[0].lower()) + self.assertIn("Подписка активна", message.answer.call_args.args[0]) + self.assertNotIn("пробный период", message.answer.call_args.args[0].lower()) renew_markup = message.answer.call_args.kwargs["reply_markup"] self.assertEqual( renew_markup.keyboard[0][0].text, @@ -1038,8 +1037,8 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): # ответ должен быть о блокировке, а не о генерации msg_calls = [c.args[0] for c in message.answer.call_args_list] - self.assertTrue(any("истёк" in msg for msg in msg_calls)) - self.assertTrue(any("звёзд" in msg for msg in msg_calls)) + self.assertIn("Подписка закончилась", " ".join(msg_calls)) + self.assertFalse(any("звёзд" in msg for msg in msg_calls)) async def test_clear_preserves_subscription(self): user_store.ensure_subscription(105, self.db_path)