diff --git a/Dockerfile b/Dockerfile index c0ef563..2d22f8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,5 +6,5 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY bot.py apinex_client.py user_store.py help_message.py payments.py keyboards.py raw_updates.py support.py activity_phrases.py VERSION ./ +COPY bot.py analytics.py apinex_client.py user_store.py help_message.py payments.py keyboards.py raw_updates.py support.py activity_phrases.py VERSION ./ CMD ["python", "bot.py"] diff --git a/VERSION b/VERSION index 3b3f8ce..b51d2a4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+64 \ No newline at end of file +0.1.0+65 diff --git a/analytics.py b/analytics.py new file mode 100644 index 0000000..faa868c --- /dev/null +++ b/analytics.py @@ -0,0 +1,138 @@ +"""Per-user product analytics stored in a separate SQLite database.""" +from __future__ import annotations + +from contextlib import closing +import datetime as dt +import os +import sqlite3 +from pathlib import Path + +DEFAULT_DB_PATH = "/app/data/analytics.sqlite3" + + +def connect(db_path: str | None = None) -> sqlite3.Connection: + path = Path(db_path or os.getenv("ANALYTICS_DB_PATH", DEFAULT_DB_PATH)) + path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(path, timeout=10) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout = 10000") + connection.execute("PRAGMA journal_mode = WAL") + return connection + + +def init_db(db_path: str | None = None) -> None: + with closing(connect(db_path)) as connection, connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_user_id INTEGER NOT NULL, + mode TEXT NOT NULL, + event_type TEXT NOT NULL, + message_type TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_events_user_time ON events(telegram_user_id, created_at)" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(event_type, created_at)" + ) + + +def record_event( + user_id: int, + mode: str, + event_type: str, + message_type: str | None = None, + db_path: str | None = None, +) -> None: + with closing(connect(db_path)) as connection, connection: + connection.execute( + "CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, telegram_user_id INTEGER NOT NULL, mode TEXT NOT NULL, event_type TEXT NOT NULL, message_type TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)" + ) + connection.execute( + "INSERT INTO events (telegram_user_id, mode, event_type, message_type) VALUES (?, ?, ?, ?)", + (int(user_id), mode[:30], event_type[:50], message_type[:20] if message_type else None), + ) + + +def _period_start(period: str, now: dt.datetime | None = None) -> str: + now = now or dt.datetime.now(dt.timezone.utc) + days = {"day": 1, "week": 7, "month": 30}.get(period, 0) + return (now - dt.timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") + + +def user_metrics(user_id: int, period: str = "all", db_path: str | None = None) -> dict[str, object]: + where = "telegram_user_id = ?" + args: list[object] = [int(user_id)] + if period != "all": + where += " AND created_at >= ?" + args.append(_period_start(period)) + with closing(connect(db_path)) as connection: + rows = connection.execute( + f"SELECT mode, event_type, message_type, COUNT(*) AS total FROM events WHERE {where} GROUP BY mode, event_type, message_type", + args, + ).fetchall() + metrics: dict[str, object] = { + "user_id": int(user_id), "period": period, + "post_generations": 0, "idea_generations": 0, "post_revisions": 0, + "generation_messages": 0, "training_messages": 0, + "generation_message_types": {}, "training_message_types": {}, + } + generation_types: dict[str, int] = {} + training_types: dict[str, int] = {} + for row in rows: + event_type, message_type, total = row["event_type"], row["message_type"], int(row["total"]) + if event_type == "post_generation": metrics["post_generations"] = int(metrics["post_generations"]) + total + elif event_type == "idea_generation": metrics["idea_generations"] = int(metrics["idea_generations"]) + total + elif event_type == "post_revision": metrics["post_revisions"] = int(metrics["post_revisions"]) + total + elif event_type == "user_message": + target = generation_types if row["mode"] == "generation" else training_types + if row["mode"] == "generation": metrics["generation_messages"] = int(metrics["generation_messages"]) + total + elif row["mode"] == "training": metrics["training_messages"] = int(metrics["training_messages"]) + total + if message_type: target[str(message_type)] = target.get(str(message_type), 0) + total + metrics["generation_message_types"] = generation_types + metrics["training_message_types"] = training_types + return metrics + + +def all_metrics(period: str = "all", db_path: str | None = None) -> dict[str, object]: + """Return owner-facing aggregate metrics from per-user events.""" + where = "" + args: list[object] = [] + if period != "all": + where = " WHERE created_at >= ?" + args.append(_period_start(period)) + with closing(connect(db_path)) as connection: + row = connection.execute( + f"""SELECT COUNT(DISTINCT telegram_user_id) AS users, + COUNT(DISTINCT CASE WHEN event_type = 'user_message' THEN telegram_user_id END) AS active_users, + SUM(CASE WHEN event_type = 'post_generation' THEN 1 ELSE 0 END) AS post_generations, + SUM(CASE WHEN event_type = 'idea_generation' THEN 1 ELSE 0 END) AS idea_generations, + SUM(CASE WHEN event_type = 'post_revision' THEN 1 ELSE 0 END) AS post_revisions, + SUM(CASE WHEN event_type = 'user_message' AND mode = 'generation' THEN 1 ELSE 0 END) AS generation_messages, + SUM(CASE WHEN event_type = 'user_message' AND mode = 'training' THEN 1 ELSE 0 END) AS training_messages + FROM events{where}""", args + ).fetchone() + types = connection.execute( + f"SELECT mode, message_type, COUNT(*) AS total FROM events WHERE event_type='user_message' AND message_type IS NOT NULL{(' AND created_at >= ?' if period != 'all' else '')} GROUP BY mode, message_type", + args, + ).fetchall() + result = {key: int(row[key] or 0) for key in ("users", "active_users", "post_generations", "idea_generations", "post_revisions", "generation_messages", "training_messages")} + result["message_types"] = {(str(r["mode"]), str(r["message_type"])): int(r["total"]) for r in types} + result["period"] = period + return result + + +def render_report(period: str = "day", db_path: str | None = None) -> str: + metrics = all_metrics(period, db_path) + types = metrics["message_types"] + generation_types = {k[1]: v for k, v in types.items() if k[0] == "generation"} + training_types = {k[1]: v for k, v in types.items() if k[0] == "training"} + def line(label: str, value: object) -> str: + return f"{label}: {value}" + rows = ["
Период: {period}
", "" + "
".join(rows) + "
{user_id}",
parse_mode="HTML",
)
@@ -544,17 +538,30 @@ 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":
+@router.callback_query(F.data.startswith("subscription:plan:"))
+async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None:
+ if await current_mode(state) != "subscription" or not isinstance(callback.message, Message):
await callback.answer("Открой раздел «Подписка» из главного меню.", show_alert=True)
return
- message = callback.message
- if not isinstance(message, Message) or callback.from_user is None:
- await callback.answer("Не удалось открыть оплату. Попробуй ещё раз.", show_alert=True)
+ try:
+ days = int((callback.data or "").rsplit(":", 1)[1])
+ except ValueError:
+ await callback.answer("Некорректный срок.", show_alert=True)
+ return
+ if days not in user_store.SUBSCRIPTION_PLANS:
+ await callback.answer("Такой тариф недоступен.", show_alert=True)
return
await callback.answer()
- await _send_subscription_invoice(message, callback.from_user.id)
+ await _send_subscription_invoice(callback.message, callback.from_user.id, days)
+
+
+@router.callback_query(F.data == "subscription:renew")
+async def subscription_renew(callback: CallbackQuery, state: FSMContext) -> None:
+ if await current_mode(state) != "subscription" or not isinstance(callback.message, Message):
+ await callback.answer("Открой раздел «Подписка» из главного меню.", show_alert=True)
+ return
+ await callback.answer()
+ await callback.message.answer("Выберите срок подписки", reply_markup=subscription_plans_keyboard())
async def style_command(message: Message, state: FSMContext) -> None:
@@ -778,6 +785,7 @@ async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> No
parse_mode="HTML",
)
await asyncio.to_thread(save_idea, user_id, idea)
+ await asyncio.to_thread(analytics.record_event, user_id, "generation", "idea_generation")
async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None:
@@ -785,7 +793,8 @@ async def _send_three_posts(message: Message, posts: list[str], user_id: int | N
await message.answer(post)
if user_id is not None:
await asyncio.to_thread(save_generated_posts, user_id, posts, origin)
-
+ event_type = "post_revision" if origin == "revised" else "post_generation"
+ await asyncio.to_thread(analytics.record_event, user_id, "generation", event_type)
@asynccontextmanager
@@ -1242,6 +1251,9 @@ async def _generate_posts_from_images(message: Message, images, request: str, st
async def _handle_image_message(message: Message, bot: Bot, state: FSMContext) -> None:
+ mode = await current_mode(state)
+ if message.from_user is not None and mode in {"generation", "training"}:
+ await asyncio.to_thread(analytics.record_event, message.from_user.id, mode, "user_message", "image")
if (await notify_development_deploy(bot, state, message.from_user.id if message.from_user else None)) is True:
return
with diagnostic_operation():
@@ -1339,7 +1351,16 @@ async def _process_voice(message: Message, bot: Bot, state: FSMContext, status=N
await status.finish("⚠️ Не удалось распознать речь")
await message.answer("Не удалось распознать речь в аудио.")
return
- if await current_mode(state) == "training":
+ current_mode_result = await current_mode(state)
+ if message.from_user is not None:
+ await asyncio.to_thread(
+ analytics.record_event,
+ message.from_user.id,
+ current_mode_result,
+ "user_message",
+ "voice" if message.voice else "audio",
+ )
+ if current_mode_result == "training":
if message.from_user is None:
if status:
await status.finish("❌ Не удалось определить пользователя")
@@ -1414,6 +1435,8 @@ async def handle_command_text(message: Message, state: FSMContext) -> None:
await subscribe_command(message, state)
elif command == "/support":
await open_support(message, state)
+ elif command == "/stats":
+ await stats_command(message, state)
else:
await message.answer("Неизвестная команда. Открой /help, чтобы посмотреть доступные команды.")
@@ -1436,7 +1459,7 @@ async def handle_text(message: Message, state: FSMContext) -> None:
return
if button == SUBSCRIPTION_ACTION_VALUE:
if await require_mode(message, state, "subscription") and message.from_user is not None:
- await _send_subscription_invoice(message, message.from_user.id)
+ await message.answer("Выберите срок подписки", reply_markup=subscription_plans_keyboard())
return
if button == TRAINING_RESET_VALUE:
await clear_command(message, state)
@@ -1446,6 +1469,8 @@ async def handle_text(message: Message, state: FSMContext) -> None:
return
mode = await current_mode(state)
+ if message.from_user is not None and mode in {"generation", "training"}:
+ await asyncio.to_thread(analytics.record_event, message.from_user.id, mode, "user_message", "text")
pending_image = (await state.get_data()).get("pending_image_request")
if mode == "generation" and pending_image:
await state.update_data(pending_image_request=None)
@@ -1491,6 +1516,26 @@ async def handle_other_message(message: Message, state: FSMContext) -> None:
await message.answer(text, reply_markup=keyboard_for_mode(mode))
+async def send_owner_stats(bot: Bot, period: str = "day") -> None:
+ if not OWNER_USER_ID:
+ return
+ report = await asyncio.to_thread(analytics.render_report, period)
+ await bot.send_rich_message(chat_id=ANALYTICS_CHAT_ID, message_thread_id=ANALYTICS_TOPIC_ID, rich_message=InputRichMessage(html=report))
+
+
+@admin_router.message(F.text.regexp(r"^/stats(?:\s+(day|week|month|all))?$") )
+async def stats_command(message: Message, state: FSMContext) -> None:
+ if message.chat.id != ANALYTICS_CHAT_ID or message.message_thread_id != ANALYTICS_TOPIC_ID:
+ return
+ if OWNER_USER_ID and (not message.from_user or message.from_user.id != OWNER_USER_ID):
+ return
+ period = ((message.text or "").split(maxsplit=1)[1] if len((message.text or "").split()) > 1 else "day")
+ report = await asyncio.to_thread(analytics.render_report, period)
+ if message.bot is None:
+ return
+ await message.bot.send_rich_message(chat_id=ANALYTICS_CHAT_ID, message_thread_id=ANALYTICS_TOPIC_ID, rich_message=InputRichMessage(html=report))
+
+
async def set_main_menu(bot: Bot) -> None:
"""Публикует команды переключения режимов и действия текущих режимов."""
try:
@@ -1508,6 +1553,7 @@ async def set_main_menu(bot: Bot) -> None:
BotCommand(command="balance", description="Статус подписки"),
BotCommand(command="subscribe", description="Оплатить подписку"),
BotCommand(command="support", description="Написать в поддержку"),
+ BotCommand(command="stats", description="Статистика бота"),
]
)
except Exception as exc:
@@ -1516,6 +1562,7 @@ async def set_main_menu(bot: Bot) -> None:
async def main() -> None:
init_db()
+ analytics.init_db()
payments.init_payments_table()
await asyncio.to_thread(support.init_support_table)
if is_development_environment():
@@ -1534,6 +1581,7 @@ async def main() -> None:
# Роли: групповые сценарии регистрируются ДО основного роутера, иначе
# catch-all @router.message() основного роутера перехватывает их апдейты.
dp.include_router(support.router)
+ dp.include_router(admin_router)
dp.include_router(router)
dp.message.outer_middleware(support.SupportRelayMiddleware())
# Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту.
diff --git a/keyboards.py b/keyboards.py
index 15a6270..bc6dc12 100644
--- a/keyboards.py
+++ b/keyboards.py
@@ -88,6 +88,14 @@ def mode_keyboard(
)
+def subscription_plans_keyboard() -> InlineKeyboardMarkup:
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [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")],
+ ])
+
+
def help_inline_keyboard() -> InlineKeyboardMarkup:
"""Стандартная inline-клавиатура для навигации по разделам справки."""
return InlineKeyboardMarkup(
diff --git a/payments.py b/payments.py
index db6d4d5..26f241c 100644
--- a/payments.py
+++ b/payments.py
@@ -18,7 +18,8 @@ from aiogram import F, Router
from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment
from user_store import (
- SUBSCRIPTION_PRICE_STARS,
+ SUBSCRIPTION_PLANS,
+ activate_subscription_days,
activate_subscription,
connect,
ensure_subscription,
@@ -28,7 +29,18 @@ logger = logging.getLogger(__name__)
router = Router(name="payments")
STARS_CURRENCY = "XTR"
-INVOICE_PREFIX = "sub30:"
+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:"):
+ return (30, SUBSCRIPTION_PLANS[30])
+ price = SUBSCRIPTION_PLANS.get(days)
+ return (days, price) if price is not None else None
def _db_path(db_path: str | None = None) -> str | None:
@@ -81,16 +93,19 @@ def save_payment(
)
-async def send_subscription_invoice(bot, chat_id: int, thread_id: int | None = None) -> None:
- """Отправляет инвойс на 30 дней подписки за Stars."""
- payload = f"{INVOICE_PREFIX}{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}"
+async def send_subscription_invoice(
+ bot, chat_id: int, thread_id: int | None = None, days: int = 30
+) -> None:
+ """Отправляет выбранный инвойс на разовую подписку в Stars."""
+ price = SUBSCRIPTION_PLANS[days]
+ payload = f"{INVOICE_PREFIX}{days}:{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}"
kwargs: dict[str, object] = {
"chat_id": chat_id,
- "title": "Подписка на 30 дней",
- "description": "Доступ к генерации постов, идей и голосовым на 30 дней.",
+ "title": f"Подписка на {days} дней",
+ "description": "Доступ к боту по генерации текста постов",
"payload": payload,
"currency": STARS_CURRENCY,
- "prices": [LabeledPrice(label="30 дней", amount=SUBSCRIPTION_PRICE_STARS)],
+ "prices": [LabeledPrice(label=f"{days} дней", amount=price)],
"provider_token": "",
}
if thread_id is not None:
@@ -101,12 +116,10 @@ async def send_subscription_invoice(bot, chat_id: int, thread_id: int | None = N
@router.pre_checkout_query()
async def handle_pre_checkout(query: PreCheckoutQuery) -> None:
"""Подтверждаем только свой инвойс, в правильной валюте и по правильной цене."""
- if query.currency != STARS_CURRENCY or query.total_amount != SUBSCRIPTION_PRICE_STARS:
+ plan = plan_for_payload(query.invoice_payload)
+ if plan is None or query.currency != STARS_CURRENCY or query.total_amount != plan[1]:
await query.answer(ok=False, error_message="Некорректная сумма или валюта платежа.")
return
- if not query.invoice_payload.startswith(INVOICE_PREFIX):
- await query.answer(ok=False, error_message="Этот счёт не принадлежит подписке.")
- return
await query.answer(ok=True)
@@ -117,12 +130,11 @@ async def handle_successful_payment(message: Message) -> None:
return
charge_id = payment.telegram_payment_charge_id
- if payment.currency != STARS_CURRENCY or payment.total_amount != SUBSCRIPTION_PRICE_STARS:
+ plan = plan_for_payload(payment.invoice_payload)
+ if plan is None or payment.currency != STARS_CURRENCY or payment.total_amount != plan[1]:
await _reject(message, charge_id, "неверная сумма или валюта")
return
- if not payment.invoice_payload.startswith(INVOICE_PREFIX):
- await _reject(message, charge_id, "неизвестный invoice_payload")
- return
+ days, _price = plan
db_path = _db_path()
if await asyncio.to_thread(payment_recorded, charge_id, db_path):
@@ -130,7 +142,7 @@ async def handle_successful_payment(message: Message) -> None:
return
await asyncio.to_thread(ensure_subscription, message.from_user.id)
- days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1)
+ days_left = await asyncio.to_thread(activate_subscription_days, message.from_user.id, days)
await asyncio.to_thread(
save_payment,
charge_id,
@@ -141,9 +153,7 @@ async def handle_successful_payment(message: Message) -> None:
db_path,
)
await message.answer(
- f"Оплата получена — подписка активирована на {days} дн.\n\n"
- f"Списано {payment.total_amount} звёзд. Теперь доступны генерация постов, "
- f"идей и голосовые. Команда /balance покажет статус."
+ f"Подписка активна\nЗакончится через {days_left} дн."
)
diff --git a/tests/test_payments.py b/tests/test_payments.py
index d15d115..2710305 100644
--- a/tests/test_payments.py
+++ b/tests/test_payments.py
@@ -46,15 +46,15 @@ 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)
+ 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(payments.INVOICE_PREFIX))
- prices = kwargs["prices"]
- self.assertIsInstance(prices[0], LabeledPrice)
- self.assertEqual(prices[0].amount, user_store.SUBSCRIPTION_PRICE_STARS)
+ 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))
async def test_invoice_includes_thread_when_given(self):
fake_bot = AsyncMock()
@@ -68,7 +68,7 @@ class InvoiceTests(unittest.IsolatedAsyncioTestCase):
class PreCheckoutTests(unittest.IsolatedAsyncioTestCase):
- def _query(self, currency="XTR", amount=100, payload="sub30:1"):
+ def _query(self, currency="XTR", amount=450, payload="sub:30:1"):
query = AsyncMock()
query.currency = currency
query.total_amount = amount
@@ -76,7 +76,7 @@ class PreCheckoutTests(unittest.IsolatedAsyncioTestCase):
return query
async def test_accepts_valid_subscription_invoice(self):
- query = self._query()
+ query = self._query(payload="sub:30:1")
await payments.handle_pre_checkout(query)
query.answer.assert_awaited_once_with(ok=True)
@@ -121,7 +121,7 @@ class _StubMessage:
return self
-def _paid_message(charge_id="charge1", amount=100, currency="XTR", payload="sub30:1"):
+def _paid_message(charge_id="charge1", amount=450, currency="XTR", payload="sub:30:1"):
payment = SuccessfulPayment(
currency=currency,
total_amount=amount,
@@ -152,7 +152,7 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase):
with patch.dict(os.environ, {"BOT_DB_PATH": self.db}):
await payments.handle_successful_payment(message)
self.assertEqual(len(message.answers), 1)
- self.assertIn("активирована", message.answers[0])
+ self.assertIn("Подписка активна", message.answers[0])
self.assertTrue(payments.payment_recorded("charge1", self.db))
row = user_store.subscription_row(42, self.db)
self.assertIsNotNone(row["last_paid_at"])
@@ -172,7 +172,7 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase):
message = _paid_message(amount=5)
with (
patch.object(payments, "save_payment", new=AsyncMock()) as save,
- patch.object(payments, "activate_subscription") as activate,
+ patch.object(payments, "activate_subscription_days") as activate,
):
await payments.handle_successful_payment(message)
activate.assert_not_called()
@@ -181,13 +181,13 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase):
async def test_wrong_currency_is_rejected(self):
message = _paid_message(currency="USD")
- with patch.object(payments, "activate_subscription") as activate:
+ with patch.object(payments, "activate_subscription_days") as activate:
await payments.handle_successful_payment(message)
activate.assert_not_called()
async def test_foreign_payload_is_rejected_without_activation(self):
message = _paid_message(payload="other")
- with patch.object(payments, "activate_subscription") as activate:
+ with patch.object(payments, "activate_subscription_days") as activate:
await payments.handle_successful_payment(message)
activate.assert_not_called()
diff --git a/user_store.py b/user_store.py
index e01bac0..c9028db 100644
--- a/user_store.py
+++ b/user_store.py
@@ -137,10 +137,15 @@ def init_db(db_path: str | None = None) -> None:
)
-# Периоды: триал 3 дня, подписка 30 дней (100 звёзд)
+# Единый бесплатный старт и разовые тарифы подписки.
TRIAL_DAYS = 3
-SUBSCRIPTION_MONTH_DAYS = 30
-SUBSCRIPTION_PRICE_STARS = 100
+SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием.
+SUBSCRIPTION_PRICE_STARS = 450
+SUBSCRIPTION_PLANS = {
+ 1: 15,
+ 10: 150,
+ 30: 450,
+}
def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int:
@@ -185,31 +190,35 @@ def ensure_subscription(user_id: int, db_path: str | None = None) -> None:
)
-def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int:
- """Активирует/продлевает подписку на заданное число месяцев. Возвращает дни нового срока."""
+def activate_subscription_days(user_id: int, days: int, db_path: str | None = None) -> int:
+ """Добавляет срок к текущей подписке и возвращает оставшиеся дни."""
+ if days <= 0:
+ raise ValueError("Subscription days must be positive")
with closing(connect(db_path)) as connection, connection:
sub = subscription_row(user_id, db_path)
- if sub and sub["last_paid_at"]:
- # Продление: отсчитываем от момента оплаты
- now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
- elif sub:
- # Первый платёж после триала: отсчитываем ровно 30 дней от момента оплаты
- now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
- else:
+ if not sub:
raise RuntimeError("Subscription record not found")
- new_expires = now + datetime.timedelta(days=SUBSCRIPTION_MONTH_DAYS * months)
+ now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
+ current_days = _days_until(sub, now)
+ if current_days > 0:
+ current_expires = now + datetime.timedelta(days=current_days, seconds=1)
+ new_expires = current_expires + datetime.timedelta(days=days)
+ else:
+ new_expires = now + datetime.timedelta(days=days)
connection.execute(
"""
UPDATE subscriptions
- SET last_paid_at = ?,
- is_active = 1,
- monthly_days = ?,
- updated_at = CURRENT_TIMESTAMP
+ SET last_paid_at = ?, is_active = 1, monthly_days = ?, updated_at = CURRENT_TIMESTAMP
WHERE telegram_user_id = ?
""",
- (new_expires.isoformat(), SUBSCRIPTION_MONTH_DAYS * months, user_id),
+ (new_expires.isoformat(), days, user_id),
)
- return (new_expires - now).days
+ return max(0, (new_expires - now).days)
+
+
+def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int:
+ """Совместимый интерфейс старого dev-сценария: добавляет месячный срок."""
+ return activate_subscription_days(user_id, SUBSCRIPTION_MONTH_DAYS * months, db_path)
def deactivate_subscription(user_id: int, db_path: str | None = None) -> None: