fix(subscription): process zero-day one-star payment

This commit is contained in:
admin committed 2026-10-29 13:03:55 +02:00
1 parent 0dc54a26b9
commit dc2ec6344b
6 files changed
+47 -31

No files matched your search

+1 -1
View File
@@ -1 +1 @@
0.1.0+69 0.1.0+70
+1 -19
View File
@@ -494,22 +494,7 @@ async def subscribe_command(message: Message, state: FSMContext) -> None:
async def _send_zero_star_invoice(message: Message, user_id: int) -> None: async def _send_zero_star_invoice(message: Message, user_id: int) -> None:
"""Sends a one-Star test invoice; never grants access.""" """Sends a one-Star test invoice; never grants access."""
try: await _send_subscription_invoice(message, user_id, days=0)
await message.bot.send_invoice(
chat_id=message.chat.id,
title="Тестовый счёт на 1 ⭐",
description="Проверка поддержки минимального платежа Telegram Stars",
payload=f"sub:0:{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}",
currency="XTR",
prices=[LabeledPrice(label="Тест", amount=1)],
provider_token="",
message_thread_id=message.message_thread_id,
)
except Exception as exc:
logger.info("Zero-Star invoice rejected by Telegram (%s)", type(exc).__name__)
await message.answer("Telegram отклонил тестовый счёт на 1 ⭐.")
return
await message.answer("Telegram принял тестовый счёт на 1 ⭐. Доступ он не выдаёт.")
async def _send_subscription_invoice(message: Message, user_id: int, days: int = 30) -> None: async def _send_subscription_invoice(message: Message, user_id: int, days: int = 30) -> None:
@@ -554,9 +539,6 @@ async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None:
await callback.answer("Такой тариф недоступен.", show_alert=True) await callback.answer("Такой тариф недоступен.", show_alert=True)
return return
await callback.answer() await callback.answer()
if days == 0:
await _send_zero_star_invoice(callback.message, callback.from_user.id)
return
try: try:
await callback.message.delete() await callback.message.delete()
except Exception as exc: except Exception as exc:
+1 -1
View File
@@ -90,7 +90,7 @@ def mode_keyboard(
def subscription_plans_keyboard() -> InlineKeyboardMarkup: def subscription_plans_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[ return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="0 дней — 0 ⭐ (тест)", callback_data="subscription:plan:0")], [InlineKeyboardButton(text="0 дней — 1 ⭐", callback_data="subscription:plan:0")],
[InlineKeyboardButton(text="1 день — 15 ⭐", callback_data="subscription:plan:1")], [InlineKeyboardButton(text="1 день — 15 ⭐", callback_data="subscription:plan:1")],
[InlineKeyboardButton(text="10 дней — 150 ⭐", callback_data="subscription:plan:10")], [InlineKeyboardButton(text="10 дней — 150 ⭐", callback_data="subscription:plan:10")],
[InlineKeyboardButton(text="30 дней — 450 ⭐", callback_data="subscription:plan:30")], [InlineKeyboardButton(text="30 дней — 450 ⭐", callback_data="subscription:plan:30")],
+16 -1
View File
@@ -48,7 +48,9 @@ def plan_for_payload(payload: str) -> tuple[int, int] | None:
except (ValueError, IndexError): except (ValueError, IndexError):
return None return None
price = SUBSCRIPTION_PLANS.get(days) price = SUBSCRIPTION_PLANS.get(days)
return (days, price) if price is not None else None if price is None or (days == 0 and price != 1):
return None
return days, price
def _db_path(db_path: str | None = None) -> str | None: def _db_path(db_path: str | None = None) -> str | None:
@@ -149,6 +151,19 @@ async def handle_successful_payment(message: Message) -> None:
await message.answer("Этот платёж уже учтён — подписка не продлена повторно.") await message.answer("Этот платёж уже учтён — подписка не продлена повторно.")
return return
if days == 0:
await asyncio.to_thread(
save_payment,
charge_id,
message.from_user.id,
payment.invoice_payload,
payment.currency,
payment.total_amount,
db_path,
)
await message.answer("Оплата 1 ⭐ получена. Срок подписки не изменён.")
return
await asyncio.to_thread(ensure_subscription, message.from_user.id) await asyncio.to_thread(ensure_subscription, message.from_user.id)
days_left = await asyncio.to_thread(activate_subscription_days, message.from_user.id, days) days_left = await asyncio.to_thread(activate_subscription_days, message.from_user.id, days)
await asyncio.to_thread( await asyncio.to_thread(
+2 -2
View File
@@ -256,7 +256,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(message.answers), 1) self.assertEqual(len(message.answers), 1)
self.assertEqual(message.answers[0][0], "Выберите срок подписки") self.assertEqual(message.answers[0][0], "Выберите срок подписки")
labels = [button.text for row in message.answers[0][1]["reply_markup"].inline_keyboard for button in row] labels = [button.text for row in message.answers[0][1]["reply_markup"].inline_keyboard for button in row]
self.assertEqual(labels, ["0 дней — 0 ⭐ (тест)", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) self.assertEqual(labels, ["0 дней — 1 ⭐", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"])
async def test_subscription_action_button_is_rejected_outside_subscription_mode(self): async def test_subscription_action_button_is_rejected_outside_subscription_mode(self):
message = _StubMessage(keyboards.RENEW_SUBSCRIPTION_BUTTON) message = _StubMessage(keyboards.RENEW_SUBSCRIPTION_BUTTON)
@@ -278,7 +278,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(message_bot.mock_calls), 2) self.assertEqual(len(message_bot.mock_calls), 2)
sent = message_bot.mock_calls[-1].args[0] sent = message_bot.mock_calls[-1].args[0]
self.assertEqual(sent.text, "Выберите срок подписки") self.assertEqual(sent.text, "Выберите срок подписки")
self.assertEqual([b.text for row in sent.reply_markup.inline_keyboard for b in row], ["0 дней — 0 ⭐ (тест)", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) self.assertEqual([b.text for row in sent.reply_markup.inline_keyboard for b in row], ["0 дней — 1 ⭐", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"])
async def test_idea_command_generates_only_in_generation_mode(self): async def test_idea_command_generates_only_in_generation_mode(self):
message = _StubMessage("/idea") message = _StubMessage("/idea")
+26 -7
View File
@@ -83,6 +83,16 @@ class PreCheckoutTests(unittest.IsolatedAsyncioTestCase):
await payments.handle_pre_checkout(query) await payments.handle_pre_checkout(query)
query.answer.assert_awaited_once_with(ok=True) query.answer.assert_awaited_once_with(ok=True)
async def test_accepts_valid_zero_day_one_star_invoice(self):
query = self._query(amount=1, payload="sub:0:20261001120000")
await payments.handle_pre_checkout(query)
query.answer.assert_awaited_once_with(ok=True)
async def test_rejects_zero_day_invoice_with_wrong_amount(self):
query = self._query(amount=0, payload="sub:0:20261001120000")
await payments.handle_pre_checkout(query)
self.assertFalse(query.answer.await_args.kwargs["ok"])
async def test_rejects_wrong_amount(self): async def test_rejects_wrong_amount(self):
query = self._query(amount=1) query = self._query(amount=1)
await payments.handle_pre_checkout(query) await payments.handle_pre_checkout(query)
@@ -230,13 +240,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(markup.inline_keyboard[0][0].text, "🧪 Оплатил (dev, 10 дн.)") self.assertEqual(markup.inline_keyboard[0][0].text, "🧪 Оплатил (dev, 10 дн.)")
self.assertEqual(markup.inline_keyboard[0][0].callback_data, "dev_paid:42:10") self.assertEqual(markup.inline_keyboard[0][0].callback_data, "dev_paid:42:10")
async def test_zero_star_plan_is_present_and_probes_one_star_invoice(self): async def test_zero_day_plan_is_present_and_does_not_activate_in_dev_mode(self):
from pathlib import Path
source = Path(bot.__file__).read_text(encoding="utf-8")
body = source.split("async def _send_zero_star_invoice")[1].split("\n\nasync def _send_subscription_invoice")[0]
self.assertIn("amount=1", body)
self.assertNotIn("activate_subscription", body)
callback = AsyncMock() callback = AsyncMock()
callback.data = "dev_paid:42:0" callback.data = "dev_paid:42:0"
callback.from_user = SimpleNamespace(id=42) callback.from_user = SimpleNamespace(id=42)
@@ -248,6 +252,21 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
to_thread.assert_not_awaited() to_thread.assert_not_awaited()
callback.answer.assert_awaited_once_with("Неизвестный срок подписки.", show_alert=True) callback.answer.assert_awaited_once_with("Неизвестный срок подписки.", show_alert=True)
async def test_zero_day_plan_is_a_one_star_invoice_without_access_grant(self):
self.assertEqual(payments.plan_for_payload("sub:0:20261001120000"), (0, 1))
message = _paid_message(amount=1, payload="sub:0:20261001120000")
self.db = _db()
with (
patch.dict(os.environ, {"BOT_DB_PATH": self.db}),
patch.object(payments.asyncio, "to_thread", new=_direct_thread_mock()),
patch.object(payments, "activate_subscription_days") as activate,
):
await payments.handle_successful_payment(message)
activate.assert_not_called()
self.assertTrue(payments.payment_recorded("charge1", self.db))
self.assertIn("Срок подписки не изменён", message.answers[0])
async def test_dev_confirmation_activates_selected_term_and_replies_with_status(self): async def test_dev_confirmation_activates_selected_term_and_replies_with_status(self):
callback = AsyncMock() callback = AsyncMock()
callback.data = "dev_paid:42:10" callback.data = "dev_paid:42:10"