From 5e5c8eb658f9d7cb50bd03dd2cf6ac39a50774c0 Mon Sep 17 00:00:00 2001 From: marsel Date: Thu, 29 Jan 2026 16:51:04 +0400 Subject: [PATCH] feat: add Platega payment system support --- assets/translations/ru/utils.ftl | 1 + src/core/enums.py | 2 + .../migrations/versions/0001_create_enums.py | 230 +++++++++--------- ...018_add_platega_to_payment_gateway_type.py | 34 +++ .../database/models/dto/__init__.py | 2 + .../database/models/dto/payment_gateway.py | 7 + .../di/providers/payment_gateways.py | 2 + .../payment_gateways/__init__.py | 2 + .../payment_gateways/platega.py | 157 ++++++++++++ src/services/payment_gateway.py | 4 + 10 files changed, 326 insertions(+), 115 deletions(-) create mode 100644 src/infrastructure/database/migrations/versions/0018_add_platega_to_payment_gateway_type.py create mode 100644 src/infrastructure/payment_gateways/platega.py diff --git a/assets/translations/ru/utils.ftl b/assets/translations/ru/utils.ftl index 04d55bc..f624973 100644 --- a/assets/translations/ru/utils.ftl +++ b/assets/translations/ru/utils.ftl @@ -272,6 +272,7 @@ gateway-type = { $gateway_type -> [CRYPTOMUS] Cryptomus [HELEKET] Heleket [URLPAY] UrlPay + [PLATEGA] Platega *[OTHER] { $gateway_type } } diff --git a/src/core/enums.py b/src/core/enums.py index c513452..0859cd0 100644 --- a/src/core/enums.py +++ b/src/core/enums.py @@ -224,6 +224,7 @@ class PaymentGatewayType(UpperStrEnum): HELEKET = auto() CRYPTOPAY = auto() ROBOKASSA = auto() + PLATEGA = auto() class Currency(UpperStrEnum): @@ -254,6 +255,7 @@ def from_gateway_type(cls, gateway_type: PaymentGatewayType) -> "Currency": PaymentGatewayType.CRYPTOMUS: cls.USD, PaymentGatewayType.HELEKET: cls.USD, PaymentGatewayType.CRYPTOPAY: cls.USD, + PaymentGatewayType.PLATEGA: cls.RUB } try: diff --git a/src/infrastructure/database/migrations/versions/0001_create_enums.py b/src/infrastructure/database/migrations/versions/0001_create_enums.py index ef75000..d228b47 100644 --- a/src/infrastructure/database/migrations/versions/0001_create_enums.py +++ b/src/infrastructure/database/migrations/versions/0001_create_enums.py @@ -1,115 +1,115 @@ -from typing import Sequence, Union - -from alembic import op - -revision: str = "0001" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.execute(""" - CREATE TYPE payment_gateway_type AS ENUM ( - 'TELEGRAM_STARS', - 'YOOKASSA', - 'YOOMONEY', - 'CRYPTOMUS', - 'HELEKET', - 'URLPAY' - ) - """) - - op.execute(""" - CREATE TYPE currency AS ENUM ('USD', 'XTR', 'RUB') - """) - - op.execute(""" - CREATE TYPE plan_type AS ENUM ( - 'TRAFFIC', - 'DEVICES', - 'BOTH', - 'UNLIMITED' - ) - """) - - op.execute(""" - CREATE TYPE plan_availability AS ENUM ( - 'ALL', - 'NEW', - 'EXISTING', - 'INVITED', - 'ALLOWED', - 'TRIAL' - ) - """) - - op.execute(""" - CREATE TYPE promocode_reward_type AS ENUM ( - 'DURATION', - 'TRAFFIC', - 'SUBSCRIPTION', - 'PERSONAL_DISCOUNT', - 'PURCHASE_DISCOUNT' - ) - """) - - op.execute(""" - CREATE TYPE access_mode AS ENUM ( - 'ALL', - 'INVITED', - 'PURCHASE', - 'BLOCKED' - ) - """) - - op.execute(""" - CREATE TYPE subscription_status AS ENUM ( - 'ACTIVE', - 'DISABLED', - 'LIMITED', - 'EXPIRED', - 'DELETED' - ) - """) - - op.execute(""" - CREATE TYPE user_role AS ENUM ('DEV', 'ADMIN', 'USER') - """) - - op.execute(""" - CREATE TYPE locale AS ENUM ( - 'AR', 'AZ', 'BE', 'CS', 'DE', 'EN', 'ES', 'FA', - 'FR', 'HE', 'HI', 'ID', 'IT', 'JA', 'KK', 'KO', - 'MS', 'NL', 'PL', 'PT', 'RO', 'RU', 'SR', 'TR', - 'UK', 'UZ', 'VI' - ) - """) - - op.execute(""" - CREATE TYPE transaction_status AS ENUM ( - 'PENDING', - 'COMPLETED', - 'CANCELED', - 'REFUNDED', - 'FAILED' - ) - """) - - op.execute(""" - CREATE TYPE purchasetype AS ENUM ('NEW', 'RENEW', 'CHANGE') - """) - - -def downgrade() -> None: - op.execute("DROP TYPE IF EXISTS purchasetype") - op.execute("DROP TYPE IF EXISTS transaction_status") - op.execute("DROP TYPE IF EXISTS locale") - op.execute("DROP TYPE IF EXISTS user_role") - op.execute("DROP TYPE IF EXISTS subscription_status") - op.execute("DROP TYPE IF EXISTS access_mode") - op.execute("DROP TYPE IF EXISTS promocode_reward_type") - op.execute("DROP TYPE IF EXISTS plan_availability") - op.execute("DROP TYPE IF EXISTS plan_type") - op.execute("DROP TYPE IF EXISTS currency") - op.execute("DROP TYPE IF EXISTS payment_gateway_type") +from typing import Sequence, Union + +from alembic import op + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + CREATE TYPE payment_gateway_type AS ENUM ( + 'TELEGRAM_STARS', + 'YOOKASSA', + 'YOOMONEY', + 'CRYPTOMUS', + 'HELEKET', + 'URLPAY' + ) + """) + + op.execute(""" + CREATE TYPE currency AS ENUM ('USD', 'XTR', 'RUB') + """) + + op.execute(""" + CREATE TYPE plan_type AS ENUM ( + 'TRAFFIC', + 'DEVICES', + 'BOTH', + 'UNLIMITED' + ) + """) + + op.execute(""" + CREATE TYPE plan_availability AS ENUM ( + 'ALL', + 'NEW', + 'EXISTING', + 'INVITED', + 'ALLOWED', + 'TRIAL' + ) + """) + + op.execute(""" + CREATE TYPE promocode_reward_type AS ENUM ( + 'DURATION', + 'TRAFFIC', + 'SUBSCRIPTION', + 'PERSONAL_DISCOUNT', + 'PURCHASE_DISCOUNT' + ) + """) + + op.execute(""" + CREATE TYPE access_mode AS ENUM ( + 'ALL', + 'INVITED', + 'PURCHASE', + 'BLOCKED' + ) + """) + + op.execute(""" + CREATE TYPE subscription_status AS ENUM ( + 'ACTIVE', + 'DISABLED', + 'LIMITED', + 'EXPIRED', + 'DELETED' + ) + """) + + op.execute(""" + CREATE TYPE user_role AS ENUM ('DEV', 'ADMIN', 'USER') + """) + + op.execute(""" + CREATE TYPE locale AS ENUM ( + 'AR', 'AZ', 'BE', 'CS', 'DE', 'EN', 'ES', 'FA', + 'FR', 'HE', 'HI', 'ID', 'IT', 'JA', 'KK', 'KO', + 'MS', 'NL', 'PL', 'PT', 'RO', 'RU', 'SR', 'TR', + 'UK', 'UZ', 'VI' + ) + """) + + op.execute(""" + CREATE TYPE transaction_status AS ENUM ( + 'PENDING', + 'COMPLETED', + 'CANCELED', + 'REFUNDED', + 'FAILED' + ) + """) + + op.execute(""" + CREATE TYPE purchasetype AS ENUM ('NEW', 'RENEW', 'CHANGE') + """) + + +def downgrade() -> None: + op.execute("DROP TYPE IF EXISTS purchasetype") + op.execute("DROP TYPE IF EXISTS transaction_status") + op.execute("DROP TYPE IF EXISTS locale") + op.execute("DROP TYPE IF EXISTS user_role") + op.execute("DROP TYPE IF EXISTS subscription_status") + op.execute("DROP TYPE IF EXISTS access_mode") + op.execute("DROP TYPE IF EXISTS promocode_reward_type") + op.execute("DROP TYPE IF EXISTS plan_availability") + op.execute("DROP TYPE IF EXISTS plan_type") + op.execute("DROP TYPE IF EXISTS currency") + op.execute("DROP TYPE IF EXISTS payment_gateway_type") diff --git a/src/infrastructure/database/migrations/versions/0018_add_platega_to_payment_gateway_type.py b/src/infrastructure/database/migrations/versions/0018_add_platega_to_payment_gateway_type.py new file mode 100644 index 0000000..c8172fe --- /dev/null +++ b/src/infrastructure/database/migrations/versions/0018_add_platega_to_payment_gateway_type.py @@ -0,0 +1,34 @@ +from typing import Sequence, Union + +from alembic import op + +revision: str = "0018" +down_revision: Union[str, None] = "0017" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + ALTER TYPE payment_gateway_type ADD VALUE 'PLATEGA' + """) + + +def downgrade() -> None: + op.execute(""" + CREATE TYPE payment_gateway_type_backup AS ENUM ( + 'TELEGRAM_STARS', + 'YOOKASSA', + 'YOOMONEY', + 'CRYPTOMUS', + 'HELEKET', + 'URLPAY' + ) + """) + op.execute(""" + ALTER TABLE payment_gateways ALTER COLUMN type + TYPE payment_gateway_type_backup + USING type::text::payment_gateway_type_backup + """) + op.execute("DROP TYPE payment_gateway_type") + op.execute("ALTER TYPE payment_gateway_type_backup RENAME TO payment_gateway_type") diff --git a/src/infrastructure/database/models/dto/__init__.py b/src/infrastructure/database/models/dto/__init__.py index dd249fe..d339883 100644 --- a/src/infrastructure/database/models/dto/__init__.py +++ b/src/infrastructure/database/models/dto/__init__.py @@ -10,6 +10,7 @@ RobokassaGatewaySettingsDto, YookassaGatewaySettingsDto, YoomoneyGatewaySettingsDto, + PlategaGatewaySettingsDto, ) from .plan import PlanDto, PlanDurationDto, PlanPriceDto, PlanSnapshotDto from .promocode import PromocodeActivationDto, PromocodeDto @@ -39,6 +40,7 @@ "CryptomusGatewaySettingsDto", "CryptopayGatewaySettingsDto", "HeleketGatewaySettingsDto", + "PlategaGatewaySettingsDto", "PaymentGatewayDto", "PaymentResult", "RobokassaGatewaySettingsDto", diff --git a/src/infrastructure/database/models/dto/payment_gateway.py b/src/infrastructure/database/models/dto/payment_gateway.py index ee155fa..b092d35 100644 --- a/src/infrastructure/database/models/dto/payment_gateway.py +++ b/src/infrastructure/database/models/dto/payment_gateway.py @@ -83,6 +83,12 @@ class CryptopayGatewaySettingsDto(GatewaySettingsDto): secret_key: Optional[SecretStr] = None +class PlategaGatewaySettingsDto(GatewaySettingsDto): + type: Literal[PaymentGatewayType.PLATEGA] = PaymentGatewayType.PLATEGA + merchant_id: Optional[str] = None + api_key: Optional[SecretStr] = None + + class RobokassaGatewaySettingsDto(GatewaySettingsDto): type: Literal[PaymentGatewayType.ROBOKASSA] = PaymentGatewayType.ROBOKASSA shop_id: Optional[str] = None @@ -98,6 +104,7 @@ class RobokassaGatewaySettingsDto(GatewaySettingsDto): HeleketGatewaySettingsDto, CryptopayGatewaySettingsDto, RobokassaGatewaySettingsDto, + PlategaGatewaySettingsDto, ], Field(discriminator="type"), ] diff --git a/src/infrastructure/di/providers/payment_gateways.py b/src/infrastructure/di/providers/payment_gateways.py index 31ba0f1..e7059b8 100644 --- a/src/infrastructure/di/providers/payment_gateways.py +++ b/src/infrastructure/di/providers/payment_gateways.py @@ -17,6 +17,7 @@ TelegramStarsGateway, YookassaGateway, YoomoneyGateway, + PlategaGateway ) GATEWAY_MAP: dict[PaymentGatewayType, Type[BasePaymentGateway]] = { @@ -25,6 +26,7 @@ PaymentGatewayType.YOOMONEY: YoomoneyGateway, PaymentGatewayType.CRYPTOMUS: CryptomusGateway, PaymentGatewayType.HELEKET: HeleketGateway, + PaymentGatewayType.PLATEGA: PlategaGateway # PaymentGatewayType.URLPAY: UrlpayGateway, } diff --git a/src/infrastructure/payment_gateways/__init__.py b/src/infrastructure/payment_gateways/__init__.py index 29bbcc2..25f5bc7 100644 --- a/src/infrastructure/payment_gateways/__init__.py +++ b/src/infrastructure/payment_gateways/__init__.py @@ -4,6 +4,7 @@ from .telegram_stars import TelegramStarsGateway from .yookassa import YookassaGateway from .yoomoney import YoomoneyGateway +from .platega import PlategaGateway __all__ = [ "BasePaymentGateway", @@ -13,4 +14,5 @@ "YoomoneyGateway", "CryptomusGateway", "HeleketGateway", + "PlategaGateway" ] diff --git a/src/infrastructure/payment_gateways/platega.py b/src/infrastructure/payment_gateways/platega.py new file mode 100644 index 0000000..c4372f0 --- /dev/null +++ b/src/infrastructure/payment_gateways/platega.py @@ -0,0 +1,157 @@ +import uuid +from decimal import Decimal +from typing import Any, Final +from uuid import UUID + +import orjson +from aiogram import Bot +from fastapi import Request +from httpx import AsyncClient, HTTPStatusError +from loguru import logger + +from src.core.config import AppConfig +from src.core.enums import Currency, TransactionStatus +from src.infrastructure.database.models.dto import ( + PaymentGatewayDto, + PaymentResult, + PlategaGatewaySettingsDto, +) + +from .base import BasePaymentGateway + + +class PlategaGateway(BasePaymentGateway): + _client: AsyncClient + + API_BASE: Final[str] = "https://app.platega.io" + CURRENCY = Currency.RUB + PAYMENT_METHOD: Final[int] = 2 + + def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> None: + super().__init__(gateway, bot, config) + + if not isinstance(self.data.settings, PlategaGatewaySettingsDto): + raise TypeError( + f"Invalid settings type: expected {PlategaGatewaySettingsDto.__name__}, " + f"got {type(self.data.settings).__name__}" + ) + + self._client = self._make_client( + base_url=self.API_BASE, + headers={ + "X-MerchantId": self.data.settings.merchant_id, + "X-Secret": self.data.settings.api_key.get_secret_value(), # type: ignore[union-attr] + }, + ) + + async def handle_create_payment(self, amount: Decimal, details: str) -> PaymentResult: + payload = await self._create_payment_payload(str(amount), details) + + try: + response = await self._client.post("/transaction/process", json=payload) + response.raise_for_status() + data = orjson.loads(response.content) + return self._get_payment_data(data) + + except HTTPStatusError as exception: + logger.error( + f"HTTP error creating payment. " + f"Status: '{exception.response.status_code}', Body: {exception.response.text}" + ) + raise + except (KeyError, orjson.JSONDecodeError) as exception: + logger.error(f"Failed to parse response. Error: {exception}") + raise + except Exception as exception: + logger.exception(f"An unexpected error occurred while creating payment: {exception}") + raise + + async def handle_webhook(self, request: Request) -> tuple[UUID, TransactionStatus]: + logger.debug(f"Received {self.__class__.__name__} webhook request") + + if not self._verify_webhook(request): + raise PermissionError("Webhook verification failed") + + webhook_data = await self._get_webhook_data(request) + + transaction_id_str = webhook_data.get("id") + status = webhook_data.get("status") + + if not transaction_id_str: + raise ValueError("Required field 'id' is missing") + + if not status: + raise ValueError("Required field 'status' is missing") + + transaction_id = UUID(transaction_id_str) + transaction_status = self._map_status(status) + + logger.info( + f"Platega webhook processed: " + f"transaction_id={transaction_id}, " + f"status={transaction_status}" + ) + return transaction_id, transaction_status + + async def _create_payment_payload( + self, amount: str, details: str + ) -> dict[str, Any]: + return { + "paymentMethod": self.PAYMENT_METHOD, + "paymentDetails": { + "amount": amount, + "currency": self.CURRENCY.value, + }, + "description": details, + "return": await self._get_bot_redirect_url(), + "failedUrl": await self._get_bot_redirect_url(), + } + + def _get_payment_data(self, data: dict[str, Any]) -> PaymentResult: + transaction_id = data.get("transactionId") + + if not transaction_id: + raise KeyError("Invalid response from Platega API: missing 'transactionId'") + + redirect_url = data.get("redirect") + + if not redirect_url: + raise KeyError("Invalid response from Platega API: missing 'redirect'") + + return PaymentResult(id=UUID(transaction_id), url=str(redirect_url)) + + def _verify_webhook(self, request: Request) -> bool: + merchant_id = request.headers.get("X-MerchantId") + secret = request.headers.get("X-Secret") + + if not merchant_id: + logger.warning("Missing X-MerchantId header") + return False + + if not secret: + logger.warning("Missing X-Secret header") + return False + + if merchant_id != self.data.settings.merchant_id: # type: ignore[union-attr] + logger.warning( + f"Invalid X-MerchantId: " + f"expected {self.data.settings.merchant_id}, got {merchant_id}" # type: ignore[union-attr] + ) + return False + + if secret != self.data.settings.api_key.get_secret_value(): # type: ignore[union-attr] + logger.warning("Invalid X-Secret") + return False + + return True + + def _map_status(self, status: str) -> TransactionStatus: + status_mapping = { + "CONFIRMED": TransactionStatus.COMPLETED, + "CANCELED": TransactionStatus.CANCELED + } + + if status not in status_mapping: + raise ValueError(f"Unsupported status: {status}") + + return status_mapping[status] diff --git a/src/services/payment_gateway.py b/src/services/payment_gateway.py index 539414c..8b3080c 100644 --- a/src/services/payment_gateway.py +++ b/src/services/payment_gateway.py @@ -33,6 +33,7 @@ PlanSnapshotDto, PriceDetailsDto, RobokassaGatewaySettingsDto, + PlategaGatewaySettingsDto, TransactionDto, UserDto, YookassaGatewaySettingsDto, @@ -103,6 +104,9 @@ async def create_default(self) -> None: case PaymentGatewayType.HELEKET: is_active = False settings = HeleketGatewaySettingsDto() + case PaymentGatewayType.PLATEGA: + is_active = False + settings = PlategaGatewaySettingsDto() # case PaymentGatewayType.CRYPTOPAY: # is_active = False # settings = CryptopayGatewaySettingsDto()