Правки по замечаниям

This commit is contained in:
firewookie
2026-03-09 14:06:00 +05:00
parent 8e53b81b3d
commit be2ec091a6
19 changed files with 222 additions and 109 deletions
+4 -4
View File
@@ -1018,10 +1018,10 @@ async def update_user_subscription(
)
if request.action == 'extend':
if not request.days:
if not request.days or request.days <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Days parameter is required for extend action',
detail='Days must be a positive integer',
)
await extend_subscription(db, subscription, request.days)
@@ -1041,10 +1041,10 @@ async def update_user_subscription(
)
if request.action == 'shorten':
if not request.days:
if not request.days or request.days <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Days parameter is required for shorten action',
detail='Days must be a positive integer',
)
await extend_subscription(db, subscription, -request.days)
+4 -4
View File
@@ -14,6 +14,10 @@ from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.saved_payment_method import (
deactivate_payment_method,
get_active_payment_methods_by_user,
)
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, Transaction, User
from app.services.payment_method_config_service import get_enabled_methods_for_user
@@ -1090,8 +1094,6 @@ async def get_saved_cards(
if not recurrent_enabled:
return SavedCardsListResponse(cards=[], recurrent_enabled=False)
from app.database.crud.saved_payment_method import get_active_payment_methods_by_user
methods = await get_active_payment_methods_by_user(db, user.id)
cards = [
@@ -1122,8 +1124,6 @@ async def delete_saved_card(
detail='Recurrent payments are not enabled',
)
from app.database.crud.saved_payment_method import deactivate_payment_method
success = await deactivate_payment_method(db, card_id, user.id)
if not success:
+4 -7
View File
@@ -3,7 +3,7 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class BalanceResponse(BaseModel):
@@ -26,8 +26,7 @@ class TransactionResponse(BaseModel):
created_at: datetime
completed_at: datetime | None = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class TransactionListResponse(BaseModel):
@@ -114,8 +113,7 @@ class PendingPaymentResponse(BaseModel):
user_telegram_id: int | None = None
user_username: str | None = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PendingPaymentListResponse(BaseModel):
@@ -149,8 +147,7 @@ class SavedCardResponse(BaseModel):
title: str | None = None
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class SavedCardsListResponse(BaseModel):
+1 -1
View File
@@ -356,7 +356,7 @@ class Settings(BaseSettings):
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
YOOKASSA_RECURRENT_ENABLED: bool = False
YOOKASSA_RECURRENT_REQUIRED: bool = True
YOOKASSA_RECURRENT_REQUIRED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
+1 -1
View File
@@ -57,7 +57,7 @@ async def get_commission_payment_count(db: AsyncSession, referrer_id: int, refer
and_(
ReferralEarning.user_id == referrer_id,
ReferralEarning.referral_id == referral_id,
ReferralEarning.reason.in_(['referral_commission_topup', 'referral_first_topup']),
ReferralEarning.reason == 'referral_commission_topup',
)
)
)
+42 -16
View File
@@ -26,28 +26,36 @@ async def create_saved_payment_method(
"""Создаёт или реактивирует сохранённый метод оплаты."""
# Проверяем, есть ли уже такой метод (включая деактивированные)
existing = await get_payment_method_by_yookassa_id(db, yookassa_payment_method_id, include_inactive=True)
if existing:
# Реактивируем и обновляем данные
existing.is_active = True
existing.method_type = method_type
existing.card_first6 = card_first6
existing.card_last4 = card_last4
existing.card_type = card_type
existing.card_expiry_month = card_expiry_month
existing.card_expiry_year = card_expiry_year
existing.title = title
existing.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(existing)
result = await db.execute(
update(SavedPaymentMethod)
.where(
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
SavedPaymentMethod.user_id == user_id,
)
.values(
is_active=True,
method_type=method_type,
card_first6=card_first6,
card_last4=card_last4,
card_type=card_type,
card_expiry_month=card_expiry_month,
card_expiry_year=card_expiry_year,
title=title,
updated_at=datetime.now(UTC),
)
.returning(SavedPaymentMethod)
)
await db.commit()
reactivated = result.scalar_one_or_none()
if reactivated:
logger.info(
'Реактивирован сохранённый метод оплаты',
saved_method_id=existing.id,
saved_method_id=reactivated.id,
user_id=user_id,
method_type=method_type,
card_last4=card_last4,
)
return existing
return reactivated
method = SavedPaymentMethod(
user_id=user_id,
@@ -101,6 +109,24 @@ async def get_active_payment_methods_by_user(
return list(result.scalars().all())
async def get_user_ids_with_active_payment_methods(
db: AsyncSession,
user_ids: list[int],
) -> set[int]:
"""Вернуть подмножество user_ids, у которых есть хотя бы один активный метод оплаты."""
if not user_ids:
return set()
result = await db.execute(
select(SavedPaymentMethod.user_id)
.where(
SavedPaymentMethod.user_id.in_(user_ids),
SavedPaymentMethod.is_active == True, # noqa: E712
)
.distinct()
)
return set(result.scalars().all())
async def get_payment_method_by_yookassa_id(
db: AsyncSession,
yookassa_payment_method_id: str,
+2 -2
View File
@@ -259,8 +259,8 @@ class SavedPaymentMethod(Base):
is_active = Column(Boolean, default=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
updated_at = Column(AwareDateTime(), nullable=False, server_default=func.now(), onupdate=func.now())
user = relationship('User', backref='saved_payment_methods')
+5 -2
View File
@@ -98,8 +98,11 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS > 0:
commission_line = texts.t(
'REFERRAL_REWARD_COMMISSION_LIMITED',
'• Комиссия с пополнений реферала: <b>{percent}%</b>',
).format(percent=get_effective_referral_commission_percent(db_user))
'• Комиссия с первых {max_payments} пополнений реферала: <b>{percent}%</b>',
).format(
percent=get_effective_referral_commission_percent(db_user),
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
)
else:
commission_line = texts.t(
'REFERRAL_REWARD_COMMISSION',
+7 -1
View File
@@ -889,6 +889,12 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
"AUTOPAY_STATUS_DISABLED": "disabled",
"AUTOPAY_STATUS_ENABLED": "enabled",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ Enabled — automatic card charge scheduled",
"AUTOPAY_STATUS_NO_CARD": "✅ Enabled — subscription will renew automatically",
"AUTOPAY_STATUS_OFF": "❌ Disabled — don't forget to renew manually!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 Make sure you have enough balance: {balance}",
"AUTOPAY_ACTION_ENABLE": "💡 Enable autopay or renew your subscription manually",
"AUTOPAY_ACTION_RENEW": "💡 Renew your subscription manually",
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Auto-payment completed</b>\n\nBalance topped up by {amount} for subscription renewal.",
@@ -1310,7 +1316,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> from {referral_name}",
"REFERRAL_REWARDS_HEADER": "🎁 <b>How rewards work:</b>",
"REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Commission from referral top-ups: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Commission from the first {max_payments} referral top-ups: <b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: <b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "• New user receives: <b>{bonus}</b> on the first top-up from <b>{minimum}</b>",
"REFERRAL_SHARE_BUTTON": "📤 Share",
+7 -1
View File
@@ -909,6 +909,12 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ تنظیم روزها",
"AUTOPAY_STATUS_DISABLED": "غیرفعال",
"AUTOPAY_STATUS_ENABLED": "فعال",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ فعال — کارت به صورت خودکار شارژ می‌شود",
"AUTOPAY_STATUS_NO_CARD": "✅ فعال — اشتراک به صورت خودکار تمدید می‌شود",
"AUTOPAY_STATUS_OFF": "❌ غیرفعال — فراموش نکنید دستی تمدید کنید!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 مطمئن شوید موجودی کافی دارید: {balance}",
"AUTOPAY_ACTION_ENABLE": "💡 پرداخت خودکار را فعال کنید یا اشتراک را دستی تمدید کنید",
"AUTOPAY_ACTION_RENEW": "💡 اشتراک را دستی تمدید کنید",
"AUTOPAY_SUCCESS": "\n✅ <b>پرداخت خودکار انجام شد</b>\n\nاشتراک {days} روز تمدید شد.\nکسر: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>پرداخت خودکار انجام شد</b>\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.",
@@ -1331,7 +1337,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> از {referral_name}",
"REFERRAL_REWARDS_HEADER": "🎁 <b>نحوه عملکرد پاداش‌ها:</b>",
"REFERRAL_REWARD_COMMISSION": "• کمیسیون از هر شارژ دعوت‌شده: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• کمیسیون از شارژ دعوت‌شده: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• کمیسیون از {max_payments} شارژ اول دعوت‌شده: <b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "• پاداش اولین شارژ دعوت‌شده: <b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "• کاربر جدید دریافت می‌کند: <b>{bonus}</b> با اولین شارژ از <b>{minimum}</b>",
"REFERRAL_SHARE_BUTTON": "📤 اشتراک‌گذاری",
+7 -1
View File
@@ -909,6 +909,12 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
"AUTOPAY_STATUS_DISABLED": "выключен",
"AUTOPAY_STATUS_ENABLED": "включен",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ Включен — будет автоматическое списание с карты",
"AUTOPAY_STATUS_NO_CARD": "✅ Включен — подписка продлится автоматически",
"AUTOPAY_STATUS_OFF": "❌ Отключен — не забудьте продлить вручную!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 Убедитесь, что на балансе достаточно средств: {balance}",
"AUTOPAY_ACTION_ENABLE": "💡 Включите автоплатеж или продлите подписку вручную",
"AUTOPAY_ACTION_RENEW": "💡 Продлите подписку вручную",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.",
@@ -1331,7 +1337,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> от {referral_name}",
"REFERRAL_REWARDS_HEADER": "🎁 <b>Как работают награды:</b>",
"REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Комиссия с пополнений реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Комиссия с первых {max_payments} пополнений реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
"REFERRAL_SHARE_BUTTON": "📤 Поделиться",
+7 -1
View File
@@ -831,6 +831,12 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Налаштувати дні",
"AUTOPAY_STATUS_DISABLED": "вимкнено",
"AUTOPAY_STATUS_ENABLED": "увімкнено",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ Увімкнено — буде автоматичне списання з картки",
"AUTOPAY_STATUS_NO_CARD": "✅ Увімкнено — підписка продовжиться автоматично",
"AUTOPAY_STATUS_OFF": "❌ Вимкнено — не забудьте продовжити вручну!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 Переконайтеся, що на балансі достатньо коштів: {balance}",
"AUTOPAY_ACTION_ENABLE": "💡 Увімкніть автоплатіж або продовжіть підписку вручну",
"AUTOPAY_ACTION_RENEW": "💡 Продовжіть підписку вручну",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатіж виконано</b>\n\nВашу підписку автоматично продовжено на {days} днів.\nСписано з балансу: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатіж виконано</b>\n\nБаланс поповнено на {amount} для продовження підписки.",
@@ -1247,7 +1253,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> від {referral_name}",
"REFERRAL_REWARDS_HEADER": "🎁 <b>Як працюють нагороди:</b>",
"REFERRAL_REWARD_COMMISSION": "• Комісія з кожного поповнення реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Комісія з поповнень реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "• Комісія з перших {max_payments} поповнень реферала: <b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "• Ви отримуєте при першому поповненні реферала: <b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "• Новий користувач отримує: <b>{bonus}</b> при першому поповненні від <b>{minimum}</b>",
"REFERRAL_SHARE_BUTTON": "📤 Поділитися",
+7 -1
View File
@@ -829,6 +829,12 @@
"AUTOPAY_SET_DAYS_BUTTON": "⚙️设置天数",
"AUTOPAY_STATUS_DISABLED": "已禁用",
"AUTOPAY_STATUS_ENABLED": "已启用",
"AUTOPAY_STATUS_CARD_ACTIVE": "✅ 已启用 — 将自动从银行卡扣款",
"AUTOPAY_STATUS_NO_CARD": "✅ 已启用 — 订阅将自动续订",
"AUTOPAY_STATUS_OFF": "❌ 已禁用 — 请别忘了手动续订!",
"AUTOPAY_ACTION_CHECK_BALANCE": "💰 请确保余额充足:{balance}",
"AUTOPAY_ACTION_ENABLE": "💡 请启用自动支付或手动续订",
"AUTOPAY_ACTION_RENEW": "💡 请手动续订",
"AUTOPAY_SUCCESS": "\n✅<b>自动支付成功</b>\n\n您的订阅已自动延长{days}天。\n已从余额扣除:{amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>自动扣款成功</b>\n\n余额已充值{amount},用于续订订阅。",
@@ -1245,7 +1251,7 @@
"REFERRAL_RECENT_EARNINGS_ITEM": "•{reason}:<b>{amount}</b>(来自{referral_name})",
"REFERRAL_REWARDS_HEADER": "🎁<b>奖励如何运作:</b>",
"REFERRAL_REWARD_COMMISSION": "•每次推荐充值的佣金:<b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "•推荐充值佣金:<b>{percent}%</b>",
"REFERRAL_REWARD_COMMISSION_LIMITED": "•前{max_payments}次推荐充值佣金:<b>{percent}%</b>",
"REFERRAL_REWARD_INVITER": "•推荐首次充值时您将获得:<b>{bonus}</b>",
"REFERRAL_REWARD_NEW_USER": "•新用户首次充值<b>{minimum}</b>起将获得:<b>{bonus}</b>",
"REFERRAL_SHARE_BUTTON": "📤分享",
+63 -32
View File
@@ -420,6 +420,15 @@ class MonitoringService:
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
sent_count = 0
# Batch-запрос: собираем user_id с autopay и проверяем наличие карт одним запросом
users_with_cards: set[int] = set()
if settings.ENABLE_AUTOPAY and settings.YOOKASSA_RECURRENT_ENABLED:
autopay_user_ids = [s.user_id for s in expiring_subscriptions if s.autopay_enabled]
if autopay_user_ids:
from app.database.crud.saved_payment_method import get_user_ids_with_active_payment_methods
users_with_cards = await get_user_ids_with_active_payment_methods(db, autopay_user_ids)
for subscription in expiring_subscriptions:
user = await get_user_by_id(db, subscription.user_id)
if not user:
@@ -440,18 +449,7 @@ class MonitoringService:
)
continue
# Пропускаем уведомление если autopay + рекуррентные платежи с карты настроены
if subscription.autopay_enabled and settings.ENABLE_AUTOPAY and settings.YOOKASSA_RECURRENT_ENABLED:
from app.database.crud.saved_payment_method import get_active_payment_methods_by_user
saved_methods = await get_active_payment_methods_by_user(db, user.id)
if saved_methods:
logger.debug(
'Пропускаем уведомление об истечении: autopay + сохранённая карта',
user_identifier=user_identifier,
days=days,
)
continue
has_saved_card = subscription.autopay_enabled and user.id in users_with_cards
should_send = True
for other_days in warning_days:
@@ -489,7 +487,9 @@ class MonitoringService:
continue
if self.bot:
success = await self._send_subscription_expiring_notification(user, subscription, days)
success = await self._send_subscription_expiring_notification(
user, subscription, days, has_saved_card=has_saved_card
)
if success:
await record_notification(db, user.id, subscription.id, 'expiring', days)
all_processed_users.add(user_key)
@@ -979,7 +979,7 @@ class MonitoringService:
# Берём ACTIVE + недавно EXPIRED (middleware или check_and_update могли
# экспайрить до того, как monitoring успел запустить autopay)
recently_expired_threshold = current_time - timedelta(hours=48)
recently_expired_threshold = current_time - timedelta(hours=2)
result = await db.execute(
select(Subscription)
.options(
@@ -1283,7 +1283,9 @@ class MonitoringService:
)
return False
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
async def _send_subscription_expiring_notification(
self, user: User, subscription: Subscription, days: int, *, has_saved_card: bool = False
) -> bool:
try:
from app.utils.formatters import format_days_declension
@@ -1291,27 +1293,56 @@ class MonitoringService:
days_text = format_days_declension(days, user.language)
if settings.ENABLE_AUTOPAY:
if subscription.autopay_enabled:
autopay_status = '✅ Включен - подписка продлится автоматически'
action_text = (
f'💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}'
if subscription.autopay_enabled and has_saved_card:
autopay_status = texts.t(
'AUTOPAY_STATUS_CARD_ACTIVE',
'✅ Включен — будет автоматическое списание с карты',
)
action_text = texts.t(
'AUTOPAY_ACTION_CHECK_BALANCE',
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
).format(balance=texts.format_price(user.balance_kopeks))
elif subscription.autopay_enabled:
autopay_status = texts.t(
'AUTOPAY_STATUS_NO_CARD',
'✅ Включен — подписка продлится автоматически',
)
action_text = texts.t(
'AUTOPAY_ACTION_CHECK_BALANCE',
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
).format(balance=texts.format_price(user.balance_kopeks))
else:
autopay_status = '❌ Отключен - не забудьте продлить вручную!'
action_text = '💡 Включите автоплатеж или продлите подписку вручную'
autopay_status = texts.t(
'AUTOPAY_STATUS_OFF',
'❌ Отключен — не забудьте продлить вручную!',
)
action_text = texts.t(
'AUTOPAY_ACTION_ENABLE',
'💡 Включите автоплатеж или продлите подписку вручную',
)
else:
autopay_status = '❌ Отключен - не забудьте продлить вручную!'
action_text = '💡 Продлите подписку вручную'
autopay_status = texts.t(
'AUTOPAY_STATUS_OFF',
'❌ Отключен — не забудьте продлить вручную!',
)
action_text = texts.t(
'AUTOPAY_ACTION_RENEW',
'💡 Продлите подписку вручную',
)
message = f"""
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
end_date = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')
message = texts.t(
'SUBSCRIPTION_EXPIRING_PAID',
'\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\n'
'Ваша платная подписка истекает {end_date}.\n\n'
'💳 <b>Автоплатеж:</b> {autopay_status}\n\n'
'{action_text}\n',
).format(
days_text=days_text,
end_date=end_date,
autopay_status=autopay_status,
action_text=action_text,
)
from aiogram.types import InlineKeyboardMarkup
+4 -2
View File
@@ -1114,13 +1114,15 @@ class YooKassaPaymentMixin:
get_payment_method_by_yookassa_id,
)
# Проверяем, не сохранён ли уже
existing = await get_payment_method_by_yookassa_id(db, pm_id)
# Проверяем, не сохранён ли уже (включая деактивированные —
# если пользователь удалил карту, не реактивируем её)
existing = await get_payment_method_by_yookassa_id(db, pm_id, include_inactive=True)
if existing:
logger.debug(
'Метод оплаты уже сохранён',
yookassa_payment_method_id=pm_id,
user_id=payment.user_id,
is_active=existing.is_active,
)
return
+26 -5
View File
@@ -64,6 +64,7 @@ async def process_recurrent_payments(bot: Bot | None = None) -> dict:
'checked': 0,
'payments_created': 0,
'insufficient_no_card': 0,
'all_cards_failed': 0,
'already_processed': 0,
'errors': 0,
}
@@ -91,6 +92,8 @@ async def process_recurrent_payments(bot: Bot | None = None) -> dict:
_processed_today.add(guard_key)
elif result == 'no_card':
stats['insufficient_no_card'] += 1
elif result == 'all_cards_failed':
stats['all_cards_failed'] += 1
elif result == 'skipped':
stats['already_processed'] += 1
except Exception as e:
@@ -234,13 +237,17 @@ async def _process_single_subscription(
}
# Перебираем все сохранённые карты пока не найдём рабочую
today = datetime.now(UTC).strftime('%Y-%m-%d')
for saved_method in saved_methods:
# Детерминированный ключ: при рестарте/повторе YooKassa вернёт тот же платёж
idem_key = f'recurrent_{subscription.id}_{saved_method.id}_{today}'
result = await yookassa_service.create_autopayment(
amount=topup_amount_rubles,
currency='RUB',
description=description,
payment_method_id=saved_method.yookassa_payment_method_id,
metadata=metadata,
idempotence_key=idem_key,
)
if not result:
@@ -254,14 +261,28 @@ async def _process_single_subscription(
)
continue
# Успешно — создаём локальную запись платежа
# Успешно — сохраняем локальную запись с привязкой к YooKassa ID
try:
result_payment = await payment_service.create_yookassa_payment(
from app.database.crud.yookassa import create_yookassa_payment
yookassa_created_at = None
if result.get('created_at'):
try:
yookassa_created_at = datetime.fromisoformat(result['created_at'].replace('Z', '+00:00'))
except Exception:
pass
result_payment = await create_yookassa_payment(
db=db,
user_id=user.id,
yookassa_payment_id=result['id'],
amount_kopeks=topup_amount_kopeks,
currency='RUB',
description=description,
metadata=metadata,
status=result.get('status', 'pending'),
metadata_json=metadata,
yookassa_created_at=yookassa_created_at,
test_mode=result.get('test_mode', False),
)
if result_payment:
logger.info(
@@ -269,7 +290,7 @@ async def _process_single_subscription(
user_id=user.id,
subscription_id=subscription.id,
amount_kopeks=topup_amount_kopeks,
yookassa_payment_id=result.get('id'),
yookassa_payment_id=result['id'],
)
except Exception as e:
logger.warning('Ошибка создания локальной записи рекуррентного платежа', error=e)
@@ -342,4 +363,4 @@ async def _process_single_subscription(
except Exception as notify_error:
logger.warning('Ошибка уведомления о неудачном автоплатеже', notify_error=notify_error)
return 'skipped'
return 'all_cards_failed'
+21 -22
View File
@@ -16,6 +16,23 @@ from app.utils.user_utils import get_effective_referral_commission_percent
logger = structlog.get_logger(__name__)
async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool:
"""Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал."""
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0:
return False
paid_count = await get_commission_payment_count(db, referrer_id, referral_id)
if paid_count >= settings.REFERRAL_MAX_COMMISSION_PAYMENTS:
logger.info(
'Лимит комиссионных платежей исчерпан',
referrer_id=referrer_id,
referral_id=referral_id,
paid_count=paid_count,
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
)
return True
return False
async def send_referral_notification(
bot: Bot,
telegram_id: int | None,
@@ -178,17 +195,8 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
topup_amount_kopeks=topup_amount_kopeks / 100,
)
if commission_amount > 0 and settings.REFERRAL_MAX_COMMISSION_PAYMENTS > 0:
paid_count = await get_commission_payment_count(db, referrer.id, user.id)
if paid_count >= settings.REFERRAL_MAX_COMMISSION_PAYMENTS:
logger.info(
'Лимит комиссионных платежей исчерпан',
referrer_id=referrer.id,
referral_id=user.id,
paid_count=paid_count,
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
)
return True
if commission_amount > 0 and await _is_commission_limit_reached(db, referrer.id, user.id):
return True
if commission_amount > 0:
balance_ok = await add_user_balance(
@@ -346,17 +354,8 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
)
elif commission_amount > 0:
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS > 0:
paid_count = await get_commission_payment_count(db, referrer.id, user.id)
if paid_count >= settings.REFERRAL_MAX_COMMISSION_PAYMENTS:
logger.info(
'Лимит комиссионных платежей исчерпан',
referrer_id=referrer.id,
referral_id=user.id,
paid_count=paid_count,
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
)
return True
if await _is_commission_limit_reached(db, referrer.id, user.id):
return True
balance_ok = await add_user_balance(
db,
+7 -3
View File
@@ -1,5 +1,6 @@
import asyncio
import uuid
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -357,6 +358,7 @@ class YooKassaService:
metadata: dict[str, Any],
receipt_email: str | None = None,
receipt_phone: str | None = None,
idempotence_key: str | None = None,
) -> dict[str, Any] | None:
"""Создаёт рекуррентный автоплатёж через сохранённый payment_method_id (без confirmation)."""
@@ -398,11 +400,13 @@ class YooKassaService:
receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
if not idempotence_key:
sub_id = metadata.get('subscription_id', uuid.uuid4())
idempotence_key = f'autopay_{sub_id}_{datetime.now(UTC).strftime("%Y-%m-%d")}'
payment_request = builder.build()
logger.info(
'Создание автоплатежа YooKassa. Сумма: . payment_method_id: . Метаданные: ',
'Создание автоплатежа YooKassa',
amount=amount,
currency=currency,
payment_method_id=payment_method_id,
@@ -416,7 +420,7 @@ class YooKassaService:
)
logger.info(
'Ответ YooKassa автоплатёж: ID=, Status=, Paid',
'Ответ YooKassa автоплатёж',
response_id=response.id,
status=response.status,
paid=response.paid,
@@ -36,9 +36,9 @@ def upgrade() -> None:
sa.Column('card_expiry_month', sa.String(2), nullable=True),
sa.Column('card_expiry_year', sa.String(4), nullable=True),
sa.Column('title', sa.String(255), nullable=True),
sa.Column('is_active', sa.Boolean(), server_default=sa.text('1'), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('is_active', sa.Boolean(), server_default=sa.true_(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)