diff --git a/Dockerfile.local b/Dockerfile.local index c6012a51..af741099 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -15,6 +15,8 @@ RUN pip install --no-cache-dir uv && uv sync --frozen ENV UVICORN_RELOAD_ENABLED=true ENV PATH="/opt/remnashop/.venv/bin:$PATH" -RUN chmod +x ./docker-entrypoint.sh +RUN sed -i 's/\r$//' ./docker-entrypoint.sh && \ + chmod +x ./docker-entrypoint.sh && \ + ls -la ./docker-entrypoint.sh -CMD ["./docker-entrypoint.sh"] \ No newline at end of file +CMD ["/bin/sh", "./docker-entrypoint.sh"] \ No newline at end of file diff --git a/assets/translations/ru/buttons.ftl b/assets/translations/ru/buttons.ftl index cd4a5edd..bea53999 100644 --- a/assets/translations/ru/buttons.ftl +++ b/assets/translations/ru/buttons.ftl @@ -282,6 +282,11 @@ btn-gateway = *[0] 🔴 Выключено } + .yookassa-request-email = { $request_email -> + [1] 🟢 Запрашивать почту + *[0] 🔴 Запрашивать почту + } + .default-currency-choice = { $enabled -> [1] 🔘 *[0] ⚪ @@ -463,6 +468,8 @@ btn-subscription = .back-plans = ⬅️ Назад к выбору плана .back-duration = ⬅️ Изменить длительность .back-payment-method = ⬅️ Изменить способ оплаты + .back-yookassa-email = ⬅️ Назад к вводу почты + .skip-email = ⏭️ Пропустить .connect = 🚀 Подключиться .duration = { $period } | { $final_amount -> diff --git a/assets/translations/ru/messages.ftl b/assets/translations/ru/messages.ftl index 572e9023..1b1492c2 100644 --- a/assets/translations/ru/messages.ftl +++ b/assets/translations/ru/messages.ftl @@ -1151,6 +1151,11 @@ msg-subscription-payment-method = { msg-subscription-details } +msg-subscription-yookassa-email = + ✉️ Укажите почту для чека + + { msg-subscription-details } + msg-subscription-confirm = 🛒 Подтверждение { $purchase_type -> [RENEW] продления diff --git a/assets/translations/ru/notifications.ftl b/assets/translations/ru/notifications.ftl index f295944b..82c9dc9a 100644 --- a/assets/translations/ru/notifications.ftl +++ b/assets/translations/ru/notifications.ftl @@ -114,6 +114,7 @@ ntf-subscription = .gateways-unavailable = ❌ В данный момент нет доступных платежных систем. .renew-plan-unavailable = ❌ Текущий план устарел и недоступен для продления. .payment-creation-failed = ❌ Ошибка при создании платежа. Попробуйте позже. + .email-invalid = ❌ Введена некорректная почта ntf-broadcast = .message = { $content } diff --git a/src/application/dto/payment_gateway.py b/src/application/dto/payment_gateway.py index d1403369..9b6bbec3 100644 --- a/src/application/dto/payment_gateway.py +++ b/src/application/dto/payment_gateway.py @@ -61,6 +61,17 @@ class YooKassaGatewaySettingsDto(GatewaySettingsDto): api_key: Optional[SecretStr] = None customer: Optional[str] = None vat_code: Optional[int] = None + request_email: bool = False + + @property + def as_list(self) -> list[dict[str, Any]]: + return [ + {"field": f.name, "value": getattr(self, f.name)} + for f in fields(self) + if f.name + not in {"type", "created_at", "updated_at", "request_email"} + and not f.name.startswith("_") + ] @dataclass(kw_only=True) diff --git a/src/application/use_cases/gateways/__init__.py b/src/application/use_cases/gateways/__init__.py index e06233a8..cab7323b 100644 --- a/src/application/use_cases/gateways/__init__.py +++ b/src/application/use_cases/gateways/__init__.py @@ -5,6 +5,7 @@ from .commands.configuration import ( MovePaymentGatewayUp, TogglePaymentGatewayActive, + ToggleYooKassaRequestEmail, UpdatePaymentGatewaySettings, ) from .commands.payment import ( @@ -19,6 +20,7 @@ GetPaymentGatewayInstance, MovePaymentGatewayUp, TogglePaymentGatewayActive, + ToggleYooKassaRequestEmail, UpdatePaymentGatewaySettings, CreateDefaultPaymentGateway, CreatePayment, diff --git a/src/application/use_cases/gateways/commands/configuration.py b/src/application/use_cases/gateways/commands/configuration.py index 6af093b7..6ed454b5 100644 --- a/src/application/use_cases/gateways/commands/configuration.py +++ b/src/application/use_cases/gateways/commands/configuration.py @@ -8,6 +8,7 @@ from src.application.common.policy import Permission from src.application.common.uow import UnitOfWork from src.application.dto import UserDto +from src.application.dto.payment_gateway import YooKassaGatewaySettingsDto from src.core.exceptions import GatewayNotConfiguredError @@ -82,6 +83,34 @@ async def _execute(self, actor: UserDto, gateway_id: int) -> None: ) +class ToggleYooKassaRequestEmail(Interactor[int, None]): + required_permission = Permission.REMNASHOP_GATEWAYS + + def __init__(self, uow: UnitOfWork, gateway_dao: PaymentGatewayDao) -> None: + self.uow = uow + self.gateway_dao = gateway_dao + + async def _execute(self, actor: UserDto, gateway_id: int) -> None: + async with self.uow: + gateway = await self.gateway_dao.get_by_id(gateway_id) + + if not gateway: + raise ValueError(f"Payment gateway with id '{gateway_id}' not found") + + if not isinstance(gateway.settings, YooKassaGatewaySettingsDto): + raise ValueError(f"Gateway '{gateway_id}' is not YooKassa") + + gateway.settings.request_email = not gateway.settings.request_email + + await self.gateway_dao.update(gateway) + await self.uow.commit() + + logger.info( + f"{actor.log} Toggled YooKassa request_email for gateway '{gateway_id}' " + f"to '{gateway.settings.request_email}'" + ) + + @dataclass(frozen=True) class UpdatePaymentGatewaySettingsDto: gateway_id: int diff --git a/src/application/use_cases/gateways/commands/payment.py b/src/application/use_cases/gateways/commands/payment.py index b73a284c..3e7d9ae9 100644 --- a/src/application/use_cases/gateways/commands/payment.py +++ b/src/application/use_cases/gateways/commands/payment.py @@ -1,5 +1,6 @@ import uuid from dataclasses import dataclass +from typing import Optional from uuid import UUID from loguru import logger @@ -111,6 +112,7 @@ class CreatePaymentDto: pricing: PriceDetailsDto purchase_type: PurchaseType gateway_type: PaymentGatewayType + receipt_email: Optional[str] = None class CreatePayment(Interactor[CreatePaymentDto, PaymentResultDto]): @@ -166,6 +168,7 @@ async def _execute(self, actor: UserDto, data: CreatePaymentDto) -> PaymentResul payment: PaymentResultDto = await gateway_instance.handle_create_payment( amount=data.pricing.final_amount, details=details, + receipt_email=data.receipt_email, ) transaction.payment_id = payment.id diff --git a/src/core/utils/email.py b/src/core/utils/email.py new file mode 100644 index 00000000..608d3cb3 --- /dev/null +++ b/src/core/utils/email.py @@ -0,0 +1,12 @@ +import re + +_EMAIL_PATTERN = re.compile( + r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+" +) + + +def is_valid_email(value: str) -> bool: + s = value.strip() + if not s or len(s) > 254: + return False + return bool(_EMAIL_PATTERN.fullmatch(s)) diff --git a/src/infrastructure/payment_gateways/base.py b/src/infrastructure/payment_gateways/base.py index 5f702437..a998dfd6 100644 --- a/src/infrastructure/payment_gateways/base.py +++ b/src/infrastructure/payment_gateways/base.py @@ -38,7 +38,12 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N logger.debug(f"{self.__class__.__name__} Initialized") @abstractmethod - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: ... + async def handle_create_payment( + self, + amount: Decimal, + details: str, + receipt_email: Optional[str] = None, + ) -> PaymentResultDto: ... @abstractmethod async def handle_webhook(self, request: Request) -> tuple[UUID, TransactionStatus]: ... diff --git a/src/infrastructure/payment_gateways/cryptomus.py b/src/infrastructure/payment_gateways/cryptomus.py index 9ca95e44..c4fd75e3 100644 --- a/src/infrastructure/payment_gateways/cryptomus.py +++ b/src/infrastructure/payment_gateways/cryptomus.py @@ -4,7 +4,7 @@ import uuid from decimal import Decimal from hmac import compare_digest -from typing import Any +from typing import Any, Optional from uuid import UUID import orjson @@ -49,7 +49,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N headers={"merchant": self.data.settings.merchant_id}, # type: ignore[dict-item] ) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: payload = await self._create_payment_payload(str(amount), str(uuid.uuid4())) headers = {"sign": self._generate_signature(json.dumps(payload))} logger.debug(f"Creating payment payload: {payload}") diff --git a/src/infrastructure/payment_gateways/cryptopay.py b/src/infrastructure/payment_gateways/cryptopay.py index 61a530c7..7bbf4cf6 100644 --- a/src/infrastructure/payment_gateways/cryptopay.py +++ b/src/infrastructure/payment_gateways/cryptopay.py @@ -2,7 +2,7 @@ import hmac import uuid from decimal import Decimal -from typing import Any, Final +from typing import Any, Final, Optional from uuid import UUID import orjson @@ -39,7 +39,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N headers={"Crypto-Pay-API-Token": self.data.settings.api_key.get_secret_value()}, # type: ignore[union-attr] ) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: payload = await self._create_payment_payload(str(amount), details) logger.debug(f"Creating payment payload: {payload}") diff --git a/src/infrastructure/payment_gateways/freekassa.py b/src/infrastructure/payment_gateways/freekassa.py index 86a4ce07..88ed8b5a 100644 --- a/src/infrastructure/payment_gateways/freekassa.py +++ b/src/infrastructure/payment_gateways/freekassa.py @@ -3,7 +3,7 @@ import time import uuid from decimal import Decimal -from typing import Any, Final +from typing import Any, Final, Optional from uuid import UUID import orjson @@ -45,7 +45,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N self._client = self._make_client(base_url=self.API_BASE) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: order_id = str(uuid.uuid4()) payload = await self._create_payment_payload(str(amount), order_id) logger.debug(f"Creating payment payload: {payload}") diff --git a/src/infrastructure/payment_gateways/mulen_pay.py b/src/infrastructure/payment_gateways/mulen_pay.py index f65564be..f55d6a1e 100644 --- a/src/infrastructure/payment_gateways/mulen_pay.py +++ b/src/infrastructure/payment_gateways/mulen_pay.py @@ -2,7 +2,7 @@ import uuid from decimal import Decimal from hmac import compare_digest -from typing import Any, Final +from typing import Any, Final, Optional from uuid import UUID import orjson @@ -44,7 +44,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N }, ) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: order_uuid = str(uuid.uuid4()) payload = self._create_payment_payload(amount, details, order_uuid) logger.debug(f"Creating payment payload: {payload}") diff --git a/src/infrastructure/payment_gateways/pay_master.py b/src/infrastructure/payment_gateways/pay_master.py index 663a8a9e..fb4ff34f 100644 --- a/src/infrastructure/payment_gateways/pay_master.py +++ b/src/infrastructure/payment_gateways/pay_master.py @@ -1,6 +1,6 @@ import uuid from decimal import Decimal -from typing import Any, Final +from typing import Any, Final, Optional from uuid import UUID import orjson @@ -40,7 +40,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N }, ) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: payload = await self._create_payment_payload(str(amount), details) headers = {"Idempotency-Key": str(uuid.uuid4())} logger.debug(f"Creating payment payload: {payload}") diff --git a/src/infrastructure/payment_gateways/platega.py b/src/infrastructure/payment_gateways/platega.py index abd7582a..0efc2592 100644 --- a/src/infrastructure/payment_gateways/platega.py +++ b/src/infrastructure/payment_gateways/platega.py @@ -1,6 +1,6 @@ import hmac from decimal import Decimal -from typing import Any, Final +from typing import Any, Final, Optional from uuid import UUID import orjson @@ -42,7 +42,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N }, ) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: payload = await self._create_payment_payload(amount, details) logger.debug(f"Creating payment payload: {payload}") diff --git a/src/infrastructure/payment_gateways/robokassa.py b/src/infrastructure/payment_gateways/robokassa.py index e9ac1599..157bc751 100644 --- a/src/infrastructure/payment_gateways/robokassa.py +++ b/src/infrastructure/payment_gateways/robokassa.py @@ -2,7 +2,7 @@ import uuid from decimal import Decimal from hmac import compare_digest -from typing import Any, Final +from typing import Any, Final, Optional from urllib.parse import parse_qs, urlencode from uuid import UUID @@ -34,7 +34,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N f"got {type(self.data.settings).__name__}" ) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: order_id = uuid.uuid4() inv_id = 0 out_sum = self._format_amount(amount) diff --git a/src/infrastructure/payment_gateways/telegram_stars.py b/src/infrastructure/payment_gateways/telegram_stars.py index 099ade5f..75075eb7 100644 --- a/src/infrastructure/payment_gateways/telegram_stars.py +++ b/src/infrastructure/payment_gateways/telegram_stars.py @@ -1,5 +1,6 @@ import uuid from decimal import Decimal +from typing import Optional from uuid import UUID from aiogram.types import LabeledPrice @@ -14,7 +15,9 @@ # https://core.telegram.org/api/stars/ class TelegramStarsGateway(BasePaymentGateway): - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: prices = [LabeledPrice(label=self.data.currency, amount=int(amount))] payment_id = uuid.uuid4() diff --git a/src/infrastructure/payment_gateways/url_pay.py b/src/infrastructure/payment_gateways/url_pay.py index 2582b907..de8e9bc4 100644 --- a/src/infrastructure/payment_gateways/url_pay.py +++ b/src/infrastructure/payment_gateways/url_pay.py @@ -2,7 +2,7 @@ import hmac import uuid from decimal import Decimal -from typing import Any, Final +from typing import Any, Final, Optional from uuid import UUID import orjson @@ -44,7 +44,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N }, ) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: order_uuid = str(uuid.uuid4()) payload = self._create_payment_payload(str(amount), details, order_uuid) logger.debug(f"Creating payment payload: {payload}") diff --git a/src/infrastructure/payment_gateways/wata.py b/src/infrastructure/payment_gateways/wata.py index b4bd72b7..791cb27e 100644 --- a/src/infrastructure/payment_gateways/wata.py +++ b/src/infrastructure/payment_gateways/wata.py @@ -2,7 +2,7 @@ from base64 import b64decode from datetime import datetime, timezone from decimal import Decimal -from typing import Any, Final +from typing import Any, Final, Optional from uuid import UUID import orjson @@ -50,7 +50,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N }, ) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: order_id = str(uuid.uuid4()) payload = await self._create_payment_payload(amount, details, order_id) logger.debug(f"Creating payment payload: {payload}") diff --git a/src/infrastructure/payment_gateways/yookassa.py b/src/infrastructure/payment_gateways/yookassa.py index 3135a5e8..cb3acfa2 100644 --- a/src/infrastructure/payment_gateways/yookassa.py +++ b/src/infrastructure/payment_gateways/yookassa.py @@ -1,7 +1,7 @@ import asyncio import uuid from decimal import Decimal -from typing import Any, Final +from typing import Any, Final, Optional from uuid import UUID import orjson @@ -62,8 +62,13 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N ), ) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: - payload = await self._create_payment_payload(str(amount), details) + async def handle_create_payment( + self, + amount: Decimal, + details: str, + receipt_email: Optional[str] = None, + ) -> PaymentResultDto: + payload = await self._create_payment_payload(str(amount), details, receipt_email=receipt_email) headers = {"Idempotence-Key": str(uuid.uuid4())} logger.debug(f"Creating payment payload: {payload}") @@ -130,25 +135,36 @@ async def handle_webhook(self, request: Request) -> tuple[UUID, TransactionStatu return payment_id, transaction_status - async def _create_payment_payload(self, amount: str, details: str) -> dict[str, Any]: + async def _create_payment_payload( + self, + amount: str, + details: str, + *, + receipt_email: Optional[str] = None, + ) -> dict[str, Any]: + settings = self.data.settings + receipt: dict[str, Any] = { + "items": [ + { + "description": details, + "quantity": "1.00", + "amount": {"value": amount, "currency": self.data.currency}, + "vat_code": settings.vat_code, # type: ignore[union-attr] + "payment_subject": self.PAYMENT_SUBJECT, + "payment_mode": self.PAYMENT_MODE, + } + ], + } + if receipt_email is not None: + receipt["customer"] = {"email": receipt_email} + elif not settings.request_email: + receipt["customer"] = {"email": settings.customer} # type: ignore[union-attr] return { "amount": {"value": amount, "currency": self.data.currency}, "confirmation": {"type": "redirect", "return_url": await self._get_bot_redirect_url()}, "capture": True, "description": details, - "receipt": { - "customer": {"email": self.data.settings.customer}, # type: ignore[union-attr] - "items": [ - { - "description": details, - "quantity": "1.00", - "amount": {"value": amount, "currency": self.data.currency}, - "vat_code": self.data.settings.vat_code, # type: ignore[union-attr] - "payment_subject": self.PAYMENT_SUBJECT, - "payment_mode": self.PAYMENT_MODE, - } - ], - }, + "receipt": receipt, } def _get_payment_data(self, data: dict[str, Any]) -> PaymentResultDto: diff --git a/src/infrastructure/payment_gateways/yoomoney.py b/src/infrastructure/payment_gateways/yoomoney.py index 5c9c1f61..0f3a0703 100644 --- a/src/infrastructure/payment_gateways/yoomoney.py +++ b/src/infrastructure/payment_gateways/yoomoney.py @@ -1,7 +1,7 @@ import hashlib import uuid from decimal import Decimal -from typing import Any, Final +from typing import Any, Final, Optional from urllib.parse import parse_qs from uuid import UUID @@ -41,7 +41,9 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N self._client = self._make_client(base_url=self.API_BASE) - async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResultDto: + async def handle_create_payment( + self, amount: Decimal, details: str, receipt_email: Optional[str] = None + ) -> PaymentResultDto: payment_id = uuid.uuid4() payload = await self._create_payment_payload(str(amount), str(payment_id)) logger.debug(f"Creating payment payload: {payload}") diff --git a/src/telegram/routers/dashboard/remnashop/gateways/dialog.py b/src/telegram/routers/dashboard/remnashop/gateways/dialog.py index 6b0ff8e6..c9553e56 100644 --- a/src/telegram/routers/dashboard/remnashop/gateways/dialog.py +++ b/src/telegram/routers/dashboard/remnashop/gateways/dialog.py @@ -15,7 +15,7 @@ from aiogram_dialog.widgets.text import Format from magic_filter import F -from src.core.enums import BannerName, Currency +from src.core.enums import BannerName, Currency, PaymentGatewayType from src.telegram.keyboards import main_menu_button from src.telegram.states import DashboardRemnashop, RemnashopGateways from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate @@ -35,6 +35,7 @@ on_gateway_move, on_gateway_select, on_gateway_test, + on_yookassa_request_email_toggle, ) gateways = Window( @@ -110,6 +111,17 @@ ), width=2, ), + Row( + Button( + text=I18nFormat( + "btn-gateway.yookassa-request-email", + request_email=F["request_email"], + ), + id="yookassa_request_email", + on_click=on_yookassa_request_email_toggle, + ), + when=F["gateway_type"] == PaymentGatewayType.YOOKASSA, + ), Row( CopyText( text=I18nFormat("btn-gateway.webhook-copy"), diff --git a/src/telegram/routers/dashboard/remnashop/gateways/getters.py b/src/telegram/routers/dashboard/remnashop/gateways/getters.py index 3c16b647..9e2d3ec0 100644 --- a/src/telegram/routers/dashboard/remnashop/gateways/getters.py +++ b/src/telegram/routers/dashboard/remnashop/gateways/getters.py @@ -6,6 +6,7 @@ from src.application.common.dao import PaymentGatewayDao, SettingsDao from src.application.dto import PaymentGatewayDto +from src.application.dto.payment_gateway import YooKassaGatewaySettingsDto from src.core.config import AppConfig from src.core.enums import Currency @@ -55,6 +56,11 @@ async def gateway_getter( "settings": gateway.settings.as_list, "webhook": config.get_webhook(gateway.type), "requires_webhook": gateway.requires_webhook, + "request_email": ( + int(gateway.settings.request_email) + if isinstance(gateway.settings, YooKassaGatewaySettingsDto) + else None + ), } diff --git a/src/telegram/routers/dashboard/remnashop/gateways/handlers.py b/src/telegram/routers/dashboard/remnashop/gateways/handlers.py index 21cf584f..5a80c462 100644 --- a/src/telegram/routers/dashboard/remnashop/gateways/handlers.py +++ b/src/telegram/routers/dashboard/remnashop/gateways/handlers.py @@ -12,6 +12,7 @@ from src.application.use_cases.gateways.commands.configuration import ( MovePaymentGatewayUp, TogglePaymentGatewayActive, + ToggleYooKassaRequestEmail, UpdatePaymentGatewaySettings, UpdatePaymentGatewaySettingsDto, ) @@ -106,6 +107,18 @@ async def on_active_toggle( await notifier.notify_user(user, i18n_key="ntf-gateway.not-configured") +@inject +async def on_yookassa_request_email_toggle( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + toggle_yookassa_request_email: FromDishka[ToggleYooKassaRequestEmail], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + gateway_id = dialog_manager.dialog_data["gateway_id"] + await toggle_yookassa_request_email(user, gateway_id) + + async def on_field_select( callback: CallbackQuery, widget: Select, diff --git a/src/telegram/routers/subscription/dialog.py b/src/telegram/routers/subscription/dialog.py index df7f46ea..bc4c6267 100644 --- a/src/telegram/routers/subscription/dialog.py +++ b/src/telegram/routers/subscription/dialog.py @@ -1,5 +1,6 @@ from aiogram.enums import ButtonStyle from aiogram_dialog import Dialog, Window +from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Column, Group, Row, Select, SwitchTo, Url from aiogram_dialog.widgets.style import Style from aiogram_dialog.widgets.text import Format @@ -20,6 +21,7 @@ plans_getter, subscription_getter, success_payment_getter, + yookassa_email_getter, ) from .handlers import ( on_duration_select, @@ -27,6 +29,8 @@ on_payment_method_select, on_plan_select, on_subscription_plans, + on_yookassa_email_input, + on_yookassa_email_skip, ) subscription = Window( @@ -189,6 +193,39 @@ getter=payment_method_getter, ) +yookassa_email = Window( + Banner(BannerName.SUBSCRIPTION), + I18nFormat("msg-subscription-yookassa-email"), + Row( + Button( + text=I18nFormat("btn-subscription.skip-email"), + id=f"{PAYMENT_PREFIX}yookassa_skip_email", + on_click=on_yookassa_email_skip, + ), + ), + Row( + SwitchTo( + text=I18nFormat("btn-subscription.back-payment-method"), + id=f"{PAYMENT_PREFIX}yookassa_change_method", + state=Subscription.PAYMENT_METHOD, + when=~F["only_single_gateway"], + ), + ), + Row( + SwitchTo( + text=I18nFormat("btn-subscription.back-plans"), + id=f"{PAYMENT_PREFIX}yookassa_back_plans", + state=Subscription.PLANS, + when=~F["only_single_plan"], + ), + ), + *back_main_menu_button, + MessageInput(func=on_yookassa_email_input), + IgnoreUpdate(), + state=Subscription.YOOKASSA_EMAIL, + getter=yookassa_email_getter, +) + confirm = Window( Banner(BannerName.SUBSCRIPTION), I18nFormat("msg-subscription-confirm"), @@ -208,17 +245,32 @@ ), ), Row( + SwitchTo( + text=I18nFormat("btn-subscription.back-yookassa-email"), + id=f"{PAYMENT_PREFIX}back_yookassa_email", + state=Subscription.YOOKASSA_EMAIL, + when=F["yookassa_email_flow"] & ~F["is_free"], + ), SwitchTo( text=I18nFormat("btn-subscription.back-payment-method"), id=f"{PAYMENT_PREFIX}back_payment_method", state=Subscription.PAYMENT_METHOD, - when=~F["only_single_gateway"] & ~F["is_free"], + when=~F["yookassa_email_flow"] & ~F["only_single_gateway"] & ~F["is_free"], ), SwitchTo( text=I18nFormat("btn-subscription.back-duration"), id=f"{PAYMENT_PREFIX}back_duration", state=Subscription.DURATION, - when=F["only_single_gateway"] & ~F["only_single_duration"] | F["is_free"], + when=( + ~F["yookassa_email_flow"] + & ((F["only_single_gateway"] & ~F["only_single_duration"]) | F["is_free"]) + ) + | ( + F["yookassa_email_flow"] + & F["only_single_gateway"] + & ~F["only_single_duration"] + & ~F["is_free"] + ), ), ), Row( @@ -273,6 +325,7 @@ plans, duration, payment_method, + yookassa_email, confirm, success_payment, success_trial, diff --git a/src/telegram/routers/subscription/getters.py b/src/telegram/routers/subscription/getters.py index 82e7445f..0b8c932c 100644 --- a/src/telegram/routers/subscription/getters.py +++ b/src/telegram/routers/subscription/getters.py @@ -13,7 +13,7 @@ from src.application.use_cases.plan.queries.match import MatchPlan, MatchPlanDto from src.application.use_cases.user.queries.plans import GetAvailablePlans from src.core.config import AppConfig -from src.core.enums import PurchaseType +from src.core.enums import PaymentGatewayType, PurchaseType from src.core.utils.i18n_helpers import ( i18n_format_days, i18n_format_device_limit, @@ -215,6 +215,65 @@ async def payment_method_getter( } +@inject +async def yookassa_email_getter( + dialog_manager: DialogManager, + user: UserDto, + retort: FromDishka[Retort], + i18n: FromDishka[TranslatorRunner], + payment_gateway_dao: FromDishka[PaymentGatewayDao], + pricing_service: FromDishka[PricingService], + **kwargs: Any, +) -> dict[str, Any]: + raw_plan = dialog_manager.dialog_data.get(PlanDto.__name__) + + if not raw_plan: + logger.debug("PlanDto not found in dialog data") + await dialog_manager.start(state=Subscription.MAIN) + return {} + + plan = retort.load(raw_plan, PlanDto) + selected_duration = dialog_manager.dialog_data["selected_duration"] + duration = plan.get_duration(selected_duration) + + if not duration: + raise ValueError(f"Duration '{selected_duration}' not found in plan '{plan.name}'") + + key, kw = i18n_format_days(duration.days) + gateways = await payment_gateway_dao.get_active() + + selected_method = dialog_manager.dialog_data.get( + "selected_payment_method", PaymentGatewayType.YOOKASSA + ) + gateway = await payment_gateway_dao.get_by_type(selected_method) + if not gateway: + gateway = await payment_gateway_dao.get_by_type(PaymentGatewayType.YOOKASSA) + if not gateway: + logger.error("YooKassa gateway not found for email step") + await dialog_manager.start(state=Subscription.MAIN) + return {} + + raw_price = duration.get_price(gateway.currency) + price = pricing_service.calculate(user, raw_price, gateway.currency) + + return { + "plan": i18n.get(plan.name), + "description": i18n.get(plan.description) if plan.description else False, + "type": plan.type, + "devices": i18n_format_device_limit(plan.device_limit), + "traffic": i18n_format_traffic_limit(plan.traffic_limit), + "period": i18n.get(key, **kw), + "final_amount": price.final_amount, + "original_amount": price.original_amount, + "currency": gateway.currency.symbol, + "discount_percent": price.discount_percent, + "is_personal_discount": int(pricing_service.is_largest_discount_personal(user)), + "only_single_gateway": len(gateways) == 1, + "only_single_plan": dialog_manager.dialog_data.get("only_single_plan", False), + "only_single_duration": dialog_manager.dialog_data.get("only_single_duration", False), + } + + @inject async def confirm_getter( dialog_manager: DialogManager, @@ -254,6 +313,8 @@ async def confirm_getter( key, kw = i18n_format_days(duration.days) gateways = await payment_gateway_dao.get_active() + only_single_plan = dialog_manager.dialog_data.get("only_single_plan", False) + return { "purchase_type": purchase_type, "plan": i18n.get(plan.name), @@ -270,8 +331,10 @@ async def confirm_getter( "currency": payment_gateway.currency.symbol, "url": result_url, "only_single_gateway": len(gateways) == 1, + "only_single_plan": only_single_plan, "only_single_duration": only_single_duration, "is_free": is_free, + "yookassa_email_flow": dialog_manager.dialog_data.get("yookassa_email_flow", False), } diff --git a/src/telegram/routers/subscription/handlers.py b/src/telegram/routers/subscription/handlers.py index 819deab5..1f6d96f5 100644 --- a/src/telegram/routers/subscription/handlers.py +++ b/src/telegram/routers/subscription/handlers.py @@ -1,8 +1,9 @@ -from typing import Optional, TypedDict, cast +from typing import Optional, TypedDict, Union, cast from adaptix import Retort -from aiogram.types import CallbackQuery -from aiogram_dialog import DialogManager +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import DialogManager, ShowMode +from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Select from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -10,7 +11,8 @@ from src.application.common import Notifier from src.application.common.dao import PaymentGatewayDao, PlanDao, SettingsDao, SubscriptionDao -from src.application.dto import PlanDto, PlanSnapshotDto, UserDto +from src.application.dto import PaymentGatewayDto, PlanDto, PlanSnapshotDto, UserDto +from src.application.dto.payment_gateway import YooKassaGatewaySettingsDto from src.application.services import PricingService from src.application.use_cases.gateways.commands.payment import ( CreatePayment, @@ -22,11 +24,21 @@ from src.application.use_cases.user.queries.plans import GetAvailablePlans from src.core.constants import PAYMENT_PREFIX, USER_KEY from src.core.enums import PaymentGatewayType, PurchaseType, TransactionStatus +from src.core.utils.email import is_valid_email from src.telegram.states import Subscription PAYMENT_CACHE_KEY = "payment_cache" CURRENT_DURATION_KEY = "selected_duration" CURRENT_METHOD_KEY = "selected_payment_method" +YOOKASSA_EMAIL_FLOW_KEY = "yookassa_email_flow" + + +def _yookassa_requests_email_step(gateway: PaymentGatewayDto | None) -> bool: + if not gateway or gateway.type != PaymentGatewayType.YOOKASSA: + return False + if not isinstance(gateway.settings, YooKassaGatewaySettingsDto): + return False + return bool(gateway.settings.request_email) class CachedPaymentData(TypedDict): @@ -35,8 +47,22 @@ class CachedPaymentData(TypedDict): final_pricing: str -def _get_cache_key(duration: int, gateway_type: PaymentGatewayType) -> str: - return f"{duration}:{gateway_type.value}" +def _normalize_gateway_type(value: Union[PaymentGatewayType, str]) -> PaymentGatewayType: + if isinstance(value, PaymentGatewayType): + return value + return PaymentGatewayType(value) + + +def _get_cache_key( + duration: int, + gateway_type: Union[PaymentGatewayType, str], + receipt_email: Optional[str] = None, +) -> str: + gw = _normalize_gateway_type(gateway_type) + base = f"{duration}:{gw.value}" + if receipt_email is not None: + return f"{base}:{receipt_email}" + return base def _load_payment_data(dialog_manager: DialogManager) -> dict[str, CachedPaymentData]: @@ -55,14 +81,16 @@ async def _create_payment_and_get_data( dialog_manager: DialogManager, plan: PlanDto, duration_days: int, - gateway_type: PaymentGatewayType, + gateway_type: Union[PaymentGatewayType, str], retort: Retort, payment_gateway_dao: PaymentGatewayDao, notifier: Notifier, pricing_service: PricingService, create_payment: CreatePayment, + receipt_email: Optional[str] = None, ) -> Optional[CachedPaymentData]: user: UserDto = dialog_manager.middleware_data[USER_KEY] + gateway_type = _normalize_gateway_type(gateway_type) duration = plan.get_duration(duration_days) payment_gateway = await payment_gateway_dao.get_by_type(gateway_type) purchase_type: PurchaseType = dialog_manager.dialog_data["purchase_type"] @@ -83,6 +111,7 @@ async def _create_payment_and_get_data( pricing=pricing, purchase_type=purchase_type, gateway_type=gateway_type, + receipt_email=receipt_email, ), ) @@ -98,6 +127,53 @@ async def _create_payment_and_get_data( raise +async def _finalize_yookassa_payment_with_receipt_email( + dialog_manager: DialogManager, + *, + receipt_email: str, + retort: Retort, + payment_gateway_dao: PaymentGatewayDao, + notifier: Notifier, + pricing_service: PricingService, + create_payment: CreatePayment, +) -> bool: + raw_plan = dialog_manager.dialog_data.get(PlanDto.__name__) + if not raw_plan: + logger.error("PlanDto not found for YooKassa email step") + return False + + plan = retort.load(raw_plan, PlanDto) + selected_duration = dialog_manager.dialog_data[CURRENT_DURATION_KEY] + selected_payment_method = dialog_manager.dialog_data[CURRENT_METHOD_KEY] + cache = _load_payment_data(dialog_manager) + cache_key = _get_cache_key(selected_duration, selected_payment_method, receipt_email) + + if cache_key in cache: + logger.info("Reusing cached payment for YooKassa receipt email") + _save_payment_data(dialog_manager, cache[cache_key]) + return True + + payment_data = await _create_payment_and_get_data( + dialog_manager=dialog_manager, + plan=plan, + duration_days=selected_duration, + gateway_type=selected_payment_method, + retort=retort, + payment_gateway_dao=payment_gateway_dao, + notifier=notifier, + pricing_service=pricing_service, + create_payment=create_payment, + receipt_email=receipt_email, + ) + + if payment_data: + cache[cache_key] = payment_data + _save_payment_data(dialog_manager, payment_data) + return True + + return False + + @inject async def on_purchase_type_select( purchase_type: PurchaseType, @@ -166,6 +242,7 @@ async def on_subscription_plans( # noqa: C901 match_plan: FromDishka[MatchPlan], get_available_plans: FromDishka[GetAvailablePlans], create_payment: FromDishka[CreatePayment], + settings_dao: FromDishka[SettingsDao], ) -> None: user: UserDto = dialog_manager.middleware_data[USER_KEY] logger.info(f"{user.log} Opened subscription plans menu") @@ -219,15 +296,32 @@ async def on_subscription_plans( # noqa: C901 dialog_manager.dialog_data["only_single_duration"] = True if len(gateways) == 1: - logger.info(f"{user.log} Auto-selected payment method '{gateways[0].type}'") - dialog_manager.dialog_data["selected_payment_method"] = gateways[0].type + gw0 = gateways[0] + logger.info(f"{user.log} Auto-selected payment method '{gw0.type}'") + dialog_manager.dialog_data["selected_payment_method"] = gw0.type + dialog_manager.dialog_data[CURRENT_METHOD_KEY] = gw0.type dialog_manager.dialog_data["only_single_payment_method"] = True + settings = await settings_dao.get() + currency = settings.default_currency + ddays = plans[0].durations[0].days + price = pricing_service.calculate( + user, + plans[0].get_duration(ddays).get_price(currency), # type: ignore[union-attr] + currency, + ) + dialog_manager.dialog_data["is_free"] = price.is_free + + if _yookassa_requests_email_step(gw0) and not price.is_free: + dialog_manager.dialog_data[YOOKASSA_EMAIL_FLOW_KEY] = True + await dialog_manager.switch_to(state=Subscription.YOOKASSA_EMAIL) + return + payment_data = await _create_payment_and_get_data( dialog_manager=dialog_manager, plan=plans[0], - duration_days=plans[0].durations[0].days, - gateway_type=gateways[0].type, + duration_days=ddays, + gateway_type=gw0.type, retort=retort, payment_gateway_dao=payment_gateway_dao, notifier=notifier, @@ -275,6 +369,7 @@ async def on_plan_select( dialog_manager.dialog_data.pop(PAYMENT_CACHE_KEY, None) dialog_manager.dialog_data.pop(CURRENT_DURATION_KEY, None) dialog_manager.dialog_data.pop(CURRENT_METHOD_KEY, None) + dialog_manager.dialog_data.pop(YOOKASSA_EMAIL_FLOW_KEY, None) if len(plan.durations) == 1: logger.info(f"{user.log} Auto-selected single duration '{plan.durations[0].days}'") @@ -325,6 +420,11 @@ async def on_duration_select( selected_payment_method = gateways[0].type dialog_manager.dialog_data[CURRENT_METHOD_KEY] = selected_payment_method + if _yookassa_requests_email_step(gateways[0]) and not price.is_free: + dialog_manager.dialog_data[YOOKASSA_EMAIL_FLOW_KEY] = True + await dialog_manager.switch_to(state=Subscription.YOOKASSA_EMAIL) + return + cache = _load_payment_data(dialog_manager) cache_key = _get_cache_key(selected_duration, selected_payment_method) @@ -375,6 +475,24 @@ async def on_payment_method_select( selected_duration = dialog_manager.dialog_data[CURRENT_DURATION_KEY] dialog_manager.dialog_data[CURRENT_METHOD_KEY] = selected_payment_method + + gateway = await payment_gateway_dao.get_by_type(selected_payment_method) + if gateway and _yookassa_requests_email_step(gateway): + raw_plan_for_price = dialog_manager.dialog_data.get(PlanDto.__name__) + if raw_plan_for_price: + plan_for_price = retort.load(raw_plan_for_price, PlanDto) + du = plan_for_price.get_duration(selected_duration) + if du: + price_m = pricing_service.calculate( + user, du.get_price(gateway.currency), gateway.currency + ) + if not price_m.is_free: + dialog_manager.dialog_data[YOOKASSA_EMAIL_FLOW_KEY] = True + await dialog_manager.switch_to(state=Subscription.YOOKASSA_EMAIL) + return + + dialog_manager.dialog_data[YOOKASSA_EMAIL_FLOW_KEY] = False + cache = _load_payment_data(dialog_manager) cache_key = _get_cache_key(selected_duration, selected_payment_method) @@ -414,6 +532,76 @@ async def on_payment_method_select( await dialog_manager.switch_to(state=Subscription.CONFIRM) +@inject +async def on_yookassa_email_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], + retort: FromDishka[Retort], + payment_gateway_dao: FromDishka[PaymentGatewayDao], + pricing_service: FromDishka[PricingService], + create_payment: FromDishka[CreatePayment], +) -> None: + dialog_manager.show_mode = ShowMode.EDIT + user: UserDto = dialog_manager.middleware_data[USER_KEY] + text = (message.text or "").strip() + + if not is_valid_email(text): + await notifier.notify_user(user, i18n_key="ntf-subscription.email-invalid") + return + + ok = await _finalize_yookassa_payment_with_receipt_email( + dialog_manager, + receipt_email=text, + retort=retort, + payment_gateway_dao=payment_gateway_dao, + notifier=notifier, + pricing_service=pricing_service, + create_payment=create_payment, + ) + + if ok: + await dialog_manager.switch_to(state=Subscription.CONFIRM) + + +@inject +async def on_yookassa_email_skip( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], + retort: FromDishka[Retort], + payment_gateway_dao: FromDishka[PaymentGatewayDao], + pricing_service: FromDishka[PricingService], + create_payment: FromDishka[CreatePayment], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + gateway = await payment_gateway_dao.get_by_type(PaymentGatewayType.YOOKASSA) + + if not gateway or not isinstance(gateway.settings, YooKassaGatewaySettingsDto): + await notifier.notify_user(user, i18n_key="ntf-subscription.payment-creation-failed") + return + + default_email = gateway.settings.customer + if not default_email: + await notifier.notify_user(user, i18n_key="ntf-gateway.not-configured") + return + + ok = await _finalize_yookassa_payment_with_receipt_email( + dialog_manager, + receipt_email=default_email, + retort=retort, + payment_gateway_dao=payment_gateway_dao, + notifier=notifier, + pricing_service=pricing_service, + create_payment=create_payment, + ) + + if ok: + await dialog_manager.switch_to(state=Subscription.CONFIRM) + + @inject async def on_get_subscription( callback: CallbackQuery, diff --git a/src/telegram/states.py b/src/telegram/states.py index 36ba92d7..57a5fab2 100644 --- a/src/telegram/states.py +++ b/src/telegram/states.py @@ -24,6 +24,7 @@ class Subscription(StatesGroup): PLANS = State() DURATION = State() PAYMENT_METHOD = State() + YOOKASSA_EMAIL = State() CONFIRM = State() SUCCESS = State() FAILED = State()