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()