From dc2ec6344bedf1a2c22ed72a3835101cbe828e82 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 29 Oct 2026 13:03:55 +0200 Subject: [PATCH] fix(subscription): process zero-day one-star payment --- VERSION | 2 +- bot.py | 20 +------------------- keyboards.py | 2 +- payments.py | 17 ++++++++++++++++- tests/test_keyboards.py | 4 ++-- tests/test_payments.py | 33 ++++++++++++++++++++++++++------- 6 files changed, 47 insertions(+), 31 deletions(-) diff --git a/VERSION b/VERSION index 89226b1..83c5253 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+69 +0.1.0+70 diff --git a/bot.py b/bot.py index ba9d0ca..18cb917 100644 --- a/bot.py +++ b/bot.py @@ -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: """Sends a one-Star test invoice; never grants access.""" - try: - 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 ⭐. Доступ он не выдаёт.") + await _send_subscription_invoice(message, user_id, days=0) 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) return await callback.answer() - if days == 0: - await _send_zero_star_invoice(callback.message, callback.from_user.id) - return try: await callback.message.delete() except Exception as exc: diff --git a/keyboards.py b/keyboards.py index 5fd54ce..58ec27f 100644 --- a/keyboards.py +++ b/keyboards.py @@ -90,7 +90,7 @@ def mode_keyboard( def subscription_plans_keyboard() -> InlineKeyboardMarkup: 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="10 дней — 150 ⭐", callback_data="subscription:plan:10")], [InlineKeyboardButton(text="30 дней — 450 ⭐", callback_data="subscription:plan:30")], diff --git a/payments.py b/payments.py index d66ebb6..89b33b2 100644 --- a/payments.py +++ b/payments.py @@ -48,7 +48,9 @@ def plan_for_payload(payload: str) -> tuple[int, int] | None: except (ValueError, IndexError): return None 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: @@ -149,6 +151,19 @@ async def handle_successful_payment(message: Message) -> None: await message.answer("Этот платёж уже учтён — подписка не продлена повторно.") 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) days_left = await asyncio.to_thread(activate_subscription_days, message.from_user.id, days) await asyncio.to_thread( diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 7fce479..db6e9c3 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -256,7 +256,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): 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, ["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): message = _StubMessage(keyboards.RENEW_SUBSCRIPTION_BUTTON) @@ -278,7 +278,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): 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], ["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): message = _StubMessage("/idea") diff --git a/tests/test_payments.py b/tests/test_payments.py index 5376c8d..05958f5 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -83,6 +83,16 @@ class PreCheckoutTests(unittest.IsolatedAsyncioTestCase): await payments.handle_pre_checkout(query) 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): query = self._query(amount=1) 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].callback_data, "dev_paid:42:10") - async def test_zero_star_plan_is_present_and_probes_one_star_invoice(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) - + async def test_zero_day_plan_is_present_and_does_not_activate_in_dev_mode(self): callback = AsyncMock() callback.data = "dev_paid:42:0" callback.from_user = SimpleNamespace(id=42) @@ -248,6 +252,21 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): to_thread.assert_not_awaited() 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): callback = AsyncMock() callback.data = "dev_paid:42:10"