- payments.py: sendInvoice (XTR, 100 звёзд) -> pre_checkout_query -> successful_payment - /subscribe больше не активирует подписку сам — только отправляет счёт - handle_pre_checkout подтверждает только свой payload в XTR на 100 звёзд - таблица payments по telegram_payment_charge_id защищает от двойного начисления - 16 новых тестов платежей, старый тест заглушки переписан; 75 тестов OK - Dockerfile копирует payments.py, VERSION 0.1.0+25
160 lines
6.1 KiB
Python
160 lines
6.1 KiB
Python
"""Реальная оплата Telegram Stars (XTR) за подписку.
|
|
|
|
Цикл: sendInvoice -> pre_checkout_query -> answerPreCheckoutQuery ->
|
|
successful_payment -> activate_subscription.
|
|
`telegram_payment_charge_id` каждого платежа пишется в таблицу payments,
|
|
поэтому один платёж не может активировать подписку дважды, а по нему потом
|
|
можно сделать возврат через refundStarPayment.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import datetime
|
|
import logging
|
|
import os
|
|
from contextlib import closing
|
|
|
|
from aiogram import F, Router
|
|
from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment
|
|
|
|
from user_store import (
|
|
SUBSCRIPTION_PRICE_STARS,
|
|
activate_subscription,
|
|
connect,
|
|
ensure_subscription,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = Router(name="payments")
|
|
|
|
STARS_CURRENCY = "XTR"
|
|
INVOICE_PREFIX = "sub30:"
|
|
|
|
|
|
def _db_path(db_path: str | None = None) -> str | None:
|
|
return db_path if db_path is not None else os.getenv("BOT_DB_PATH")
|
|
|
|
|
|
def init_payments_table(db_path: str | None = None) -> None:
|
|
with closing(connect(db_path)) as connection, connection:
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS payments (
|
|
telegram_payment_charge_id TEXT PRIMARY KEY,
|
|
telegram_user_id INTEGER NOT NULL,
|
|
invoice_payload TEXT NOT NULL,
|
|
currency TEXT NOT NULL,
|
|
total_amount INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(telegram_user_id)"
|
|
)
|
|
|
|
|
|
def payment_recorded(charge_id: str, db_path: str | None = None) -> bool:
|
|
with closing(connect(db_path)) as connection:
|
|
row = connection.execute(
|
|
"SELECT 1 FROM payments WHERE telegram_payment_charge_id = ?", (charge_id,)
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
def save_payment(
|
|
charge_id: str,
|
|
user_id: int,
|
|
invoice_payload: str,
|
|
currency: str,
|
|
total_amount: int,
|
|
db_path: str | None = None,
|
|
) -> None:
|
|
with closing(connect(db_path)) as connection, connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO payments
|
|
(telegram_payment_charge_id, telegram_user_id, invoice_payload, currency, total_amount)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
(charge_id, user_id, invoice_payload, currency, total_amount),
|
|
)
|
|
|
|
|
|
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}"
|
|
kwargs: dict[str, object] = {
|
|
"chat_id": chat_id,
|
|
"title": "Подписка на 30 дней",
|
|
"description": "Доступ к генерации постов, идей и голосовым на 30 дней.",
|
|
"payload": payload,
|
|
"currency": STARS_CURRENCY,
|
|
"prices": [LabeledPrice(label="30 дней", amount=SUBSCRIPTION_PRICE_STARS)],
|
|
"provider_token": "",
|
|
}
|
|
if thread_id is not None:
|
|
kwargs["message_thread_id"] = thread_id
|
|
await bot.send_invoice(**kwargs)
|
|
|
|
|
|
@router.pre_checkout_query()
|
|
async def handle_pre_checkout(query: PreCheckoutQuery) -> None:
|
|
"""Подтверждаем только свой инвойс, в правильной валюте и по правильной цене."""
|
|
if query.currency != STARS_CURRENCY or query.total_amount != SUBSCRIPTION_PRICE_STARS:
|
|
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)
|
|
|
|
|
|
@router.message(F.successful_payment)
|
|
async def handle_successful_payment(message: Message) -> None:
|
|
payment = message.successful_payment
|
|
if payment is None or message.from_user is None:
|
|
return
|
|
|
|
charge_id = payment.telegram_payment_charge_id
|
|
if payment.currency != STARS_CURRENCY or payment.total_amount != SUBSCRIPTION_PRICE_STARS:
|
|
await _reject(message, charge_id, "неверная сумма или валюта")
|
|
return
|
|
if not payment.invoice_payload.startswith(INVOICE_PREFIX):
|
|
await _reject(message, charge_id, "неизвестный invoice_payload")
|
|
return
|
|
|
|
db_path = _db_path()
|
|
if await asyncio.to_thread(payment_recorded, charge_id, db_path):
|
|
await message.answer("Этот платёж уже учтён — подписка не продлена повторно.")
|
|
return
|
|
|
|
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
|
days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1)
|
|
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(
|
|
f"Оплата получена — подписка активирована на {days} дн.\n\n"
|
|
f"Списано {payment.total_amount} звёзд. Теперь доступны генерация постов, "
|
|
f"идей и голосовые. Команда /balance покажет статус."
|
|
)
|
|
|
|
|
|
async def _reject(message: Message, charge_id: str, reason: str) -> None:
|
|
logger.error(
|
|
"Rejected payment charge=%s user=%s reason=%s",
|
|
charge_id,
|
|
message.from_user.id if message.from_user else None,
|
|
reason,
|
|
)
|
|
await message.answer(
|
|
"Платёж получен, но активировать подписку не получилось. Напиши, пожалуйста, поддержке."
|
|
)
|