refactor: move cryptopay env-config into module

This commit is contained in:
3252a8
2026-05-18 16:32:19 +03:00
parent 1e93d25a40
commit cceba2e98e
12 changed files with 226 additions and 99 deletions
+14
View File
@@ -1,11 +1,25 @@
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, List, Mapping, Optional, Sequence, Type
from pydantic_settings import BaseSettings, SettingsConfigDict
def provider_env_file() -> Optional[str]:
"""Resolve the env file every provider config should read.
Tests set ``PROVIDER_ENV_FILE=""`` via conftest so per-provider
BaseSettings models don't pick up real credentials from the project's
.env. Production reads from ``.env`` as usual.
"""
value = os.environ.get("PROVIDER_ENV_FILE")
if value is None:
return ".env"
return value or None
class ProviderEnvConfig(BaseSettings):
"""Base class for per-provider env-config models.
+116 -27
View File
@@ -8,6 +8,8 @@ from aiocryptopay import AioCryptoPay, Networks
from aiocryptopay.models.update import Update
from aiogram import Bot, F, Router, types
from aiohttp import web
from pydantic import Field, field_validator
from pydantic_settings import SettingsConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
@@ -17,7 +19,14 @@ from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import payment_dal
from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext
from .base import (
PaymentProviderSpec,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
)
from .shared import (
PaymentSuccessRequest,
describe_payment,
@@ -38,13 +47,54 @@ logger = logging.getLogger(__name__)
_LOG = "cryptopay"
class CryptoPayConfig(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="CRYPTOPAY_",
extra="ignore",
)
ENABLED: bool = Field(default=True)
TOKEN: Optional[str] = None
NETWORK: str = Field(default="mainnet")
CURRENCY_TYPE: str = Field(default="fiat")
ASSET: str = Field(default="RUB")
@field_validator("TOKEN", mode="before")
@classmethod
def _strip_optional(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
@property
def webhook_path(self) -> str:
return "/webhook/cryptopay"
class CryptoPayPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_CRYPTOPAY_",
extra="ignore",
)
WEBAPP_LABEL_RU: Optional[str] = None
WEBAPP_LABEL_EN: Optional[str] = None
WEBAPP_ICON: Optional[str] = None
TELEGRAM_LABEL_RU: Optional[str] = None
TELEGRAM_LABEL_EN: Optional[str] = None
TELEGRAM_EMOJI: Optional[str] = None
class CryptoPayService:
def __init__(
self,
token: Optional[str],
network: str,
bot: Bot,
settings: Settings,
config: CryptoPayConfig,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
@@ -52,14 +102,15 @@ class CryptoPayService:
):
self.bot = bot
self.settings = settings
self.config = config
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.token = token
if token:
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
self.client = AioCryptoPay(token=token, network=net)
self.token = config.TOKEN
if self.token:
net = Networks.TEST_NET if str(config.NETWORK).lower() == "testnet" else Networks.MAIN_NET
self.client = AioCryptoPay(token=self.token, network=net)
self.client.register_pay_handler(self._invoice_paid_handler)
self.configured = True
else:
@@ -97,7 +148,7 @@ class CryptoPayService:
{
"user_id": user_id,
"amount": float(amount),
"currency": self.settings.CRYPTOPAY_ASSET,
"currency": self.config.ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": (
@@ -127,13 +178,9 @@ class CryptoPayService:
try:
invoice = await self.client.create_invoice(
amount=amount,
currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE,
fiat=self.settings.CRYPTOPAY_ASSET
if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat"
else None,
asset=self.settings.CRYPTOPAY_ASSET
if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto"
else None,
currency_type=self.config.CURRENCY_TYPE,
fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None,
asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None,
description=description,
payload=payload,
)
@@ -284,8 +331,7 @@ async def pay_crypto_callback_handler(
return
if (
not settings.CRYPTOPAY_ENABLED
or not cryptopay_service
not cryptopay_service
or not getattr(cryptopay_service, "configured", False)
):
await notify_service_unavailable(callback, translator)
@@ -324,15 +370,16 @@ async def pay_crypto_callback_handler(
def create_service(ctx: ServiceFactoryContext) -> CryptoPayService:
bundle = ctx.config_for("cryptopay_service")
config = bundle.config if bundle and isinstance(bundle.config, CryptoPayConfig) else CryptoPayConfig()
return CryptoPayService(
ctx.settings.CRYPTOPAY_TOKEN,
ctx.settings.CRYPTOPAY_NETWORK,
ctx.bot,
ctx.settings,
ctx.i18n,
ctx.async_session_factory,
ctx.subscription_service,
ctx.referral_service,
bot=ctx.bot,
settings=ctx.settings,
config=config,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
)
@@ -354,6 +401,45 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return payment_link_response(payment_url=url, payment_id=None)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key, type=type_, label=label, description=description,
placeholder=placeholder, subsection="CryptoPay",
target="presentation", attr=attr,
)
for key, type_, label, description, placeholder, attr in (
("PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"),
("PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"),
("PAYMENT_CRYPTOPAY_WEBAPP_ICON", "icon", "WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Bitcoin", "WEBAPP_ICON"),
("PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"),
("PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"),
("PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI", "string", "Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"", "TELEGRAM_EMOJI"),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField("CRYPTOPAY_ENABLED", "bool", "Включена",
subsection="CryptoPay", attr="ENABLED"),
ProviderManifestField("CRYPTOPAY_TOKEN", "string", "Token",
subsection="CryptoPay", secret=True, attr="TOKEN"),
ProviderManifestField("CRYPTOPAY_NETWORK", "string", "Network",
placeholder="mainnet", subsection="CryptoPay", attr="NETWORK"),
ProviderManifestField("CRYPTOPAY_CURRENCY_TYPE", "string", "Currency type",
description="fiat or crypto.",
placeholder="fiat", subsection="CryptoPay", attr="CURRENCY_TYPE"),
ProviderManifestField("CRYPTOPAY_ASSET", "string", "Asset",
placeholder="RUB", subsection="CryptoPay", attr="ASSET"),
)
SPEC = PaymentProviderSpec(
id="cryptopay",
provider_key="cryptopay",
@@ -363,14 +449,17 @@ SPEC = PaymentProviderSpec(
webapp_icon="Bitcoin",
telegram_labels={"ru": "CryptoBot", "en": "CryptoBot"},
pending_status="pending_cryptopay",
enabled=lambda settings: settings.CRYPTOPAY_ENABLED,
enabled=lambda config: bool(getattr(config, "ENABLED", False)),
service_key="cryptopay_service",
callback_prefix="pay_crypto",
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.cryptopay_webhook_path,
webhook_path=lambda source: "/webhook/cryptopay",
webhook_route=cryptopay_webhook_route,
create_webapp_payment=create_webapp_payment,
emoji="",
telegram_emoji="",
config_class=CryptoPayConfig,
presentation_class=CryptoPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
)
+3 -2
View File
@@ -28,6 +28,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
)
from .shared import (
HttpClientMixin,
@@ -56,7 +57,7 @@ _LOG = "freekassa"
class FreeKassaConfig(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=".env",
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="FREEKASSA_",
extra="ignore",
@@ -104,7 +105,7 @@ class FreeKassaConfig(ProviderEnvConfig):
class FreeKassaPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=".env",
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_FREEKASSA_",
extra="ignore",
+3 -2
View File
@@ -25,6 +25,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
)
from .shared import (
HttpClientMixin,
@@ -60,7 +61,7 @@ class HeleketConfig(ProviderEnvConfig):
"""All Heleket-specific env vars. Lives inside the provider module."""
model_config = SettingsConfigDict(
env_file=".env",
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="HELEKET_",
extra="ignore",
@@ -118,7 +119,7 @@ class HeleketPresentation(ProviderEnvConfig):
"""Admin-tunable button text/icon overrides for Heleket."""
model_config = SettingsConfigDict(
env_file=".env",
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_HELEKET_",
extra="ignore",
+7 -2
View File
@@ -50,6 +50,11 @@ def build_provider_configs() -> Dict[str, ProviderConfigBundle]:
Specs with neither ``config_class`` nor ``presentation_class`` are skipped.
The result is cached as the process-wide bundle.
"""
from .base import provider_env_file
env_file = provider_env_file()
init_kwargs = {"_env_file": env_file} if env_file is not None else {"_env_file": None}
bundles: Dict[str, ProviderConfigBundle] = {}
seen: set[str] = set()
for spec in PAYMENT_PROVIDER_SPECS:
@@ -59,8 +64,8 @@ def build_provider_configs() -> Dict[str, ProviderConfigBundle]:
continue
seen.add(spec.service_key)
bundles[spec.service_key] = ProviderConfigBundle(
config=spec.config_class() if spec.config_class else None,
presentation=spec.presentation_class() if spec.presentation_class else None,
config=spec.config_class(**init_kwargs) if spec.config_class else None,
presentation=spec.presentation_class(**init_kwargs) if spec.presentation_class else None,
)
_provider_configs.clear()
_provider_configs.update(bundles)
+3 -2
View File
@@ -24,6 +24,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
)
from .shared import (
HttpClientMixin,
@@ -53,7 +54,7 @@ _LOG = "severpay"
class SeverPayConfig(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=".env",
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="SEVERPAY_",
extra="ignore",
@@ -89,7 +90,7 @@ class SeverPayConfig(ProviderEnvConfig):
class SeverPayPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=".env",
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_SEVERPAY_",
extra="ignore",
+3 -2
View File
@@ -27,6 +27,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
)
from .shared import (
HttpClientMixin,
@@ -58,7 +59,7 @@ _LOG = "wata"
class WataConfig(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=".env",
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="WATA_",
extra="ignore",
@@ -103,7 +104,7 @@ class WataConfig(ProviderEnvConfig):
class WataPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=".env",
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_WATA_",
extra="ignore",