fix: send subscription Stars invoice directly

This commit is contained in:
admin committed 2026-10-08 18:28:26 +02:00
1 parent 6dd685a6a5
commit aaea5b6f58
7 files changed
+10 -62

No files matched your search

+2 -2
View File
@@ -21,7 +21,7 @@ Telegram-бот, который по запросу пользователя г
- **Главное меню** — внутреннее состояние после `/start` или выхода из режима; пользователь не выбирает его как отдельный режим.
- **Обучение** — принимает скриншоты, накапливает образцы, показывает стиль (`/style`); `/clear` очищает профиль и историю, не затрагивая подписку или триал.
- **Генерация** — создаёт и редактирует посты, предлагает идеи (`/post`, `/idea`); генерация требует активного доступа.
- **Подписка** — при входе показывает активный период и остаток дней; inline-кнопки продления и оплаты ведут к Stars-инвойсу (`/balance`, `/subscribe`).
- **Подписка** — при входе показывает активный период и остаток дней; inline-кнопка продления сразу открывает Stars-инвойс (`/balance`, `/subscribe`).
- **Помощь** — справка и навигация по её разделам (`/help`).
`/start` показывает ровно одно welcome-сообщение без автоматического вызова помощи. При входе в любой режим показывается короткий onboarding. Главное меню содержит только четыре пользовательских режима; внутри каждого режима клавиатура состоит из кнопки «Главное меню». Команды `/training`, `/generate`, `/subscription`, `/help` доступны из любого режима; профильные действия — только в своём режиме.
@@ -48,7 +48,7 @@ Telegram-бот, который по запросу пользователя г
- Таблица `subscriptions`: `telegram_user_id`, `trial_started_at`, `last_paid_at`, `is_active`, `monthly_days`, `updated_at`.
- `/start` создаёт запись триала (`ensure_subscription`), если её нет.
- `/subscribe` отправляет инвойс на 100 звёзд; подписка продлевается только обработчиком `successful_payment` после подтверждённой оплаты.
- Вход в «Подписка» сразу показывает активный тип периода и сколько дней осталось; inline-кнопка «Продлить подписку» открывает сообщение для покупки.
- Вход в «Подписка» сразу показывает активный тип периода и сколько дней осталось; inline-кнопка «Продлить подписку» сразу открывает Stars-инвойс.
- `/balance` показывает статус: триал активен (сколько дней осталось) / подписка активна (сколько дней) / период истёк.
- Генерация поста и идеи блокируется, если подписка не активна (`_check_subscription`); переходы между режимами доступны всегда.
- `/balance` и `/subscribe` доступны в режиме «Подписка»; `/clear` доступна только в режиме «Обучение».
+2 -2
View File
@@ -44,7 +44,7 @@ Telegram-бот для генерации постов для соцсетей.
«Главное меню» — внутренняя точка входа после `/start` и выхода из режима, а не отдельный режим.
Внутри любого режима reply-клавиатура содержит только «🏠 Главное меню». При входе показывается короткий
onboarding. В «Подписке» сразу отображаются тип активного периода и оставшиеся дни; inline-кнопка
«Продлить подписку» открывает предложение, а кнопка оплаты создаёт Stars-инвойс.
«Продлить подписку» сразу открывает Stars-инвойс на оплату.
Команды перехода: `/start`, `/training`, `/generate`, `/subscription`, `/help`; `/start` возвращает
в главное меню. Команды `/style` и `/clear` доступны только в обучении; `/post` и `/idea` — в генерации;
@@ -65,7 +65,7 @@ onboarding. В «Подписке» сразу отображаются тип
| Придумать идею | В режиме «Генерация» пишет «предложи тему» или `/idea` | Одна идея одним сообщением |
| Настроить стиль | В режиме «Обучение» присылает скриншоты постов | Бот распознаёт тексты, накапливает их и обновляет ToV |
| Голосом | В режиме «Генерация» отправляет голосовое | STT расшифровывает → обычный текстовый запрос |
| Узнать статус / продлить | Входит в режим «Подписка», затем нажимает «Продлить подписку» и «Оплатить» | Текущий тип периода, остаток дней и Stars-инвойс на 100 звёзд |
| Узнать статус / продлить | Входит в режим «Подписка» и нажимает «Продлить подписку» | Текущий тип периода, остаток дней и Stars-инвойс на 100 звёзд |
| Очистить данные | В режиме «Обучение» вызывает `/clear` | Очищает стиль и историю, не меняя подписку или триал |
### Индикаторы и обратная связь
+1 -1
View File
@@ -1 +1 @@
0.1.0+30
0.1.0+31
+1 -3
View File
@@ -23,9 +23,7 @@ flowchart TB
SELECT --> SUBS
SUBS --> STATUS[Показать активный период<br/>и остаток дней]
STATUS --> RENEW[Inline: «Продлить подписку»]
RENEW --> OFFER[Сообщение с предложением оплаты]
OFFER --> PAY[Inline: «Оплатить»]
PAY --> INVOICE[Stars-инвойс]
RENEW --> INVOICE[Сразу открыть Stars-инвойс]
INVOICE --> SUCCESS[Оплата подтверждена<br/>successful_payment]
SELECT --> HELP[Помощь<br/>короткий onboarding и разделы]
-28
View File
@@ -346,17 +346,6 @@ def subscription_renew_keyboard() -> InlineKeyboardMarkup:
)
def subscription_purchase_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[[
InlineKeyboardButton(
text=f"Оплатить {SUBSCRIPTION_PRICE_STARS} ⭐",
callback_data="subscription:buy",
)
]]
)
async def balance_command(message: Message, state: FSMContext) -> None:
if not await require_mode(message, state, "subscription"):
return
@@ -412,23 +401,6 @@ async def _send_subscription_invoice(message: Message, user_id: int) -> None:
@router.callback_query(F.data == "subscription:renew")
async def subscription_renew(callback: CallbackQuery, state: FSMContext) -> None:
if await current_mode(state) != "subscription":
await callback.answer("Открой режим «Подписка» из главного меню.", show_alert=True)
return
message = callback.message
if not isinstance(message, Message):
await callback.answer("Открой режим «Подписка» ещё раз.", show_alert=True)
return
await callback.answer()
await message.answer(
f"Продление подписки на 30 дней — {SUBSCRIPTION_PRICE_STARS} ⭐. "
"Нажми кнопку, чтобы открыть счёт.",
reply_markup=subscription_purchase_keyboard(),
)
@router.callback_query(F.data == "subscription:buy")
async def subscription_buy(callback: CallbackQuery, state: FSMContext) -> None:
if await current_mode(state) != "subscription":
await callback.answer("Открой режим «Подписка» из главного меню.", show_alert=True)
return
+1 -1
View File
@@ -46,7 +46,7 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
<li><code>/balance</code> — узнать статус: сколько осталось дней триала или подписки.</li>
<li><code>/subscribe</code> — оплатить подписку на 30 дней.</li>
</ul>
<p>При входе в режим «Подписка» бот сразу показывает активный период и сколько дней осталось. Нажми «Продлить подписку», затем «Оплатить», чтобы открыть счёт. Когда период закончится, генерация постов и идей станет недоступна.</p>""",
<p>При входе в режим «Подписка» бот сразу показывает активный период и сколько дней осталось. Нажми «Продлить подписку», чтобы сразу открыть счёт в звёздах. Когда период закончится, генерация постов и идей станет недоступна.</p>""",
),
"commands": (
"⌨️ Команды",
+3 -25
View File
@@ -173,46 +173,24 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
"subscription:renew",
)
async def test_renew_callback_opens_purchase_prompt(self):
async def test_renew_callback_sends_invoice_without_intermediate_message(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,
answer=AsyncMock(),
)
await bot.subscription_renew(callback, _StubState("subscription"))
callback.answer.assert_awaited_once_with()
sent_call = message_bot.await_args
if sent_call is None:
self.fail("purchase prompt was not sent")
sent = sent_call.args[0]
self.assertIn("Продление подписки", sent.text)
self.assertEqual(
sent.reply_markup.inline_keyboard[0][0].callback_data,
"subscription:buy",
)
async def test_buy_callback_sends_invoice_for_clicking_user(self):
message = Message(
message_id=92,
date=datetime.now(),
chat=Chat(id=555, type="private"),
).as_(AsyncMock())
callback = SimpleNamespace(
message=message,
from_user=SimpleNamespace(id=42),
answer=AsyncMock(),
)
with patch.object(bot, "_send_subscription_invoice", new=AsyncMock()) as send_invoice:
await bot.subscription_buy(callback, _StubState("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()
async def test_idea_command_generates_only_in_generation_mode(self):
message = _StubMessage("/idea")