Правки по замечаниям
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "📤 اشتراکگذاری",
|
||||
|
||||
@@ -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": "📤 Поделиться",
|
||||
|
||||
@@ -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": "📤 Поділитися",
|
||||
|
||||
@@ -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": "📤分享",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user