fix: simplify subscription payment messages

This commit is contained in:
admin committed 2026-10-20 21:13:51 +02:00
1 parent 1ff4012e2f
commit c32702c402
6 files changed
+113 -127

No files matched your search

+28 -42
View File
@@ -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"🧾 <b>Счёт на подписку</b>\n"
f"Срок: {days} дней\n"
f"ID пользователя: <code>{user_id}</code>",
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)
+2 -2
View File
@@ -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 дней."
+13 -5
View File
@@ -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
+27 -32
View File
@@ -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")
+37 -39
View File
@@ -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 пользователя: <code>42</code>", 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 пользователя: <code>42</code>", 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()
+6 -7
View File
@@ -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)