Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4441cf79ff | |||
| 7428bd5cf7 | |||
| 76c524e980 | |||
| 5a9fa167c2 | |||
| c61e9c78ba | |||
| e5e3a9e4b5 | |||
| a1f47e13e3 | |||
| 7ff0ed1248 | |||
| 5eef24fb91 | |||
| 05ba26d3ac | |||
| ef68ce19ce | |||
| 2fa84f6dc9 | |||
| 32409d7471 | |||
| 270282a340 | |||
| 87eaa06652 | |||
| 845f03e873 | |||
| 97eabb0604 | |||
| f0d714d787 | |||
| 27a34d6e9f | |||
| a4e97e0317 | |||
| 97dcd0b7cc | |||
| 9b57904cf4 | |||
| 6ebdae0cae | |||
| b9d4a8f8e9 | |||
| 707c3c5daf | |||
| d075abfeea | |||
| d3399c569c | |||
| e36e3f7f8f | |||
| 583e01f5e6 | |||
| 784b5e0af6 | |||
| cc42d33fbe | |||
| 9c3d8fd701 | |||
| d281eeb69d | |||
| b510ffb7d8 | |||
| 6855b7765c | |||
| 9ed1129268 | |||
| 0c4ba3c0d0 | |||
| 70337ac310 | |||
| e981082713 | |||
| 104d98542e | |||
| fe63ef5fc5 | |||
| b1ff964dd6 | |||
| aba1d5dbe5 | |||
| 0af7e4ac68 | |||
| 7d9a572e6e | |||
| de6557d7e7 | |||
| a052c7bb80 | |||
| 8c47f3c49b | |||
| 323ad903e7 | |||
| 8105fe285d | |||
| 82a19025f1 | |||
| e592b3e5c4 | |||
| 2da12354a1 | |||
| fc65b62d65 | |||
| 28603a87e1 | |||
| 8956aafc9a | |||
| 2ee9b263b8 | |||
| 3a5d838be3 | |||
| bf25fcfda6 | |||
| ecdf2fcae4 | |||
| 6fec14de4b | |||
| 7b4cbb27b7 | |||
| d7d7795257 | |||
| 93554c7034 | |||
| fbe7febdde | |||
| f091b3ea5e | |||
| aae469853e | |||
| dac68ccd8c | |||
| fb140e82f6 | |||
| 8cac442de1 | |||
| 8d8a465c43 | |||
| 51f2c660e8 | |||
| 39ecca306d | |||
| 7896a24e13 | |||
| c21325f260 | |||
| 18df7395a2 | |||
| f139251333 | |||
| 369e384b7d | |||
| 008f62e02b | |||
| 6e8981a245 | |||
| bb17cf7361 | |||
| d13c6767e6 | |||
| bf2ee37f33 | |||
| b82929ac3d | |||
| 9b0ea9bf2a | |||
| 5bde7196dc | |||
| 71c3e7fe0d | |||
| 201543c263 | |||
| 5ce7417c80 | |||
| e98564b5ab | |||
| 37dd48cc20 | |||
| 9dff6e8045 | |||
| 572094050e | |||
| 2b41337bbf | |||
| ca94c402bd | |||
| 6fdc46c3c6 | |||
| 34fc568f97 | |||
| 38664ff853 | |||
| e44284d4ba | |||
| d54dc822cc | |||
| dbe9c2d5e9 |
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.4-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.4-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.4-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.4-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.4-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.4-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.4"
|
||||
ARG VERSION="v2.5.5"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
+14
-2
@@ -16,9 +16,18 @@ from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
start,
|
||||
menu,
|
||||
subscription,
|
||||
balance,
|
||||
promocode,
|
||||
referral,
|
||||
support,
|
||||
server_status,
|
||||
common,
|
||||
tickets,
|
||||
)
|
||||
from app.handlers import polls as user_polls
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
@@ -31,6 +40,7 @@ from app.handlers.admin import (
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
polls as admin_polls,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
@@ -145,6 +155,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
@@ -163,6 +174,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_faq.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import logging
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollAnswer,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_poll(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
description: str | None,
|
||||
reward_enabled: bool,
|
||||
reward_amount_kopeks: int,
|
||||
created_by: int | None,
|
||||
questions: Sequence[dict[str, Iterable[str]]],
|
||||
) -> Poll:
|
||||
poll = Poll(
|
||||
title=title,
|
||||
description=description,
|
||||
reward_enabled=reward_enabled,
|
||||
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(poll)
|
||||
await db.flush()
|
||||
|
||||
for order, question_data in enumerate(questions, start=1):
|
||||
question_text = question_data.get("text", "").strip()
|
||||
if not question_text:
|
||||
continue
|
||||
|
||||
question = PollQuestion(
|
||||
poll_id=poll.id,
|
||||
text=question_text,
|
||||
order=order,
|
||||
)
|
||||
db.add(question)
|
||||
await db.flush()
|
||||
|
||||
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
|
||||
option_text = option_text.strip()
|
||||
if not option_text:
|
||||
continue
|
||||
option = PollOption(
|
||||
question_id=question.id,
|
||||
text=option_text,
|
||||
order=option_order,
|
||||
)
|
||||
db.add(option)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(
|
||||
poll,
|
||||
attribute_names=["questions"],
|
||||
)
|
||||
return poll
|
||||
|
||||
|
||||
async def list_polls(db: AsyncSession) -> list[Poll]:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
|
||||
)
|
||||
.order_by(Poll.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
|
||||
selectinload(Poll.responses),
|
||||
)
|
||||
.where(Poll.id == poll_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
|
||||
poll = await db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
return False
|
||||
|
||||
await db.delete(poll)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалён опрос %s", poll_id)
|
||||
return True
|
||||
|
||||
|
||||
async def create_poll_response(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
user_id: int,
|
||||
) -> PollResponse:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
response = result.scalar_one_or_none()
|
||||
if response:
|
||||
return response
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(response)
|
||||
await db.commit()
|
||||
await db.refresh(response)
|
||||
return response
|
||||
|
||||
|
||||
async def get_poll_response_by_id(
|
||||
db: AsyncSession,
|
||||
response_id: int,
|
||||
) -> PollResponse | None:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.poll)
|
||||
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
|
||||
selectinload(PollResponse.answers),
|
||||
selectinload(PollResponse.user),
|
||||
)
|
||||
.where(PollResponse.id == response_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_poll_answer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
response_id: int,
|
||||
question_id: int,
|
||||
option_id: int,
|
||||
) -> PollAnswer:
|
||||
result = await db.execute(
|
||||
select(PollAnswer)
|
||||
.where(
|
||||
and_(
|
||||
PollAnswer.response_id == response_id,
|
||||
PollAnswer.question_id == question_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
answer = result.scalar_one_or_none()
|
||||
if answer:
|
||||
answer.option_id = option_id
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
answer = PollAnswer(
|
||||
response_id=response_id,
|
||||
question_id=question_id,
|
||||
option_id=option_id,
|
||||
)
|
||||
db.add(answer)
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
|
||||
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(PollAnswer).where(PollAnswer.response_id == response_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.count(PollResponse.id),
|
||||
func.count(PollResponse.completed_at),
|
||||
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
|
||||
).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total_responses, completed_responses, reward_sum = totals_result.one()
|
||||
|
||||
option_counts_result = await db.execute(
|
||||
select(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
func.count(PollAnswer.id),
|
||||
)
|
||||
.join(PollOption, PollOption.question_id == PollQuestion.id)
|
||||
.outerjoin(
|
||||
PollAnswer,
|
||||
and_(
|
||||
PollAnswer.question_id == PollQuestion.id,
|
||||
PollAnswer.option_id == PollOption.id,
|
||||
),
|
||||
)
|
||||
.where(PollQuestion.poll_id == poll_id)
|
||||
.group_by(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
)
|
||||
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
|
||||
)
|
||||
|
||||
questions_map: dict[int, dict] = {}
|
||||
for (
|
||||
question_id,
|
||||
question_text,
|
||||
question_order,
|
||||
option_id,
|
||||
option_text,
|
||||
option_order,
|
||||
answer_count,
|
||||
) in option_counts_result:
|
||||
question_entry = questions_map.setdefault(
|
||||
question_id,
|
||||
{
|
||||
"id": question_id,
|
||||
"text": question_text,
|
||||
"order": question_order,
|
||||
"options": [],
|
||||
},
|
||||
)
|
||||
question_entry["options"].append(
|
||||
{
|
||||
"id": option_id,
|
||||
"text": option_text,
|
||||
"count": answer_count,
|
||||
}
|
||||
)
|
||||
|
||||
questions = sorted(questions_map.values(), key=lambda item: item["order"])
|
||||
|
||||
return {
|
||||
"total_responses": total_responses,
|
||||
"completed_responses": completed_responses,
|
||||
"reward_sum_kopeks": reward_sum,
|
||||
"questions": questions,
|
||||
}
|
||||
@@ -219,7 +219,8 @@ async def add_user_balance(
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
bot = None
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
bot = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
@@ -228,12 +229,11 @@ async def add_user_balance(
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.models import TransactionType
|
||||
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
@@ -253,9 +253,10 @@ async def add_user_balance(
|
||||
|
||||
async def add_user_balance_by_id(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
description: str = "Пополнение баланса",
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
@@ -263,7 +264,13 @@ async def add_user_balance_by_id(
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
return await add_user_balance(db, user, amount_kopeks, description)
|
||||
return await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
description,
|
||||
transaction_type=transaction_type,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
|
||||
|
||||
+109
-7
@@ -58,11 +58,12 @@ class SubscriptionStatus(Enum):
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
POLL_REWARD = "poll_reward"
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
@@ -530,6 +531,7 @@ class User(Base):
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
poll_responses = relationship("PollResponse", back_populates="user")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -1061,9 +1063,9 @@ class PromoOfferLog(Base):
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
@@ -1079,6 +1081,106 @@ class BroadcastHistory(Base):
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
|
||||
class Poll(Base):
|
||||
__tablename__ = "polls"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
reward_enabled = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
|
||||
questions = relationship(
|
||||
"PollQuestion",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollQuestion.order",
|
||||
)
|
||||
responses = relationship(
|
||||
"PollResponse",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class PollQuestion(Base):
|
||||
__tablename__ = "poll_questions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="questions")
|
||||
options = relationship(
|
||||
"PollOption",
|
||||
back_populates="question",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollOption.order",
|
||||
)
|
||||
answers = relationship("PollAnswer", back_populates="question")
|
||||
|
||||
|
||||
class PollOption(Base):
|
||||
__tablename__ = "poll_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
question = relationship("PollQuestion", back_populates="options")
|
||||
answers = relationship("PollAnswer", back_populates="option")
|
||||
|
||||
|
||||
class PollResponse(Base):
|
||||
__tablename__ = "poll_responses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
sent_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
reward_given = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="responses")
|
||||
user = relationship("User", back_populates="poll_responses")
|
||||
answers = relationship(
|
||||
"PollAnswer",
|
||||
back_populates="response",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
|
||||
)
|
||||
|
||||
|
||||
class PollAnswer(Base):
|
||||
__tablename__ = "poll_answers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
|
||||
response = relationship("PollResponse", back_populates="answers")
|
||||
question = relationship("PollQuestion", back_populates="answers")
|
||||
option = relationship("PollOption", back_populates="answers")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
|
||||
)
|
||||
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -203,8 +203,56 @@ async def check_heleket_payment_status(
|
||||
await callback.answer("Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
if payment.is_paid:
|
||||
await callback.answer("✅ Платёж уже оплачен", show_alert=True)
|
||||
message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("Платёж ещё не оплачен", show_alert=True)
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated_payment = await payment_service.sync_heleket_payment_status(
|
||||
db,
|
||||
local_payment_id=local_payment_id,
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
payment = updated_payment
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
status_normalized = (payment.status or "").lower()
|
||||
status_messages = {
|
||||
"check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"),
|
||||
"process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"),
|
||||
"confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"),
|
||||
"wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"),
|
||||
"wrong_amount_waiting": texts.t(
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING",
|
||||
"❗️ Недостаточная сумма, ожидаем доплату",
|
||||
),
|
||||
"paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"),
|
||||
"paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"),
|
||||
"cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"),
|
||||
"fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"),
|
||||
"system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"),
|
||||
"refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"),
|
||||
"refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"),
|
||||
"refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"),
|
||||
"locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"),
|
||||
}
|
||||
|
||||
message = status_messages.get(status_normalized)
|
||||
if message is None:
|
||||
template = texts.t("HELEKET_STATUS_UNKNOWN", "ℹ️ Статус платежа: {status}")
|
||||
status_value = payment.status or status_normalized or "—"
|
||||
try:
|
||||
message = template.format(status=status_value)
|
||||
except Exception: # pragma: no cover - defensive formatting
|
||||
message = f"ℹ️ Статус платежа: {status_value}"
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.poll import (
|
||||
get_poll_response_by_id,
|
||||
record_poll_answer,
|
||||
)
|
||||
from app.database.models import PollQuestion, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
await bot.delete_message(chat_id, message_id)
|
||||
except Exception as error: # pragma: no cover - cleanup best effort
|
||||
logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
|
||||
|
||||
|
||||
async def _render_question_text(
|
||||
poll_title: str,
|
||||
question: PollQuestion,
|
||||
current_index: int,
|
||||
total: int,
|
||||
language: str,
|
||||
) -> str:
|
||||
texts = get_texts(language)
|
||||
header = texts.t("POLL_QUESTION_HEADER", "<b>Вопрос {current}/{total}</b>").format(
|
||||
current=current_index,
|
||||
total=total,
|
||||
)
|
||||
lines = [f"🗳️ <b>{poll_title}</b>", "", header, "", question.text]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _update_poll_message(
|
||||
message: types.Message,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> bool:
|
||||
try:
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return True
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "message is not modified" in error_text:
|
||||
logger.debug(
|
||||
"Опросное сообщение уже актуально, пропускаем обновление: %s",
|
||||
error,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"Не удалось обновить сообщение опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при обновлении сообщения опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for option in sorted(question.options, key=lambda o: o.order):
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=option.text,
|
||||
callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
async def handle_poll_start(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
response_id = int(callback.data.split(":")[1])
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll or not response.poll.questions:
|
||||
await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
index, question = await get_next_question(response)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
|
||||
return
|
||||
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_poll_answer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
_, response_id, question_id, option_id = callback.data.split(":", 3)
|
||||
response_id = int(response_id)
|
||||
question_id = int(question_id)
|
||||
option_id = int(option_id)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
question = next((q for q in response.poll.questions if q.id == question_id), None)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
option = await get_question_option(question, option_id)
|
||||
if not option:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
await record_poll_answer(
|
||||
db,
|
||||
response_id=response.id,
|
||||
question_id=question.id,
|
||||
option_id=option.id,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(response, attribute_names=["answers"])
|
||||
except Exception as error: # pragma: no cover - defensive cache busting
|
||||
logger.debug(
|
||||
"Не удалось обновить локальные ответы опроса %s: %s",
|
||||
response.id,
|
||||
error,
|
||||
)
|
||||
response = await get_poll_response_by_id(db, response.id)
|
||||
if not response:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
index, next_question = await get_next_question(response)
|
||||
|
||||
if next_question:
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
next_question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, next_question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
response.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
reward_amount = await reward_user_for_poll(db, response)
|
||||
|
||||
thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
|
||||
if reward_amount:
|
||||
thanks_lines.append(
|
||||
texts.t(
|
||||
"POLL_REWARD_GRANTED",
|
||||
"Награда {amount} зачислена на ваш баланс.",
|
||||
).format(amount=settings.format_price(reward_amount))
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
"\n\n".join(thanks_lines),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!"))
|
||||
return
|
||||
asyncio.create_task(
|
||||
_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
|
||||
dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))
|
||||
@@ -2,7 +2,7 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from aiogram import types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -17,6 +17,7 @@ from app.services.subscription_purchase_service import SubscriptionPurchaseServi
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.subscription_utils import get_display_subscription_link
|
||||
from app.utils.pricing_utils import compute_simple_subscription_price
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,11 +50,7 @@ async def start_simple_subscription_purchase(
|
||||
|
||||
# Сохраняем параметры в состояние
|
||||
await state.update_data(subscription_params=subscription_params)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
|
||||
data = await state.get_data()
|
||||
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
|
||||
db,
|
||||
@@ -62,39 +59,38 @@ async def start_simple_subscription_purchase(
|
||||
user_id=db_user.id,
|
||||
state_data=data,
|
||||
)
|
||||
period_days = subscription_params["period_days"]
|
||||
recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks)
|
||||
direct_purchase_min_balance = recorded_price
|
||||
extra_components = []
|
||||
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
|
||||
if traffic_limit and traffic_limit > 0:
|
||||
traffic_price = settings.get_traffic_price(traffic_limit)
|
||||
direct_purchase_min_balance += traffic_price
|
||||
extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}")
|
||||
|
||||
device_limit = subscription_params.get("device_limit", 1)
|
||||
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
|
||||
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_price = additional_devices * settings.PRICE_PER_DEVICE
|
||||
direct_purchase_min_balance += devices_price
|
||||
extra_components.append(f"devices+{additional_devices}->{devices_price}")
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s",
|
||||
db_user.id,
|
||||
period_days,
|
||||
price_kopeks,
|
||||
recorded_price,
|
||||
",".join(extra_components) if extra_components else "none",
|
||||
direct_purchase_min_balance,
|
||||
getattr(settings, "PRICE_30_DAYS", None),
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
|
||||
can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance
|
||||
period_days = subscription_params["period_days"]
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s",
|
||||
db_user.id,
|
||||
period_days,
|
||||
price_breakdown.get("base_price", 0),
|
||||
price_breakdown.get("traffic_price", 0),
|
||||
price_breakdown.get("devices_price", 0),
|
||||
price_breakdown.get("servers_price", 0),
|
||||
price_breakdown.get("total_discount", 0),
|
||||
price_kopeks,
|
||||
",".join(price_breakdown.get("resolved_squad_uuids", []))
|
||||
if price_breakdown.get("resolved_squad_uuids")
|
||||
else "none",
|
||||
)
|
||||
|
||||
can_pay_from_balance = user_balance_kopeks >= price_kopeks
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s",
|
||||
db_user.id,
|
||||
user_balance_kopeks,
|
||||
direct_purchase_min_balance,
|
||||
price_kopeks,
|
||||
can_pay_from_balance,
|
||||
)
|
||||
|
||||
@@ -158,27 +154,23 @@ async def start_simple_subscription_purchase(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _calculate_simple_subscription_price(params: dict) -> int:
|
||||
async def _calculate_simple_subscription_price(
|
||||
db: AsyncSession,
|
||||
params: dict,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
resolved_squad_uuid: Optional[str] = None,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Рассчитывает цену простой подписки."""
|
||||
period_days = params.get("period_days", 30)
|
||||
attr_name = f"PRICE_{period_days}_DAYS"
|
||||
attr_value = getattr(settings, attr_name, None)
|
||||
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s",
|
||||
period_days,
|
||||
attr_name,
|
||||
attr_value,
|
||||
settings.BASE_SUBSCRIPTION_PRICE,
|
||||
resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None
|
||||
return await compute_simple_subscription_price(
|
||||
db,
|
||||
params,
|
||||
user=user,
|
||||
resolved_squad_uuids=resolved_uuids,
|
||||
)
|
||||
|
||||
# Получаем цену для стандартного периода
|
||||
if attr_value is not None:
|
||||
return attr_value
|
||||
else:
|
||||
# Если нет цены для конкретного периода, используем базовую цену
|
||||
return settings.BASE_SUBSCRIPTION_PRICE
|
||||
|
||||
|
||||
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру с методами оплаты для простой подписки."""
|
||||
@@ -335,27 +327,22 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
|
||||
total_required = recorded_price
|
||||
extras = []
|
||||
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
|
||||
if traffic_limit and traffic_limit > 0:
|
||||
traffic_price = settings.get_traffic_price(traffic_limit)
|
||||
total_required += traffic_price
|
||||
extras.append(f"traffic={traffic_limit}GB->{traffic_price}")
|
||||
device_limit = subscription_params.get("device_limit", 1)
|
||||
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
|
||||
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_price = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_required += devices_price
|
||||
extras.append(f"devices+{additional_devices}->{devices_price}")
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
total_required = price_kopeks
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s",
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s",
|
||||
db_user.id,
|
||||
subscription_params["period_days"],
|
||||
price_kopeks,
|
||||
",".join(extras) if extras else "none",
|
||||
price_breakdown.get("base_price", 0),
|
||||
price_breakdown.get("traffic_price", 0),
|
||||
price_breakdown.get("devices_price", 0),
|
||||
price_breakdown.get("servers_price", 0),
|
||||
price_breakdown.get("total_discount", 0),
|
||||
total_required,
|
||||
getattr(db_user, "balance_kopeks", 0),
|
||||
)
|
||||
@@ -595,29 +582,38 @@ async def handle_simple_subscription_other_payment_methods(
|
||||
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
|
||||
db,
|
||||
state,
|
||||
subscription_params,
|
||||
user_id=db_user.id,
|
||||
state_data=data,
|
||||
)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
|
||||
total_required = recorded_price
|
||||
if subscription_params.get("traffic_limit_gb", 0) > 0:
|
||||
total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"])
|
||||
if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT:
|
||||
additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT
|
||||
total_required += additional_devices * settings.PRICE_PER_DEVICE
|
||||
can_pay_from_balance = user_balance_kopeks >= total_required
|
||||
can_pay_from_balance = user_balance_kopeks >= price_kopeks
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s",
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s",
|
||||
db_user.id,
|
||||
user_balance_kopeks,
|
||||
price_breakdown.get("base_price", 0),
|
||||
price_breakdown.get("traffic_price", 0),
|
||||
price_breakdown.get("devices_price", 0),
|
||||
price_breakdown.get("servers_price", 0),
|
||||
price_breakdown.get("total_discount", 0),
|
||||
price_kopeks,
|
||||
total_required,
|
||||
can_pay_from_balance,
|
||||
)
|
||||
|
||||
# Отображаем доступные методы оплаты
|
||||
resolved_squad_uuid = data.get("resolved_squad_uuid")
|
||||
server_label = _get_simple_subscription_server_label(
|
||||
texts,
|
||||
subscription_params,
|
||||
@@ -677,14 +673,27 @@ async def handle_simple_subscription_payment_method(
|
||||
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
|
||||
return
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
|
||||
payment_method = callback.data.replace("simple_subscription_", "")
|
||||
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
|
||||
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
|
||||
db,
|
||||
state,
|
||||
subscription_params,
|
||||
user_id=db_user.id,
|
||||
state_data=data,
|
||||
)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks, _ = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
|
||||
if payment_method == "stars":
|
||||
# Оплата через Telegram Stars
|
||||
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
|
||||
@@ -719,7 +728,7 @@ async def handle_simple_subscription_payment_method(
|
||||
|
||||
# Создаем заказ на подписку
|
||||
purchase_service = SubscriptionPurchaseService()
|
||||
|
||||
|
||||
order = await purchase_service.create_subscription_order(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
@@ -64,6 +64,7 @@ from app.states import SubscriptionStates
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
compute_simple_subscription_price,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
@@ -2383,8 +2384,19 @@ async def handle_simple_subscription_purchase(
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
# Если у пользователя уже есть активная подписка, продлеваем её
|
||||
if current_subscription and current_subscription.is_active:
|
||||
await callback.answer("❌ У вас уже есть активная подписка", show_alert=True)
|
||||
# Продлеваем существующую подписку
|
||||
await _extend_existing_subscription(
|
||||
callback=callback,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
current_subscription=current_subscription,
|
||||
period_days=settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
device_limit=settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
)
|
||||
return
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
@@ -2401,7 +2413,22 @@ async def handle_simple_subscription_purchase(
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=subscription_params.get("squad_uuid"),
|
||||
)
|
||||
logger.debug(
|
||||
"SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s",
|
||||
db_user.id,
|
||||
price_kopeks,
|
||||
price_breakdown.get("base_price", 0),
|
||||
price_breakdown.get("traffic_price", 0),
|
||||
price_breakdown.get("devices_price", 0),
|
||||
price_breakdown.get("servers_price", 0),
|
||||
price_breakdown.get("total_discount", 0),
|
||||
)
|
||||
traffic_text = (
|
||||
"Безлимит"
|
||||
if subscription_params["traffic_limit_gb"] == 0
|
||||
@@ -2453,16 +2480,22 @@ async def handle_simple_subscription_purchase(
|
||||
|
||||
|
||||
|
||||
def _calculate_simple_subscription_price(params: dict) -> int:
|
||||
async def _calculate_simple_subscription_price(
|
||||
db: AsyncSession,
|
||||
params: dict,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
resolved_squad_uuid: Optional[str] = None,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Рассчитывает цену простой подписки."""
|
||||
period_days = params.get("period_days", 30)
|
||||
|
||||
# Получаем цену для стандартного периода
|
||||
if hasattr(settings, f'PRICE_{period_days}_DAYS'):
|
||||
return getattr(settings, f'PRICE_{period_days}_DAYS')
|
||||
else:
|
||||
# Если нет цены для конкретного периода, используем базовую цену
|
||||
return settings.BASE_SUBSCRIPTION_PRICE
|
||||
|
||||
resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None
|
||||
return await compute_simple_subscription_price(
|
||||
db,
|
||||
params,
|
||||
user=user,
|
||||
resolved_squad_uuids=resolved_uuids,
|
||||
)
|
||||
|
||||
|
||||
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
@@ -2523,3 +2556,210 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb
|
||||
)])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _extend_existing_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
current_subscription: Subscription,
|
||||
period_days: int,
|
||||
device_limit: int,
|
||||
traffic_limit_gb: int,
|
||||
squad_uuid: str
|
||||
):
|
||||
"""Продлевает существующую подписку."""
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
subscription_params = {
|
||||
"period_days": period_days,
|
||||
"device_limit": device_limit,
|
||||
"traffic_limit_gb": traffic_limit_gb,
|
||||
"squad_uuid": squad_uuid
|
||||
}
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=squad_uuid,
|
||||
)
|
||||
logger.debug(
|
||||
"SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s",
|
||||
db_user.id,
|
||||
price_kopeks,
|
||||
price_breakdown.get("base_price", 0),
|
||||
price_breakdown.get("traffic_price", 0),
|
||||
price_breakdown.get("devices_price", 0),
|
||||
price_breakdown.get("servers_price", 0),
|
||||
price_breakdown.get("total_discount", 0),
|
||||
)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
if db_user.balance_kopeks < price_kopeks:
|
||||
missing_kopeks = price_kopeks - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
"⚠️ <b>Недостаточно средств</b>\n\n"
|
||||
"Стоимость услуги: {required}\n"
|
||||
"На балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\n"
|
||||
"Выберите способ пополнения. Сумма подставится автоматически."
|
||||
),
|
||||
).format(
|
||||
required=texts.format_price(price_kopeks),
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
# Подготовим данные для сохранения в корзину
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
cart_data = {
|
||||
'period_days': period_days,
|
||||
'total_price': price_kopeks,
|
||||
'user_id': db_user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing_kopeks,
|
||||
'return_to_cart': True
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
has_saved_cart=True
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Списываем средства
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
price_kopeks,
|
||||
f"Продление подписки на {period_days} дней",
|
||||
consume_promo_offer=False, # Простая покупка не использует промо-скидки
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
# Обновляем параметры подписки
|
||||
current_time = datetime.utcnow()
|
||||
old_end_date = current_subscription.end_date
|
||||
|
||||
# Обновляем параметры в зависимости от типа текущей подписки
|
||||
if current_subscription.is_trial:
|
||||
# При продлении триальной подписки переводим её в обычную
|
||||
current_subscription.is_trial = False
|
||||
current_subscription.status = "active"
|
||||
# Убираем ограничения с триальной подписки
|
||||
current_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
current_subscription.device_limit = device_limit
|
||||
# Если указан squad_uuid, добавляем его к существующим серверам
|
||||
if squad_uuid and squad_uuid not in current_subscription.connected_squads:
|
||||
# Используем += для безопасного добавления в список SQLAlchemy
|
||||
current_subscription.connected_squads = current_subscription.connected_squads + [squad_uuid]
|
||||
else:
|
||||
# Для обычной подписки просто продлеваем
|
||||
# Обновляем трафик и устройства, если нужно
|
||||
if traffic_limit_gb != 0: # Если не безлимит, обновляем
|
||||
current_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
if device_limit > current_subscription.device_limit:
|
||||
current_subscription.device_limit = device_limit
|
||||
# Если указан squad_uuid и его ещё нет в подписке, добавляем
|
||||
if squad_uuid and squad_uuid not in current_subscription.connected_squads:
|
||||
# Используем += для безопасного добавления в список SQLAlchemy
|
||||
current_subscription.connected_squads = current_subscription.connected_squads + [squad_uuid]
|
||||
|
||||
# Продлеваем подписку
|
||||
if current_subscription.end_date > current_time:
|
||||
# Если подписка ещё активна, добавляем дни к текущей дате окончания
|
||||
new_end_date = current_subscription.end_date + timedelta(days=period_days)
|
||||
else:
|
||||
# Если подписка уже истекла, начинаем от текущего времени
|
||||
new_end_date = current_time + timedelta(days=period_days)
|
||||
|
||||
current_subscription.end_date = new_end_date
|
||||
current_subscription.updated_at = current_time
|
||||
|
||||
# Сохраняем изменения
|
||||
await db.commit()
|
||||
await db.refresh(current_subscription)
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
remnawave_result = await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
current_subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="продление подписки",
|
||||
)
|
||||
if remnawave_result:
|
||||
logger.info("✅ RemnaWave обновлен успешно")
|
||||
else:
|
||||
logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
|
||||
|
||||
# Создаём транзакцию
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=f"Продление подписки на {period_days} дней"
|
||||
)
|
||||
|
||||
# Отправляем уведомление админу
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
db,
|
||||
db_user,
|
||||
current_subscription,
|
||||
transaction,
|
||||
period_days,
|
||||
old_end_date,
|
||||
new_end_date=new_end_date,
|
||||
balance_after=db_user.balance_kopeks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
|
||||
# Отправляем сообщение пользователю
|
||||
success_message = (
|
||||
"✅ Подписка успешно продлена!\n\n"
|
||||
f"⏰ Добавлено: {period_days} дней\n"
|
||||
f"Действует до: {new_end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"💰 Списано: {texts.format_price(price_kopeks)}"
|
||||
)
|
||||
|
||||
# Если это была триальная подписка, добавляем информацию о преобразовании
|
||||
if current_subscription.is_trial:
|
||||
success_message += "\n🎯 Триальная подписка преобразована в платную"
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_message,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {period_days} дней за {price_kopeks / 100}₽")
|
||||
await callback.answer()
|
||||
|
||||
@@ -101,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"),
|
||||
callback_data="admin_polls",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
|
||||
|
||||
@@ -329,7 +329,7 @@ def get_main_menu_keyboard(
|
||||
and getattr(current_subscription, "is_active", False)
|
||||
)
|
||||
simple_purchase_button = None
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED and not has_active_paid_subscription:
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
simple_purchase_button = InlineKeyboardButton(
|
||||
text=texts.MENU_SIMPLE_SUBSCRIPTION,
|
||||
callback_data="simple_subscription_purchase",
|
||||
@@ -346,9 +346,6 @@ def get_main_menu_keyboard(
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
|
||||
)
|
||||
if simple_purchase_button:
|
||||
subscription_buttons.append(simple_purchase_button)
|
||||
simple_purchase_button = None
|
||||
|
||||
if subscription_buttons:
|
||||
paired_buttons.extend(subscription_buttons)
|
||||
|
||||
@@ -977,6 +977,23 @@
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
|
||||
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited",
|
||||
"HELEKET_PAYMENT_SUCCESS": "✅ Payment credited to your balance",
|
||||
"HELEKET_STATUS_CHECK": "⏳ Waiting for payment",
|
||||
"HELEKET_STATUS_PROCESS": "⚙️ Payment is being processed",
|
||||
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Waiting for network confirmations",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Incorrect amount paid",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Not enough amount, waiting for additional payment",
|
||||
"HELEKET_STATUS_PAID_OVER": "✅ Payment credited (overpaid)",
|
||||
"HELEKET_STATUS_PAID": "✅ Payment credited",
|
||||
"HELEKET_STATUS_CANCEL": "🚫 Payment was cancelled",
|
||||
"HELEKET_STATUS_FAIL": "❌ Payment failed",
|
||||
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Heleket system error",
|
||||
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Refund in progress",
|
||||
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Refund failed",
|
||||
"HELEKET_STATUS_REFUND_PAID": "✅ Refund completed",
|
||||
"HELEKET_STATUS_LOCKED": "🔒 Funds are locked",
|
||||
"HELEKET_STATUS_UNKNOWN": "ℹ️ Payment status: {status}",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
@@ -1358,5 +1375,50 @@
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase"
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase",
|
||||
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls",
|
||||
"ADMIN_POLLS_CREATE": "➕ Create poll",
|
||||
"ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}",
|
||||
"ADMIN_POLLS_REWARD_DISABLED": "Reward disabled",
|
||||
"ADMIN_POLLS_SEND": "📤 Send",
|
||||
"ADMIN_POLLS_STATS": "📊 Stats",
|
||||
"ADMIN_POLLS_DELETE": "🗑️ Delete",
|
||||
"ADMIN_POLLS_BACK": "⬅️ Back to list",
|
||||
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters",
|
||||
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send",
|
||||
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Polls</b>",
|
||||
"ADMIN_POLLS_LIST_EMPTY": "No polls yet.",
|
||||
"ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}",
|
||||
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Create poll</b>\n\nEnter poll title:",
|
||||
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.",
|
||||
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}",
|
||||
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.",
|
||||
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.",
|
||||
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.",
|
||||
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.",
|
||||
"ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}",
|
||||
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.",
|
||||
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.",
|
||||
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Questions:</b>",
|
||||
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
|
||||
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:",
|
||||
"ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:",
|
||||
"ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}",
|
||||
"ADMIN_POLLS_SENDING": "📤 Sending poll...",
|
||||
"ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}",
|
||||
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Poll statistics</b>",
|
||||
"ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}",
|
||||
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
|
||||
"ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?",
|
||||
"ADMIN_POLLS_DELETED": "🗑️ Poll deleted.",
|
||||
"POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.",
|
||||
"POLL_INVITATION_START": "Tap the button below to answer the poll.",
|
||||
"POLL_START_BUTTON": "📝 Take the poll",
|
||||
"POLL_QUESTION_HEADER": "<b>Question {current}/{total}</b>",
|
||||
"POLL_ALREADY_COMPLETED": "You have already completed this poll.",
|
||||
"POLL_EMPTY": "Poll is not available yet.",
|
||||
"POLL_ERROR": "Unable to process the poll. Please try again later.",
|
||||
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
|
||||
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance."
|
||||
}
|
||||
|
||||
@@ -977,6 +977,23 @@
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
|
||||
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен",
|
||||
"HELEKET_PAYMENT_SUCCESS": "✅ Платёж зачислен на баланс",
|
||||
"HELEKET_STATUS_CHECK": "⏳ Ожидаем оплату",
|
||||
"HELEKET_STATUS_PROCESS": "⚙️ Платёж обрабатывается",
|
||||
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Ожидание подтверждений сети",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Оплачена неверная сумма",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Недостаточная сумма, ожидаем доплату",
|
||||
"HELEKET_STATUS_PAID_OVER": "✅ Платёж зачислен (с переплатой)",
|
||||
"HELEKET_STATUS_PAID": "✅ Платёж зачислен",
|
||||
"HELEKET_STATUS_CANCEL": "🚫 Платёж отменён",
|
||||
"HELEKET_STATUS_FAIL": "❌ Ошибка при оплате",
|
||||
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Системная ошибка Heleket",
|
||||
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Возврат обрабатывается",
|
||||
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Ошибка возврата",
|
||||
"HELEKET_STATUS_REFUND_PAID": "✅ Возврат выполнен",
|
||||
"HELEKET_STATUS_LOCKED": "🔒 Средства заблокированы",
|
||||
"HELEKET_STATUS_UNKNOWN": "ℹ️ Статус платежа: {status}",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
@@ -1358,5 +1375,50 @@
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка"
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
|
||||
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы",
|
||||
"ADMIN_POLLS_CREATE": "➕ Создать опрос",
|
||||
"ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}",
|
||||
"ADMIN_POLLS_REWARD_DISABLED": "Награда отключена",
|
||||
"ADMIN_POLLS_SEND": "📤 Отправить",
|
||||
"ADMIN_POLLS_STATS": "📊 Статистика",
|
||||
"ADMIN_POLLS_DELETE": "🗑️ Удалить",
|
||||
"ADMIN_POLLS_BACK": "⬅️ К списку",
|
||||
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям",
|
||||
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить",
|
||||
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Опросы</b>",
|
||||
"ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.",
|
||||
"ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}",
|
||||
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
|
||||
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
|
||||
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
|
||||
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
|
||||
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.",
|
||||
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.",
|
||||
"ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}",
|
||||
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.",
|
||||
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.",
|
||||
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Вопросы:</b>",
|
||||
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
|
||||
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:",
|
||||
"ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:",
|
||||
"ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}",
|
||||
"ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...",
|
||||
"ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}",
|
||||
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Статистика опроса</b>",
|
||||
"ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}",
|
||||
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
|
||||
"ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?",
|
||||
"ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.",
|
||||
"POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.",
|
||||
"POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.",
|
||||
"POLL_START_BUTTON": "📝 Пройти опрос",
|
||||
"POLL_QUESTION_HEADER": "<b>Вопрос {current}/{total}</b>",
|
||||
"POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.",
|
||||
"POLL_EMPTY": "Опрос пока недоступен.",
|
||||
"POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
|
||||
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс."
|
||||
}
|
||||
|
||||
@@ -170,14 +170,16 @@ class HeleketPaymentMixin:
|
||||
"discount_percent": discount_percent,
|
||||
}
|
||||
|
||||
async def process_heleket_webhook(
|
||||
async def _process_heleket_payload(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
*,
|
||||
metadata_key: str,
|
||||
) -> Optional["HeleketPayment"]:
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("Heleket webhook payload не является словарём: %s", payload)
|
||||
return False
|
||||
return None
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
@@ -188,7 +190,7 @@ class HeleketPaymentMixin:
|
||||
|
||||
if not uuid and not order_id:
|
||||
logger.error("Heleket webhook без uuid/order_id: %s", payload)
|
||||
return False
|
||||
return None
|
||||
|
||||
payment = None
|
||||
if uuid:
|
||||
@@ -202,7 +204,7 @@ class HeleketPaymentMixin:
|
||||
uuid,
|
||||
order_id,
|
||||
)
|
||||
return False
|
||||
return None
|
||||
|
||||
payer_amount = payload.get("payer_amount") or payload.get("payment_amount")
|
||||
payer_currency = payload.get("payer_currency") or payload.get("currency")
|
||||
@@ -244,11 +246,11 @@ class HeleketPaymentMixin:
|
||||
discount_percent=int(discount_percent) if isinstance(discount_percent, (int, float)) else None,
|
||||
paid_at=paid_at,
|
||||
payment_url=payment_url,
|
||||
metadata={"last_webhook": payload},
|
||||
metadata={metadata_key: payload},
|
||||
)
|
||||
|
||||
if updated_payment is None:
|
||||
return False
|
||||
return None
|
||||
|
||||
if updated_payment.transaction_id:
|
||||
logger.info(
|
||||
@@ -256,17 +258,17 @@ class HeleketPaymentMixin:
|
||||
updated_payment.uuid,
|
||||
updated_payment.transaction_id,
|
||||
)
|
||||
return True
|
||||
return updated_payment
|
||||
|
||||
status_normalized = (status or "").lower()
|
||||
if status_normalized not in {"paid", "paid_over"}:
|
||||
logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status)
|
||||
return True
|
||||
return updated_payment
|
||||
|
||||
amount_kopeks = updated_payment.amount_kopeks
|
||||
if amount_kopeks <= 0:
|
||||
logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount)
|
||||
return False
|
||||
return None
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
@@ -286,13 +288,19 @@ class HeleketPaymentMixin:
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await heleket_crud.link_heleket_payment_to_transaction(db, updated_payment.uuid, transaction.id)
|
||||
linked_payment = await heleket_crud.link_heleket_payment_to_transaction(
|
||||
db,
|
||||
updated_payment.uuid,
|
||||
transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
updated_payment = linked_payment
|
||||
|
||||
get_user_by_id = payment_module.get_user_by_id
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Heleket платежа", updated_payment.user_id)
|
||||
return False
|
||||
return None
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
@@ -374,4 +382,66 @@ class HeleketPaymentMixin:
|
||||
except Exception as error: # pragma: no cover
|
||||
logger.error("Ошибка отправки уведомления пользователю Heleket: %s", error)
|
||||
|
||||
return True
|
||||
return updated_payment
|
||||
|
||||
async def process_heleket_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
result = await self._process_heleket_payload(
|
||||
db,
|
||||
payload,
|
||||
metadata_key="last_webhook",
|
||||
)
|
||||
|
||||
return result is not None
|
||||
|
||||
async def sync_heleket_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
local_payment_id: int,
|
||||
) -> Optional["HeleketPayment"]:
|
||||
if not getattr(self, "heleket_service", None):
|
||||
logger.error("Heleket сервис не инициализирован")
|
||||
return None
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
|
||||
payment = await heleket_crud.get_heleket_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.error("Heleket платеж с id=%s не найден", local_payment_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
response = await self.heleket_service.get_payment_info( # type: ignore[union-attr]
|
||||
uuid=payment.uuid,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Ошибка получения статуса Heleket платежа %s: %s", payment.uuid, error)
|
||||
return payment
|
||||
|
||||
if not response:
|
||||
logger.warning(
|
||||
"Heleket API вернул пустой ответ при проверке платежа %s", payment.uuid
|
||||
)
|
||||
return payment
|
||||
|
||||
result = response.get("result") if isinstance(response, dict) else None
|
||||
if not isinstance(result, dict):
|
||||
logger.error("Некорректный ответ Heleket API при проверке платежа %s: %s", payment.uuid, response)
|
||||
return payment
|
||||
|
||||
payload: Dict[str, Any] = dict(result)
|
||||
payload.setdefault("uuid", payment.uuid)
|
||||
payload.setdefault("order_id", payment.order_id)
|
||||
|
||||
updated_payment = await self._process_heleket_payload(
|
||||
db,
|
||||
payload,
|
||||
metadata_key="last_status_check",
|
||||
)
|
||||
|
||||
return updated_payment or payment
|
||||
|
||||
@@ -246,6 +246,22 @@ class MulenPayPaymentMixin:
|
||||
f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽",
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
@@ -550,13 +550,13 @@ class YooKassaPaymentMixin:
|
||||
payment.status = event_object.get("status", payment.status)
|
||||
payment.confirmation_url = event_object.get("confirmation_url")
|
||||
|
||||
current_paid = getattr(payment, "paid", False)
|
||||
payment.paid = event_object.get("paid", current_paid)
|
||||
current_paid = bool(getattr(payment, "is_paid", getattr(payment, "paid", False)))
|
||||
payment.is_paid = bool(event_object.get("paid", current_paid))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
if payment.status == "succeeded" and payment.paid:
|
||||
if payment.status == "succeeded" and payment.is_paid:
|
||||
return await self._process_successful_yookassa_payment(db, payment)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Iterable
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_poll_invitation_text(poll: Poll, language: str) -> str:
|
||||
texts = get_texts(language)
|
||||
|
||||
lines: list[str] = [f"🗳️ <b>{poll.title}</b>"]
|
||||
if poll.description:
|
||||
lines.append(poll.description)
|
||||
|
||||
if poll.reward_enabled and poll.reward_amount_kopeks > 0:
|
||||
reward_line = texts.t(
|
||||
"POLL_INVITATION_REWARD",
|
||||
"🎁 За участие вы получите {amount}.",
|
||||
).format(amount=settings.format_price(poll.reward_amount_kopeks))
|
||||
lines.append(reward_line)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"POLL_INVITATION_START",
|
||||
"Нажмите кнопку ниже, чтобы пройти опрос.",
|
||||
)
|
||||
)
|
||||
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"),
|
||||
callback_data=f"poll_start:{response_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def send_poll_to_users(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
poll: Poll,
|
||||
users: Iterable[User],
|
||||
) -> dict:
|
||||
sent = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
|
||||
poll_id = poll.id
|
||||
poll_snapshot = SimpleNamespace(
|
||||
title=poll.title,
|
||||
description=poll.description,
|
||||
reward_enabled=poll.reward_enabled,
|
||||
reward_amount_kopeks=poll.reward_amount_kopeks,
|
||||
)
|
||||
|
||||
user_snapshots = [
|
||||
SimpleNamespace(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
language=user.language,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
for index, user in enumerate(user_snapshots, start=1):
|
||||
existing_response = await db.execute(
|
||||
select(PollResponse.id).where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing_response.scalar_one_or_none():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(response)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
|
||||
text = _build_poll_invitation_text(poll_snapshot, user.language)
|
||||
keyboard = build_start_keyboard(response.id, user.language)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
sent += 1
|
||||
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "chat not found" in error_text or "bot was blocked by the user" in error_text:
|
||||
skipped += 1
|
||||
logger.info(
|
||||
"ℹ️ Пропуск пользователя %s при отправке опроса %s: %s",
|
||||
user.telegram_id,
|
||||
poll_id,
|
||||
error,
|
||||
)
|
||||
else: # pragma: no cover - unexpected telegram error
|
||||
failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
|
||||
return {
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"total": sent + failed + skipped,
|
||||
}
|
||||
|
||||
|
||||
async def reward_user_for_poll(
|
||||
db: AsyncSession,
|
||||
response: PollResponse,
|
||||
) -> int:
|
||||
poll = response.poll
|
||||
if not poll.reward_enabled or poll.reward_amount_kopeks <= 0:
|
||||
return 0
|
||||
|
||||
if response.reward_given:
|
||||
return response.reward_amount_kopeks
|
||||
|
||||
user = response.user
|
||||
description = f"Награда за участие в опросе \"{poll.title}\""
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
poll.reward_amount_kopeks,
|
||||
description,
|
||||
transaction_type=TransactionType.POLL_REWARD,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return 0
|
||||
|
||||
response.reward_given = True
|
||||
response.reward_amount_kopeks = poll.reward_amount_kopeks
|
||||
await db.commit()
|
||||
|
||||
return poll.reward_amount_kopeks
|
||||
|
||||
|
||||
async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]:
|
||||
if not response.poll or not response.poll.questions:
|
||||
return None, None
|
||||
|
||||
answered_question_ids = {answer.question_id for answer in response.answers}
|
||||
ordered_questions = sorted(response.poll.questions, key=lambda q: q.order)
|
||||
|
||||
for index, question in enumerate(ordered_questions, start=1):
|
||||
if question.id not in answered_question_ids:
|
||||
return index, question
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None:
|
||||
for option in question.options:
|
||||
if option.id == option_id:
|
||||
return option
|
||||
return None
|
||||
+220
-1
@@ -1,7 +1,14 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Tuple
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from app.database.models import User, PromoGroup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -68,6 +75,218 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
return discounted_amount, discount_value
|
||||
|
||||
|
||||
def resolve_discount_percent(
|
||||
user: Optional["User"],
|
||||
promo_group: Optional["PromoGroup"],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Определяет размер скидки для указанной категории."""
|
||||
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError: # pragma: no cover - defensive guard
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def compute_simple_subscription_price(
|
||||
db: AsyncSession,
|
||||
params: Dict[str, Any],
|
||||
*,
|
||||
user: Optional["User"] = None,
|
||||
resolved_squad_uuids: Optional[Sequence[str]] = None,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Вычисляет стоимость простой подписки с учетом всех доплат и скидок."""
|
||||
|
||||
period_days = int(params.get("period_days", 30) or 30)
|
||||
attr_name = f"PRICE_{period_days}_DAYS"
|
||||
base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE)
|
||||
|
||||
traffic_limit_raw = params.get("traffic_limit_gb")
|
||||
try:
|
||||
traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive conversion
|
||||
traffic_limit = None
|
||||
|
||||
if traffic_limit is None or traffic_limit <= 0:
|
||||
# Default simple subscriptions already include unlimited traffic.
|
||||
traffic_price_original = 0
|
||||
else:
|
||||
traffic_price_original = settings.get_traffic_price(traffic_limit)
|
||||
|
||||
device_limit_raw = params.get("device_limit", settings.DEFAULT_DEVICE_LIMIT)
|
||||
try:
|
||||
device_limit = int(device_limit_raw)
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive conversion
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_original = additional_devices * settings.PRICE_PER_DEVICE
|
||||
|
||||
promo_group: Optional["PromoGroup"] = params.get("promo_group")
|
||||
|
||||
if promo_group is None:
|
||||
promo_group_id = params.get("promo_group_id")
|
||||
if promo_group_id:
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, int(promo_group_id))
|
||||
|
||||
if promo_group is None and user is not None:
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
period_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount = base_price_original * period_discount_percent // 100
|
||||
discounted_base = base_price_original - base_discount
|
||||
|
||||
traffic_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price_original * traffic_discount_percent // 100
|
||||
discounted_traffic = traffic_price_original - traffic_discount
|
||||
|
||||
devices_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price_original * devices_discount_percent // 100
|
||||
discounted_devices = devices_price_original - devices_discount
|
||||
|
||||
servers_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
resolved_uuids: List[str] = []
|
||||
if resolved_squad_uuids:
|
||||
resolved_uuids.extend([uuid for uuid in resolved_squad_uuids if uuid])
|
||||
else:
|
||||
raw_squad = params.get("squad_uuid")
|
||||
if isinstance(raw_squad, (list, tuple, set)):
|
||||
resolved_uuids.extend([str(uuid) for uuid in raw_squad if uuid])
|
||||
elif raw_squad:
|
||||
resolved_uuids.append(str(raw_squad))
|
||||
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
|
||||
server_breakdown: List[Dict[str, Any]] = []
|
||||
servers_price_original = 0
|
||||
servers_discount_total = 0
|
||||
|
||||
for squad_uuid in resolved_uuids:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if not server:
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s",
|
||||
squad_uuid,
|
||||
)
|
||||
server_breakdown.append(
|
||||
{
|
||||
"uuid": squad_uuid,
|
||||
"name": None,
|
||||
"available": False,
|
||||
"original_price": 0,
|
||||
"discount": 0,
|
||||
"final_price": 0,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not server.is_available or server.is_full:
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s",
|
||||
squad_uuid,
|
||||
server.is_available,
|
||||
server.is_full,
|
||||
)
|
||||
server_breakdown.append(
|
||||
{
|
||||
"uuid": squad_uuid,
|
||||
"name": server.display_name,
|
||||
"available": False,
|
||||
"original_price": 0,
|
||||
"discount": 0,
|
||||
"final_price": 0,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
original_price = server.price_kopeks
|
||||
discount_value = original_price * servers_discount_percent // 100
|
||||
final_price = original_price - discount_value
|
||||
|
||||
servers_price_original += original_price
|
||||
servers_discount_total += discount_value
|
||||
|
||||
server_breakdown.append(
|
||||
{
|
||||
"uuid": squad_uuid,
|
||||
"name": server.display_name,
|
||||
"available": True,
|
||||
"original_price": original_price,
|
||||
"discount": discount_value,
|
||||
"final_price": final_price,
|
||||
}
|
||||
)
|
||||
|
||||
total_before_discount = (
|
||||
base_price_original
|
||||
+ traffic_price_original
|
||||
+ devices_price_original
|
||||
+ servers_price_original
|
||||
)
|
||||
|
||||
total_discount = (
|
||||
base_discount
|
||||
+ traffic_discount
|
||||
+ devices_discount
|
||||
+ servers_discount_total
|
||||
)
|
||||
|
||||
total_price = max(0, total_before_discount - total_discount)
|
||||
|
||||
breakdown = {
|
||||
"base_price": base_price_original,
|
||||
"base_discount": base_discount,
|
||||
"traffic_price": traffic_price_original,
|
||||
"traffic_discount": traffic_discount,
|
||||
"devices_price": devices_price_original,
|
||||
"devices_discount": devices_discount,
|
||||
"servers_price": servers_price_original,
|
||||
"servers_discount": servers_discount_total,
|
||||
"servers_final": sum(item["final_price"] for item in server_breakdown),
|
||||
"server_details": server_breakdown,
|
||||
"total_before_discount": total_before_discount,
|
||||
"total_discount": total_discount,
|
||||
"resolved_squad_uuids": resolved_uuids,
|
||||
"applied_promo_group_id": getattr(promo_group, "id", None) if promo_group else None,
|
||||
"period_discount_percent": period_discount_percent,
|
||||
"traffic_discount_percent": traffic_discount_percent,
|
||||
"devices_discount_percent": devices_discount_percent,
|
||||
"servers_discount_percent": servers_discount_percent,
|
||||
}
|
||||
|
||||
return total_price, breakdown
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "9f0f2d5a1c7b"
|
||||
down_revision: Union[str, None] = "8fd1e338eb45"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"polls",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"reward_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"reward_amount_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_polls_id", "polls", ["id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_questions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("poll_id", sa.Integer(), nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"order",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_poll_questions_id", "poll_questions", ["id"])
|
||||
op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_options",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("question_id", sa.Integer(), nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"order",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_poll_options_id", "poll_options", ["id"])
|
||||
op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_responses",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("poll_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"sent_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"reward_given",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"reward_amount_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
|
||||
)
|
||||
op.create_index("ix_poll_responses_id", "poll_responses", ["id"])
|
||||
op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"])
|
||||
op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_answers",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("response_id", sa.Integer(), nullable=False),
|
||||
sa.Column("question_id", sa.Integer(), nullable=False),
|
||||
sa.Column("option_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
|
||||
)
|
||||
op.create_index("ix_poll_answers_id", "poll_answers", ["id"])
|
||||
op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"])
|
||||
op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_poll_answers_question_id", table_name="poll_answers")
|
||||
op.drop_index("ix_poll_answers_response_id", table_name="poll_answers")
|
||||
op.drop_index("ix_poll_answers_id", table_name="poll_answers")
|
||||
op.drop_table("poll_answers")
|
||||
|
||||
op.drop_index("ix_poll_responses_user_id", table_name="poll_responses")
|
||||
op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses")
|
||||
op.drop_index("ix_poll_responses_id", table_name="poll_responses")
|
||||
op.drop_table("poll_responses")
|
||||
|
||||
op.drop_index("ix_poll_options_question_id", table_name="poll_options")
|
||||
op.drop_index("ix_poll_options_id", table_name="poll_options")
|
||||
op.drop_table("poll_options")
|
||||
|
||||
op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions")
|
||||
op.drop_index("ix_poll_questions_id", table_name="poll_questions")
|
||||
op.drop_table("poll_questions")
|
||||
|
||||
op.drop_index("ix_polls_id", table_name="polls")
|
||||
op.drop_table("polls")
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Глобальные фикстуры и настройки окружения для тестов."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
@@ -149,3 +151,50 @@ if "yookassa" not in sys.modules:
|
||||
def fixed_datetime() -> datetime:
|
||||
"""Возвращает фиксированную отметку времени для воспроизводимых проверок."""
|
||||
return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
"""Регистрируем маркеры для асинхронных тестов."""
|
||||
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"asyncio: запуск асинхронного теста через встроенный цикл событий",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"anyio: запуск асинхронного теста через встроенный цикл событий",
|
||||
)
|
||||
|
||||
|
||||
def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функция для определения coroutine
|
||||
"""Возвращает исходную функцию, снимая обёртки pytest и декораторов."""
|
||||
|
||||
unwrapped = obj
|
||||
while hasattr(unwrapped, "__wrapped__"):
|
||||
unwrapped = unwrapped.__wrapped__
|
||||
return unwrapped
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
|
||||
"""Позволяет запускать async def тесты без дополнительных плагинов."""
|
||||
|
||||
test_func = _unwrap_test(pyfuncitem.obj)
|
||||
if not inspect.iscoroutinefunction(test_func):
|
||||
return None
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
signature = inspect.signature(test_func)
|
||||
call_kwargs = {
|
||||
name: value
|
||||
for name, value in pyfuncitem.funcargs.items()
|
||||
if name in signature.parameters
|
||||
}
|
||||
loop.run_until_complete(pyfuncitem.obj(**call_kwargs))
|
||||
finally:
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
return True
|
||||
|
||||
@@ -77,7 +77,7 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert client.calls and client.calls[0]["amount"] == Decimal("500.00")
|
||||
assert client.calls[0]["shop_id"] == "shop42"
|
||||
assert client.calls[0]["description"] == "Пополнение"
|
||||
assert client.calls[0]["custom"] == json.dumps({"extra": "value"}, ensure_ascii=False, separators=(",", ":"))
|
||||
assert client.calls[0]["custom"] == {"extra": "value"}
|
||||
assert client.calls[0]["payment_method"] == "BANK_CARD"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytest
|
||||
@@ -39,14 +40,30 @@ class DummyLocalPayment:
|
||||
|
||||
|
||||
class StubHeleketService:
|
||||
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
response: Optional[Dict[str, Any]],
|
||||
*,
|
||||
info_response: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.response = response
|
||||
self.info_response = info_response
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.info_calls: list[Dict[str, Optional[str]]] = []
|
||||
|
||||
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append(payload)
|
||||
return self.response
|
||||
|
||||
async def get_payment_info(
|
||||
self,
|
||||
*,
|
||||
uuid: Optional[str] = None,
|
||||
order_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
self.info_calls.append({"uuid": uuid, "order_id": order_id})
|
||||
return self.info_response
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubHeleketService]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
@@ -156,3 +173,79 @@ async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest
|
||||
|
||||
assert result is None
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_sync_heleket_payment_status_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
info_response = {
|
||||
"state": 0,
|
||||
"result": {
|
||||
"uuid": "heleket-uuid",
|
||||
"order_id": "order-123",
|
||||
"status": "paid",
|
||||
"payment_amount": "100.00",
|
||||
},
|
||||
}
|
||||
stub = StubHeleketService(response=None, info_response=info_response)
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=55,
|
||||
uuid="heleket-uuid",
|
||||
order_id="order-123",
|
||||
status="check",
|
||||
user_id=7,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
async def fake_process(self, db, payload, *, metadata_key):
|
||||
captured["payload"] = payload
|
||||
captured["metadata_key"] = metadata_key
|
||||
return SimpleNamespace(transaction_id=999, **payload)
|
||||
|
||||
monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
|
||||
monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
|
||||
|
||||
result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.transaction_id == 999
|
||||
assert captured["metadata_key"] == "last_status_check"
|
||||
assert captured["payload"]["uuid"] == payment.uuid
|
||||
assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubHeleketService(response=None, info_response=None)
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=12,
|
||||
uuid="heleket-uuid",
|
||||
order_id="order-123",
|
||||
status="check",
|
||||
user_id=5,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
async def fake_process(*args, **kwargs): # pragma: no cover - ensure not called
|
||||
raise AssertionError("_process_heleket_payload should not be called")
|
||||
|
||||
monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
|
||||
monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
|
||||
|
||||
result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is payment
|
||||
assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]
|
||||
|
||||
@@ -490,6 +490,7 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
|
||||
assert result is True
|
||||
assert transactions and transactions[0]["amount_kopeks"] == 10000
|
||||
assert payment.transaction_id == 999
|
||||
assert payment.is_paid is True
|
||||
assert user.balance_kopeks == 10000
|
||||
assert bot.sent_messages
|
||||
assert admin_calls
|
||||
|
||||
@@ -112,6 +112,7 @@ async def test_delete_user_cart_not_found(user_cart_service):
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_user_cart(user_cart_service, mock_redis):
|
||||
"""Тест проверки наличия корзины пользователя"""
|
||||
|
||||
Reference in New Issue
Block a user