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)