diff --git a/app/cabinet/routes/admin_landings.py b/app/cabinet/routes/admin_landings.py index 5325db85..043a0aa7 100644 --- a/app/cabinet/routes/admin_landings.py +++ b/app/cabinet/routes/admin_landings.py @@ -7,6 +7,10 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field, field_validator from sqlalchemy.ext.asyncio import AsyncSession +from app.cabinet.utils.locale import ( + ensure_locale_dict, + validate_locale_dict, +) from app.database.crud.landing import ( create_landing, delete_landing, @@ -48,8 +52,23 @@ _RESERVED_SLUGS = frozenset( class LandingFeatureInput(BaseModel): icon: str = Field(default='', max_length=100) - title: str = Field(default='', max_length=200) - description: str = Field(default='', max_length=500) + title: dict[str, str] = Field(default_factory=dict) + description: dict[str, str] = Field(default_factory=dict) + + @field_validator('title', 'description', mode='before') + @classmethod + def coerce_to_dict(cls, v: dict[str, str] | str | None) -> dict[str, str]: + return ensure_locale_dict(v) + + @field_validator('title') + @classmethod + def validate_title_length(cls, v: dict[str, str]) -> dict[str, str]: + return validate_locale_dict(v, max_length=200, field_name='feature.title') + + @field_validator('description') + @classmethod + def validate_description_length(cls, v: dict[str, str]) -> dict[str, str]: + return validate_locale_dict(v, max_length=500, field_name='feature.description') class LandingPaymentMethodInput(BaseModel): @@ -71,34 +90,116 @@ class LandingPaymentMethodInput(BaseModel): class LandingCreateRequest(BaseModel): slug: str = Field(pattern=r'^[a-z0-9\-]+$', min_length=1, max_length=100) - title: str = Field(min_length=1, max_length=500) - subtitle: str | None = Field(default=None, max_length=1000) + title: dict[str, str] = Field(default_factory=lambda: {'ru': ''}) + subtitle: dict[str, str] | None = None is_active: bool = True features: list[LandingFeatureInput] = Field(default_factory=list, max_length=20) - footer_text: str | None = Field(default=None, max_length=5000) + footer_text: dict[str, str] | None = None allowed_tariff_ids: list[int] = Field(default_factory=list) allowed_periods: dict[str, list[int]] = Field(default_factory=dict) payment_methods: list[LandingPaymentMethodInput] = Field(default_factory=list, max_length=10) gift_enabled: bool = True custom_css: str | None = Field(default=None, max_length=10000) - meta_title: str | None = Field(default=None, max_length=200) - meta_description: str | None = Field(default=None, max_length=500) + meta_title: dict[str, str] | None = None + meta_description: dict[str, str] | None = None + + @field_validator('title', 'subtitle', 'footer_text', 'meta_title', 'meta_description', mode='before') + @classmethod + def coerce_text_to_dict(cls, v: dict[str, str] | str | None) -> dict[str, str] | None: + if v is None: + return None + return ensure_locale_dict(v) + + @field_validator('title') + @classmethod + def validate_title(cls, v: dict[str, str]) -> dict[str, str]: + return validate_locale_dict(v, max_length=500, field_name='title') + + @field_validator('subtitle') + @classmethod + def validate_subtitle(cls, v: dict[str, str] | None) -> dict[str, str] | None: + if v is None: + return None + return validate_locale_dict(v, max_length=1000, field_name='subtitle') + + @field_validator('footer_text') + @classmethod + def validate_footer_text(cls, v: dict[str, str] | None) -> dict[str, str] | None: + if v is None: + return None + return validate_locale_dict(v, max_length=5000, field_name='footer_text') + + @field_validator('meta_title') + @classmethod + def validate_meta_title(cls, v: dict[str, str] | None) -> dict[str, str] | None: + if v is None: + return None + return validate_locale_dict(v, max_length=200, field_name='meta_title') + + @field_validator('meta_description') + @classmethod + def validate_meta_description(cls, v: dict[str, str] | None) -> dict[str, str] | None: + if v is None: + return None + return validate_locale_dict(v, max_length=500, field_name='meta_description') class LandingUpdateRequest(BaseModel): slug: str | None = Field(default=None, pattern=r'^[a-z0-9\-]+$', min_length=1, max_length=100) - title: str | None = Field(default=None, min_length=1, max_length=500) - subtitle: str | None = Field(default=None, max_length=1000) + title: dict[str, str] | None = None + subtitle: dict[str, str] | None = None is_active: bool | None = None features: list[LandingFeatureInput] | None = Field(default=None, max_length=20) - footer_text: str | None = Field(default=None, max_length=5000) + footer_text: dict[str, str] | None = None allowed_tariff_ids: list[int] | None = None allowed_periods: dict[str, list[int]] | None = None payment_methods: list[LandingPaymentMethodInput] | None = Field(default=None, max_length=10) gift_enabled: bool | None = None custom_css: str | None = Field(default=None, max_length=10000) - meta_title: str | None = Field(default=None, max_length=200) - meta_description: str | None = Field(default=None, max_length=500) + meta_title: dict[str, str] | None = None + meta_description: dict[str, str] | None = None + + @field_validator('title', 'subtitle', 'footer_text', 'meta_title', 'meta_description', mode='before') + @classmethod + def coerce_text_to_dict(cls, v: dict[str, str] | str | None) -> dict[str, str] | None: + if v is None: + return None + return ensure_locale_dict(v) + + @field_validator('title') + @classmethod + def validate_title(cls, v: dict[str, str] | None) -> dict[str, str] | None: + if v is None: + return None + return validate_locale_dict(v, max_length=500, field_name='title') + + @field_validator('subtitle') + @classmethod + def validate_subtitle(cls, v: dict[str, str] | None) -> dict[str, str] | None: + if v is None: + return None + return validate_locale_dict(v, max_length=1000, field_name='subtitle') + + @field_validator('footer_text') + @classmethod + def validate_footer_text(cls, v: dict[str, str] | None) -> dict[str, str] | None: + if v is None: + return None + return validate_locale_dict(v, max_length=5000, field_name='footer_text') + + @field_validator('meta_title') + @classmethod + def validate_meta_title(cls, v: dict[str, str] | None) -> dict[str, str] | None: + if v is None: + return None + return validate_locale_dict(v, max_length=200, field_name='meta_title') + + @field_validator('meta_description') + @classmethod + def validate_meta_description(cls, v: dict[str, str] | None) -> dict[str, str] | None: + if v is None: + return None + return validate_locale_dict(v, max_length=500, field_name='meta_description') class PurchaseStats(BaseModel): @@ -113,7 +214,7 @@ class PurchaseStats(BaseModel): class LandingListItem(BaseModel): id: int slug: str - title: str + title: dict[str, str] is_active: bool display_order: int gift_enabled: bool @@ -123,6 +224,11 @@ class LandingListItem(BaseModel): created_at: datetime | None = None updated_at: datetime | None = None + @field_validator('title', mode='before') + @classmethod + def coerce_title(cls, v: dict[str, str] | str | None) -> dict[str, str]: + return ensure_locale_dict(v) + class Config: from_attributes = True @@ -130,22 +236,29 @@ class LandingListItem(BaseModel): class LandingDetailResponse(BaseModel): id: int slug: str - title: str - subtitle: str | None = None + title: dict[str, str] + subtitle: dict[str, str] | None = None is_active: bool display_order: int features: list[LandingFeatureInput] - footer_text: str | None = None + footer_text: dict[str, str] | None = None allowed_tariff_ids: list[int] allowed_periods: dict[str, list[int]] payment_methods: list[LandingPaymentMethodInput] gift_enabled: bool custom_css: str | None = None - meta_title: str | None = None - meta_description: str | None = None + meta_title: dict[str, str] | None = None + meta_description: dict[str, str] | None = None created_at: datetime | None = None updated_at: datetime | None = None + @field_validator('title', 'subtitle', 'footer_text', 'meta_title', 'meta_description', mode='before') + @classmethod + def coerce_to_dict(cls, v: dict[str, str] | str | None) -> dict[str, str] | None: + if v is None: + return None + return ensure_locale_dict(v) + class Config: from_attributes = True @@ -358,12 +471,15 @@ async def toggle_landing_active( def _landing_to_detail(landing) -> LandingDetailResponse: - """Convert a LandingPage model to LandingDetailResponse.""" + """Convert a LandingPage model to LandingDetailResponse. + + Admin detail view returns full locale dicts for all text fields. + """ features = [ LandingFeatureInput( icon=f.get('icon', ''), - title=f.get('title', ''), - description=f.get('description', ''), + title=f.get('title', {}), + description=f.get('description', {}), ) for f in (landing.features or []) ] @@ -382,7 +498,7 @@ def _landing_to_detail(landing) -> LandingDetailResponse: return LandingDetailResponse( id=landing.id, slug=landing.slug, - title=landing.title, + title=landing.title or {}, subtitle=landing.subtitle, is_active=landing.is_active, display_order=landing.display_order, diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py index 61b1648d..73ca2b45 100644 --- a/app/cabinet/routes/landing.py +++ b/app/cabinet/routes/landing.py @@ -4,11 +4,12 @@ import re from datetime import UTC, datetime, timedelta import structlog -from fastapi import APIRouter, Depends, HTTPException, Path, Request, status +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status from pydantic import BaseModel, Field, model_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.cabinet.utils.locale import DEFAULT_LOCALE, resolve_locale_text from app.config import settings from app.database.crud.landing import get_active_landing_by_slug, get_purchase_by_token from app.database.models import Tariff @@ -128,6 +129,18 @@ class PurchaseStatusResponse(BaseModel): # ============ Helpers ============ +def _mask_contact(value: str) -> str: + """Mask contact value to avoid leaking PII in API responses.""" + if '@' in value and not value.startswith('@'): + # Email: show first 2 chars + mask + domain + local, domain = value.rsplit('@', 1) + return f'{local[:2]}***@{domain}' + if value.startswith('@'): + # Telegram: show first 3 chars + mask + return f'{value[:3]}***' + return value[:3] + '***' + + def _period_label(days: int) -> str: """Human-readable label for a period in days.""" if days == 1: @@ -256,12 +269,14 @@ async def get_purchase_status( subscription_url = purchase.subscription_url subscription_crypto_link = purchase.subscription_crypto_link + masked_contact = _mask_contact(purchase.contact_value) if purchase.contact_value else None + return PurchaseStatusResponse( status=purchase.status, subscription_url=subscription_url, subscription_crypto_link=subscription_crypto_link, is_gift=purchase.is_gift, - contact_value=purchase.contact_value, + contact_value=masked_contact, period_days=purchase.period_days, tariff_name=tariff_name, ) @@ -269,13 +284,19 @@ async def get_purchase_status( @router.get('/{slug}', response_model=LandingConfigResponse) async def get_landing_config( + raw_request: Request, slug: str = Path(max_length=100), + lang: str = Query(DEFAULT_LOCALE, max_length=5, description='Locale: ru, en, zh, fa'), db: AsyncSession = Depends(get_cabinet_db), ): """Get public landing page configuration with tariffs and payment methods. - No authentication required. + No authentication required. Pass ``?lang=en`` to get localized text. """ + client_ip = get_client_ip(raw_request) + if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_config', limit=60, window=60, fail_closed=True): + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests') + landing = await get_active_landing_by_slug(db, slug) if landing is None: raise HTTPException( @@ -298,27 +319,28 @@ async def get_landing_config( for m in raw_methods ] + # Resolve locale dicts to flat strings for the requested language features = [ LandingFeature( icon=f.get('icon', ''), - title=f.get('title', ''), - description=f.get('description', ''), + title=resolve_locale_text(f.get('title'), lang), + description=resolve_locale_text(f.get('description'), lang), ) for f in (landing.features or []) ] return LandingConfigResponse( slug=landing.slug, - title=landing.title, - subtitle=landing.subtitle, + title=resolve_locale_text(landing.title, lang), + subtitle=resolve_locale_text(landing.subtitle, lang) or None, features=features, - footer_text=landing.footer_text, + footer_text=resolve_locale_text(landing.footer_text, lang) or None, tariffs=tariffs, payment_methods=payment_methods, gift_enabled=landing.gift_enabled, custom_css=landing.custom_css, - meta_title=landing.meta_title, - meta_description=landing.meta_description, + meta_title=resolve_locale_text(landing.meta_title, lang) or None, + meta_description=resolve_locale_text(landing.meta_description, lang) or None, ) diff --git a/app/cabinet/utils/locale.py b/app/cabinet/utils/locale.py new file mode 100644 index 00000000..aef46ba2 --- /dev/null +++ b/app/cabinet/utils/locale.py @@ -0,0 +1,43 @@ +"""Locale resolution utilities for multi-locale landing page text fields.""" + +SUPPORTED_LOCALES: tuple[str, ...] = ('ru', 'en', 'zh', 'fa') +DEFAULT_LOCALE: str = 'ru' + + +def resolve_locale_text(data: dict[str, str] | str | None, lang: str = DEFAULT_LOCALE) -> str: + """Resolve a localized text dict to a single string for the given language. + + Fallback chain: requested lang -> 'ru' -> 'en' -> first available value -> ''. + Accepts plain strings for backward compatibility with pre-migration data. + """ + if data is None: + return '' + if isinstance(data, str): + return data + return data.get(lang) or data.get('ru') or data.get('en') or next(iter(data.values()), '') + + +def ensure_locale_dict(value: dict[str, str] | str | None) -> dict[str, str]: + """Coerce a value to a locale dict. Plain strings become ``{'ru': value}``.""" + if value is None: + return {} + if isinstance(value, str): + return {'ru': value} if value else {} + return value + + +def validate_locale_dict( + value: dict[str, str], + *, + max_length: int | None = None, + field_name: str = 'field', +) -> dict[str, str]: + """Validate that all keys are supported locales and values respect length limits.""" + for locale, text in value.items(): + if locale not in SUPPORTED_LOCALES: + raise ValueError(f'Unsupported locale "{locale}" in {field_name}. Allowed: {", ".join(SUPPORTED_LOCALES)}') + if not isinstance(text, str): + raise ValueError(f'{field_name}[{locale}] must be a string') + if max_length is not None and len(text) > max_length: + raise ValueError(f'{field_name}[{locale}] exceeds max length {max_length} (got {len(text)})') + return value diff --git a/app/database/crud/cloudpayments.py b/app/database/crud/cloudpayments.py index bdde4694..8db0317d 100644 --- a/app/database/crud/cloudpayments.py +++ b/app/database/crud/cloudpayments.py @@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__) async def create_cloudpayments_payment( db: AsyncSession, *, - user_id: int, + user_id: int | None, invoice_id: str, amount_kopeks: int, description: str | None = None, diff --git a/app/database/crud/cryptobot.py b/app/database/crud/cryptobot.py index 566e7e21..85c01daa 100644 --- a/app/database/crud/cryptobot.py +++ b/app/database/crud/cryptobot.py @@ -13,7 +13,7 @@ logger = structlog.get_logger(__name__) async def create_cryptobot_payment( db: AsyncSession, - user_id: int, + user_id: int | None, invoice_id: str, amount: str, asset: str, diff --git a/app/database/crud/freekassa.py b/app/database/crud/freekassa.py index 8140684b..89ec00c3 100644 --- a/app/database/crud/freekassa.py +++ b/app/database/crud/freekassa.py @@ -1,6 +1,5 @@ """CRUD операции для платежей Freekassa.""" -import json from datetime import UTC, datetime import structlog @@ -16,14 +15,14 @@ logger = structlog.get_logger(__name__) async def create_freekassa_payment( db: AsyncSession, *, - user_id: int, + user_id: int | None, order_id: str, amount_kopeks: int, currency: str = 'RUB', description: str | None = None, payment_url: str | None = None, expires_at: datetime | None = None, - metadata_json: str | None = None, + metadata_json: dict | None = None, ) -> FreekassaPayment: """Создает запись о платеже Freekassa.""" payment = FreekassaPayment( @@ -34,7 +33,7 @@ async def create_freekassa_payment( description=description, payment_url=payment_url, expires_at=expires_at, - metadata_json=json.loads(metadata_json) if metadata_json else None, + metadata_json=metadata_json, status='pending', is_paid=False, ) diff --git a/app/database/crud/heleket.py b/app/database/crud/heleket.py index ad226e63..c66059d0 100644 --- a/app/database/crud/heleket.py +++ b/app/database/crud/heleket.py @@ -15,7 +15,7 @@ logger = structlog.get_logger(__name__) async def create_heleket_payment( db: AsyncSession, *, - user_id: int, + user_id: int | None, uuid: str, order_id: str, amount: str, diff --git a/app/database/crud/kassa_ai.py b/app/database/crud/kassa_ai.py index 16c4112b..7d9e3d8e 100644 --- a/app/database/crud/kassa_ai.py +++ b/app/database/crud/kassa_ai.py @@ -1,6 +1,5 @@ """CRUD операции для платежей KassaAI.""" -import json from datetime import UTC, datetime import structlog @@ -16,7 +15,7 @@ logger = structlog.get_logger(__name__) async def create_kassa_ai_payment( db: AsyncSession, *, - user_id: int, + user_id: int | None, order_id: str, amount_kopeks: int, currency: str = 'RUB', @@ -24,7 +23,7 @@ async def create_kassa_ai_payment( payment_url: str | None = None, payment_system_id: int | None = None, expires_at: datetime | None = None, - metadata_json: str | None = None, + metadata_json: dict | None = None, ) -> KassaAiPayment: """Создает запись о платеже KassaAI.""" payment = KassaAiPayment( @@ -36,7 +35,7 @@ async def create_kassa_ai_payment( payment_url=payment_url, payment_system_id=payment_system_id, expires_at=expires_at, - metadata_json=json.loads(metadata_json) if metadata_json else None, + metadata_json=metadata_json, status='pending', is_paid=False, ) diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py index 059a9850..ec59a5c1 100644 --- a/app/database/crud/mulenpay.py +++ b/app/database/crud/mulenpay.py @@ -14,7 +14,7 @@ logger = structlog.get_logger(__name__) async def create_mulenpay_payment( db: AsyncSession, *, - user_id: int, + user_id: int | None, amount_kopeks: int, uuid: str, description: str, diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py index 514d8592..dc7174ce 100644 --- a/app/database/crud/pal24.py +++ b/app/database/crud/pal24.py @@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__) async def create_pal24_payment( db: AsyncSession, *, - user_id: int, + user_id: int | None, bill_id: str, amount_kopeks: int, description: str | None, diff --git a/app/database/crud/platega.py b/app/database/crud/platega.py index ade0226a..579d8aa6 100644 --- a/app/database/crud/platega.py +++ b/app/database/crud/platega.py @@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__) async def create_platega_payment( db: AsyncSession, *, - user_id: int, + user_id: int | None, amount_kopeks: int, currency: str, description: str | None, diff --git a/app/database/crud/wata.py b/app/database/crud/wata.py index e35219c7..cdb36ff5 100644 --- a/app/database/crud/wata.py +++ b/app/database/crud/wata.py @@ -16,7 +16,7 @@ logger = structlog.get_logger(__name__) async def create_wata_payment( db: AsyncSession, *, - user_id: int, + user_id: int | None, payment_link_id: str, amount_kopeks: int, currency: str, diff --git a/app/database/models.py b/app/database/models.py index 67688299..fd5ed947 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -240,7 +240,7 @@ class CryptoBotPayment(Base): __tablename__ = 'cryptobot_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) invoice_id = Column(String(255), unique=True, nullable=False, index=True) amount = Column(String(50), nullable=False) @@ -290,7 +290,7 @@ class HeleketPayment(Base): __tablename__ = 'heleket_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) uuid = Column(String(255), unique=True, nullable=False, index=True) order_id = Column(String(128), unique=True, nullable=False, index=True) @@ -349,7 +349,7 @@ class MulenPayPayment(Base): __tablename__ = 'mulenpay_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) mulen_payment_id = Column(Integer, nullable=True, index=True) uuid = Column(String(255), unique=True, nullable=False, index=True) @@ -385,7 +385,7 @@ class Pal24Payment(Base): __tablename__ = 'pal24_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) bill_id = Column(String(255), unique=True, nullable=False, index=True) order_id = Column(String(255), nullable=True, index=True) @@ -442,7 +442,7 @@ class WataPayment(Base): __tablename__ = 'wata_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) payment_link_id = Column(String(64), unique=True, nullable=False, index=True) order_id = Column(String(255), nullable=True, index=True) @@ -485,7 +485,7 @@ class PlategaPayment(Base): __tablename__ = 'platega_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True) correlation_id = Column(String(64), unique=True, nullable=False, index=True) @@ -527,7 +527,7 @@ class CloudPaymentsPayment(Base): __tablename__ = 'cloudpayments_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) # CloudPayments идентификаторы transaction_id_cp = Column(BigInteger, unique=True, nullable=True, index=True) # TransactionId от CloudPayments @@ -596,7 +596,7 @@ class FreekassaPayment(Base): __tablename__ = 'freekassa_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) # Идентификаторы order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа @@ -658,7 +658,7 @@ class KassaAiPayment(Base): __tablename__ = 'kassa_ai_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) # Идентификаторы order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа @@ -2998,17 +2998,17 @@ class LandingPage(Base): id = Column(Integer, primary_key=True, index=True) slug = Column(String(100), unique=True, nullable=False, index=True) is_active = Column(Boolean, nullable=False, default=True) - title = Column(String(500), nullable=False, default='') - subtitle = Column(Text, nullable=True) + title = Column(JSON, nullable=False, default=dict) + subtitle = Column(JSON, nullable=True) features = Column(JSON, nullable=False, default=list) - footer_text = Column(Text, nullable=True) + footer_text = Column(JSON, nullable=True) allowed_tariff_ids = Column(JSON, nullable=False, default=list) allowed_periods = Column(JSON, nullable=False, default=dict) payment_methods = Column(JSON, nullable=False, default=list) gift_enabled = Column(Boolean, nullable=False, default=True) custom_css = Column(Text, nullable=True) - meta_title = Column(String(200), nullable=True) - meta_description = Column(Text, nullable=True) + meta_title = Column(JSON, nullable=True) + meta_description = Column(JSON, nullable=True) display_order = Column(Integer, nullable=False, default=0) created_at = Column(AwareDateTime(), server_default=func.now()) updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now()) diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index 49daac40..383f9d66 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -21,7 +21,7 @@ class CloudPaymentsPaymentMixin: async def create_cloudpayments_payment( self, db: AsyncSession, - user_id: int, + user_id: int | None, amount_kopeks: int, description: str, *, @@ -212,6 +212,27 @@ class CloudPaymentsPaymentMixin: ) return False + # --- Guest purchase flow (landing page) --- + cp_metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=cp_metadata, + payment_amount_kopeks=amount_kopeks, + provider_payment_id=str(transaction_id_cp) if transaction_id_cp else invoice_id, + provider_name='cloudpayments', + ) + if guest_result is not None: + # Update payment record even for guest purchases + payment.transaction_id_cp = transaction_id_cp + payment.status = 'completed' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.callback_payload = webhook_data + await db.flush() + return True + # Update payment record payment.transaction_id_cp = transaction_id_cp payment.status = 'completed' diff --git a/app/services/payment/common.py b/app/services/payment/common.py index 59eab525..dfcf7934 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -399,3 +399,116 @@ async def send_cart_notification_after_topup( ) return sent + + +# --------------------------------------------------------------------------- +# Guest purchase fulfillment (shared across all payment providers) +# --------------------------------------------------------------------------- + + +def _extract_guest_purchase_token(metadata: dict[str, Any] | None) -> str | None: + """Return the purchase_token if the payment belongs to a guest purchase, else None.""" + if not isinstance(metadata, dict): + return None + if metadata.get('purpose') != 'guest_purchase': + return None + return metadata.get('purchase_token') or None + + +async def try_fulfill_guest_purchase( + db: AsyncSession, + *, + metadata: dict[str, Any] | None, + payment_amount_kopeks: int, + provider_payment_id: str, + provider_name: str, + skip_amount_check: bool = False, +) -> bool | None: + """Attempt to fulfill a guest purchase detected in payment metadata. + + Args: + skip_amount_check: If True, skip the webhook/purchase amount comparison. + Useful for providers like CryptoBot where currency conversion + introduces imprecision. + + Returns: + ``True`` -- guest purchase was detected and successfully fulfilled. + ``False`` -- guest purchase was detected but fulfillment failed. + ``None`` -- this is NOT a guest purchase (caller should proceed normally). + """ + purchase_token = _extract_guest_purchase_token(metadata) + if purchase_token is None: + return None + + from app.database.crud.landing import get_purchase_by_token, update_purchase_status + from app.database.models import GuestPurchaseStatus + from app.services.guest_purchase_service import fulfill_purchase + + try: + existing = await get_purchase_by_token(db, purchase_token) + + # Verify amount (skip for providers with currency conversion imprecision) + if existing and not skip_amount_check and payment_amount_kopeks != existing.amount_kopeks: + logger.error( + 'Webhook amount does not match guest purchase amount', + webhook_kopeks=payment_amount_kopeks, + purchase_kopeks=existing.amount_kopeks, + purchase_token_prefix=purchase_token[:5], + provider=provider_name, + ) + await update_purchase_status(db, purchase_token, GuestPurchaseStatus.FAILED) + return True # consumed, even though failed + + # Idempotency: skip terminal states + if existing and existing.status in ( + GuestPurchaseStatus.DELIVERED.value, + GuestPurchaseStatus.FAILED.value, + ): + logger.info( + 'Guest purchase already in terminal state, skipping', + purchase_token_prefix=purchase_token[:5], + status=existing.status, + provider=provider_name, + ) + await db.commit() + return True + + # Mark as PAID (no commit -- let fulfill_purchase do atomic commit) + await update_purchase_status( + db, + purchase_token, + GuestPurchaseStatus.PAID, + commit=False, + payment_id=provider_payment_id, + paid_at=datetime.now(UTC), + ) + + # Fulfill: create user, subscription, deliver (commits on success) + await fulfill_purchase(db, purchase_token) + + logger.info( + 'Guest purchase fulfilled', + provider_payment_id=provider_payment_id, + purchase_token_prefix=purchase_token[:5], + provider=provider_name, + ) + return True + + except Exception as guest_error: + await db.rollback() + logger.exception( + 'Error fulfilling guest purchase from webhook', + provider_payment_id=provider_payment_id, + provider=provider_name, + error=guest_error, + ) + # Mark as FAILED so it doesn't get retried forever + try: + await update_purchase_status( + db, + purchase_token, + GuestPurchaseStatus.FAILED, + ) + except Exception: + logger.exception('Failed to mark guest purchase as FAILED') + return False diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 080e5ea0..59b426c5 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -55,7 +55,7 @@ class CryptoBotPaymentMixin: async def create_cryptobot_payment( self, db: AsyncSession, - user_id: int, + user_id: int | None, amount_usd: float, asset: str = 'USDT', description: str = 'Пополнение баланса', @@ -201,6 +201,39 @@ class CryptoBotPaymentMixin: return False updated_payment = locked + # --- Guest purchase flow (landing page) --- + # CryptoBot stores guest metadata in the payload field (JSON string), + # not in metadata_json (which doesn't exist on CryptoBotPayment). + crypto_payload_str = getattr(updated_payment, 'payload', '') or '' + crypto_guest_meta: dict[str, Any] | None = None + if crypto_payload_str: + try: + import json as _json + + parsed = _json.loads(crypto_payload_str) + if isinstance(parsed, dict) and parsed.get('purpose') == 'guest_purchase': + crypto_guest_meta = parsed + except (ValueError, TypeError): + pass + + if crypto_guest_meta is not None: + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=crypto_guest_meta, + payment_amount_kopeks=0, # not used: skip_amount_check=True + provider_payment_id=invoice_id, + provider_name='cryptobot', + skip_amount_check=True, # USD->RUB conversion introduces imprecision + ) + if guest_result is not None: + locked.status = 'completed' + locked.is_paid = True + locked.paid_at = datetime.now(UTC) + await db.flush() + return True + if not updated_payment.transaction_id: amount_usd = updated_payment.amount_float diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 0ab392e5..6bca1e15 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -24,7 +24,7 @@ class FreekassaPaymentMixin: self, db: AsyncSession, *, - user_id: int, + user_id: int | None, amount_kopeks: int, description: str = 'Пополнение баланса', email: str | None = None, @@ -68,7 +68,7 @@ class FreekassaPaymentMixin: return None # Генерируем уникальный order_id - order_id = f'fk_{user_id}_{uuid.uuid4().hex[:12]}' + order_id = f'fk_{user_id or "guest"}_{uuid.uuid4().hex[:12]}' amount_rubles = amount_kopeks / 100 currency = settings.FREEKASSA_CURRENCY @@ -124,7 +124,7 @@ class FreekassaPaymentMixin: description=description, payment_url=payment_url, expires_at=expires_at, - metadata_json=json.dumps(metadata, ensure_ascii=False), + metadata_json=metadata, ) logger.info( @@ -263,6 +263,20 @@ class FreekassaPaymentMixin: ) return True + # --- Guest purchase flow (landing page) --- + fk_metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=fk_metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=str(intid) if intid else payment.order_id, + provider_name='freekassa', + ) + if guest_result is not None: + return True + # Получаем пользователя user = await payment_module.get_user_by_id(db, payment.user_id) if not user: diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index e70f329f..674c10c6 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -22,7 +22,7 @@ class HeleketPaymentMixin: async def create_heleket_payment( self, db: AsyncSession, - user_id: int, + user_id: int | None, amount_kopeks: int, description: str, *, @@ -39,7 +39,7 @@ class HeleketPaymentMixin: amount_rubles = amount_kopeks / 100 amount_str = f'{amount_rubles:.2f}' - order_id = f'heleket_{user_id}_{int(time.time())}_{secrets.token_hex(3)}' + order_id = f'heleket_{user_id or "guest"}_{int(time.time())}_{secrets.token_hex(3)}' markup_percent = settings.get_heleket_markup_percent() discount_percent: int | None = None @@ -302,6 +302,21 @@ class HeleketPaymentMixin: ) return None + # --- Guest purchase flow (landing page) --- + # Re-read metadata from the locked row to avoid stale data + locked_metadata = dict(getattr(updated_payment, 'metadata_json', {}) or {}) + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=locked_metadata, + payment_amount_kopeks=amount_kopeks, + provider_payment_id=updated_payment.uuid, + provider_name='heleket', + ) + if guest_result is not None: + return updated_payment + transaction = await payment_module.create_transaction( db, user_id=updated_payment.user_id, diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index ad4f9670..94eb05ea 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import uuid from datetime import UTC, datetime, timedelta from importlib import import_module @@ -24,7 +23,7 @@ class KassaAiPaymentMixin: self, db: AsyncSession, *, - user_id: int, + user_id: int | None, amount_kopeks: int, description: str = 'Пополнение баланса', email: str | None = None, @@ -67,8 +66,11 @@ class KassaAiPaymentMixin: # Получаем telegram_id пользователя для order_id payment_module = import_module('app.services.payment_service') - user = await payment_module.get_user_by_id(db, user_id) - tg_id = user.telegram_id if user else user_id + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + else: + user = None + tg_id = user.telegram_id if user else (user_id or 'guest') # Генерируем уникальный order_id с telegram_id для удобного поиска order_id = f'k{tg_id}_{uuid.uuid4().hex[:6]}' @@ -118,7 +120,7 @@ class KassaAiPaymentMixin: payment_url=payment_url, payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID, expires_at=expires_at, - metadata_json=json.dumps(metadata, ensure_ascii=False), + metadata_json=metadata, ) logger.info( @@ -249,6 +251,20 @@ class KassaAiPaymentMixin: ) return True + # --- Guest purchase flow (landing page) --- + kai_metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=kai_metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=str(intid) if intid else payment.order_id, + provider_name='kassa_ai', + ) + if guest_result is not None: + return True + # Получаем пользователя user = await payment_module.get_user_by_id(db, payment.user_id) if not user: diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index abaee07a..efda6744 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -21,7 +21,7 @@ class MulenPayPaymentMixin: async def create_mulenpay_payment( self, db: AsyncSession, - user_id: int, + user_id: int | None, amount_kopeks: int, description: str, language: str | None = None, @@ -53,7 +53,7 @@ class MulenPayPaymentMixin: payment_module = import_module('app.services.payment_service') try: - payment_uuid = f'mulen_{user_id}_{uuid.uuid4().hex}' + payment_uuid = f'mulen_{user_id or "guest"}_{uuid.uuid4().hex}' amount_rubles = amount_kopeks / 100 items = [ @@ -237,6 +237,20 @@ class MulenPayPaymentMixin: logger.info('Для платежа уже создана транзакция', display_name=display_name, uuid=payment.uuid) return True + # --- Guest purchase flow (landing page) --- + payment_meta = dict(getattr(payment, 'metadata_json', {}) or {}) + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=payment_meta, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.uuid, + provider_name='mulenpay', + ) + if guest_result is not None: + return True + payment_description = getattr( payment, 'description', diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 680349bb..4dd2973f 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -23,7 +23,7 @@ class Pal24PaymentMixin: self, db: AsyncSession, *, - user_id: int, + user_id: int | None, amount_kopeks: int, description: str, language: str, @@ -53,7 +53,7 @@ class Pal24PaymentMixin: ) return None - order_id = f'pal24_{user_id}_{uuid.uuid4().hex}' + order_id = f'pal24_{user_id or "guest"}_{uuid.uuid4().hex}' custom_payload = { 'user_id': user_id, @@ -357,6 +357,20 @@ class Pal24PaymentMixin: logger.info('Pal24 платеж уже привязан к транзакции (trigger=)', bill_id=payment.bill_id, trigger=trigger) return True + # --- Guest purchase flow (landing page) --- + payment_meta = dict(getattr(payment, 'metadata_json', {}) or {}) + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=payment_meta, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.bill_id, + provider_name='pal24', + ) + if guest_result is not None: + return True + user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error( diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index c4d9d7c5..6d112004 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -27,7 +27,7 @@ class PlategaPaymentMixin: self, db: AsyncSession, *, - user_id: int, + user_id: int | None, amount_kopeks: int, description: str, language: str, @@ -292,6 +292,20 @@ class PlategaPaymentMixin: # Read fresh metadata AFTER lock to avoid stale data metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + + # --- Guest purchase flow (landing page) --- + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.correlation_id, + provider_name='platega', + ) + if guest_result is not None: + return payment + if payload is not None: metadata['webhook'] = payload diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 1b4c5f80..c555d961 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -68,7 +68,7 @@ class WataPaymentMixin: async def create_wata_payment( self, db: AsyncSession, - user_id: int, + user_id: int | None, amount_kopeks: int, description: str, *, @@ -98,7 +98,10 @@ class WataPaymentMixin: # Добавляем идентификатор плательщика (telegram_id или email) в описание try: - user = await payment_module.get_user_by_id(db, user_id) + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + else: + user = None if user: if user.telegram_id: description = f'{description} | ID: {user.telegram_id}' @@ -107,7 +110,7 @@ class WataPaymentMixin: except Exception as error: logger.debug('Не удалось получить данные пользователя для описания WATA', error=error) - order_id = f'wata_{user_id}_{uuid.uuid4().hex[:12]}' + order_id = f'wata_{user_id or "guest"}_{uuid.uuid4().hex[:12]}' try: response = await self.wata_service.create_payment_link( # type: ignore[union-attr] @@ -438,6 +441,20 @@ class WataPaymentMixin: ) return payment + # --- Guest purchase flow (landing page) --- + wata_metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=wata_metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=payment.payment_link_id, + provider_name='wata', + ) + if guest_result is not None: + return payment + user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error('Пользователь не найден при обработке WATA', user_id=payment.user_id) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 26626da3..2d9d00db 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -569,83 +569,19 @@ class YooKassaPaymentMixin: return True # --- Guest purchase flow (landing page) --------------------------- - if payment_metadata.get('purpose') == 'guest_purchase': - purchase_token = payment_metadata.get('purchase_token') - if purchase_token: - try: - from app.database.crud.landing import get_purchase_by_token, update_purchase_status - from app.database.models import GuestPurchaseStatus - from app.services.guest_purchase_service import fulfill_purchase + webhook_amount_value = event_object.get('amount', {}).get('value', '0') + webhook_amount_kopeks = int(Decimal(str(webhook_amount_value)) * 100) - # Verify webhook amount matches the stored purchase amount - webhook_amount_value = event_object.get('amount', {}).get('value', '0') - webhook_amount_kopeks = int(Decimal(str(webhook_amount_value)) * 100) - - existing = await get_purchase_by_token(db, purchase_token) - if existing and webhook_amount_kopeks != existing.amount_kopeks: - logger.error( - 'Webhook amount does not match purchase amount', - webhook_kopeks=webhook_amount_kopeks, - purchase_kopeks=existing.amount_kopeks, - purchase_token_prefix=purchase_token[:5], - ) - await update_purchase_status(db, purchase_token, GuestPurchaseStatus.FAILED) - return True - - # Idempotency: check if already in terminal state - if existing and existing.status in ( - GuestPurchaseStatus.DELIVERED.value, - GuestPurchaseStatus.FAILED.value, - ): - logger.info( - 'Guest purchase already in terminal state, skipping', - purchase_token_prefix=purchase_token[:5], - status=existing.status, - ) - await db.commit() - return True - - # Mark as PAID without committing — let fulfill_purchase do atomic commit - await update_purchase_status( - db, - purchase_token, - GuestPurchaseStatus.PAID, - commit=False, - payment_id=payment.yookassa_payment_id, - paid_at=datetime.now(UTC), - ) - - # Fulfill: create user, subscription, deliver (commits on success) - await fulfill_purchase(db, purchase_token) - - logger.info( - 'Guest purchase fulfilled via YooKassa', - yookassa_payment_id=payment.yookassa_payment_id, - purchase_token_prefix=purchase_token[:5], - ) - except Exception as guest_error: - await db.rollback() - logger.exception( - 'Error fulfilling guest purchase from YooKassa webhook', - yookassa_payment_id=payment.yookassa_payment_id, - error=guest_error, - ) - # Mark as FAILED so it doesn't get retried forever - try: - await update_purchase_status( - db, - purchase_token, - GuestPurchaseStatus.FAILED, - ) - except Exception: - logger.exception('Failed to mark guest purchase as FAILED') - else: - logger.error( - 'Guest purchase metadata missing purchase_token', - yookassa_payment_id=payment.yookassa_payment_id, - ) - await db.commit() + from app.services.payment.common import try_fulfill_guest_purchase + guest_result = await try_fulfill_guest_purchase( + db, + metadata=payment_metadata, + payment_amount_kopeks=webhook_amount_kopeks, + provider_payment_id=payment.yookassa_payment_id, + provider_name='yookassa', + ) + if guest_result is not None: return True # --- Standard user payment flow ------------------------------------ diff --git a/app/services/payment_service.py b/app/services/payment_service.py index fe027e44..e3109813 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from importlib import import_module from typing import Any @@ -35,7 +36,7 @@ from app.services.payment.kassa_ai import KassaAiPaymentMixin from app.services.platega_service import PlategaService from app.services.wata_service import WataService from app.services.yookassa_service import YooKassaService -from app.utils.currency_converter import currency_converter # noqa: F401 +from app.utils.currency_converter import currency_converter logger = structlog.get_logger(__name__) @@ -294,6 +295,13 @@ async def update_cloudpayments_payment(*args, **kwargs): return await cloudpayments_crud.update_cloudpayments_payment(*args, **kwargs) +# Mapping from model_name to getter function name for providers +# where it differs from the standard get_{model_name}_payment_by_id pattern. +_GETTER_OVERRIDES: dict[str, str] = { + 'mulenpay': 'get_mulenpay_payment_by_local_id', +} + + class PaymentService( PaymentCommonMixin, TelegramStarsMixin, @@ -381,12 +389,36 @@ class PaymentService( success, or ``None`` when the requested provider is unavailable or the creation call fails. """ - guest_metadata = { + guest_metadata: dict[str, Any] = { 'purpose': 'guest_purchase', 'purchase_token': purchase_token, 'source': 'landing', } + async def _patch_guest_metadata(local_payment_id: int, model_name: str) -> None: + """Merge guest_metadata into the local payment record's metadata_json.""" + try: + crud_module = import_module(f'app.database.crud.{model_name}') + getter_name = _GETTER_OVERRIDES.get(model_name, f'get_{model_name}_payment_by_id') + getter = getattr(crud_module, getter_name, None) + if getter is None: + logger.warning('No getter found for patching guest metadata', model_name=model_name, getter_name=getter_name) + return + payment_record = await getter(db, local_payment_id) + if payment_record is None: + return + existing_meta = dict(getattr(payment_record, 'metadata_json', None) or {}) + existing_meta.update(guest_metadata) + payment_record.metadata_json = existing_meta + await db.commit() + except Exception as patch_error: + logger.warning( + 'Failed to patch guest metadata into payment record', + model_name=model_name, + local_payment_id=local_payment_id, + error=patch_error, + ) + # --- YooKassa (card / sbp) ------------------------------------------- if payment_method in ('yookassa', 'yookassa_sbp'): if self.yookassa_service is None: @@ -422,17 +454,234 @@ class PaymentService( } return None - # --- Other providers: placeholder for future integration -------------- - # TODO: Add per-provider branches following the same pattern as above. - # Each branch should: - # 1. Check that the corresponding service is initialised (not None). - # 2. Call the provider-specific ``create_*_payment`` mixin method, - # passing ``guest_metadata`` so the purchase_token is persisted. - # 3. Return a dict with ``payment_url``, ``payment_id``, ``provider``. + # --- CryptoBot -------------------------------------------------------- + if payment_method == 'cryptobot': + if self.cryptobot_service is None: + logger.warning('CryptoBot is not enabled, cannot create guest payment') + return None + amount_rubles = amount_kopeks / 100 + try: + amount_usd = await currency_converter.rub_to_usd(amount_rubles) + except Exception as conv_error: + logger.error('Currency conversion failed for CryptoBot guest payment', error=conv_error) + return None + + # Encode guest metadata into the payload string (CryptoBot uses payload, not metadata dict) + payload_str = json.dumps(guest_metadata, ensure_ascii=False) + + result = await self.create_cryptobot_payment( + db=db, + user_id=None, + amount_usd=amount_usd, + asset='USDT', + description=description, + payload=payload_str, + ) + if result: + # CryptoBot stores guest_metadata in the payload field (no metadata_json column) + payment_url = result.get('bot_invoice_url') or result.get('mini_app_invoice_url') + return { + 'payment_url': payment_url, + 'payment_id': result.get('invoice_id'), + 'provider': 'cryptobot', + } + return None + + # --- Heleket ---------------------------------------------------------- + if payment_method == 'heleket': + if self.heleket_service is None: + logger.warning('Heleket is not enabled, cannot create guest payment') + return None + + result = await self.create_heleket_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'heleket') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('uuid'), + 'provider': 'heleket', + } + return None + + # --- MulenPay --------------------------------------------------------- + if payment_method == 'mulenpay': + if self.mulenpay_service is None: + logger.warning('MulenPay is not enabled, cannot create guest payment') + return None + + result = await self.create_mulenpay_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'mulenpay') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('uuid'), + 'provider': 'mulenpay', + } + return None + + # --- Pal24 (PayPalych) ------------------------------------------------ + if payment_method in ('pal24', 'pal24_sbp', 'pal24_card'): + if self.pal24_service is None: + logger.warning('Pal24 is not enabled, cannot create guest payment') + return None + + pal24_method = 'sbp' if payment_method == 'pal24_sbp' else ( + 'card' if payment_method == 'pal24_card' else None + ) + + result = await self.create_pal24_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + language=settings.DEFAULT_LANGUAGE, + payment_method=pal24_method, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'pal24') + return { + 'payment_url': result.get('payment_url') or result.get('primary_url'), + 'payment_id': result.get('bill_id'), + 'provider': 'pal24', + } + return None + + # --- Platega ---------------------------------------------------------- + if payment_method.startswith('platega'): + if self.platega_service is None: + logger.warning('Platega is not enabled, cannot create guest payment') + return None + + # Extract method code: "platega_2" -> 2, "platega" -> first active method + method_code: int | None = None + if '_' in payment_method: + suffix = payment_method.split('_', 1)[1] + try: + method_code = int(suffix) + except ValueError: + pass + + if method_code is None: + active_methods = settings.get_platega_active_methods() + method_code = active_methods[0] if active_methods else 2 + + result = await self.create_platega_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + language=settings.DEFAULT_LANGUAGE, + payment_method_code=method_code, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'platega') + return { + 'payment_url': result.get('redirect_url'), + 'payment_id': result.get('correlation_id'), + 'provider': 'platega', + } + return None + + # --- WATA ------------------------------------------------------------- + if payment_method == 'wata': + if self.wata_service is None: + logger.warning('WATA is not enabled, cannot create guest payment') + return None + + result = await self.create_wata_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'wata') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('payment_link_id'), + 'provider': 'wata', + } + return None + + # --- CloudPayments ---------------------------------------------------- + if payment_method == 'cloudpayments': + if self.cloudpayments_service is None: + logger.warning('CloudPayments is not enabled, cannot create guest payment') + return None + + result = await self.create_cloudpayments_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + ) + if result: + await _patch_guest_metadata(result['payment_id'], 'cloudpayments') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('invoice_id'), + 'provider': 'cloudpayments', + } + return None + + # --- Freekassa -------------------------------------------------------- + if payment_method in ('freekassa', 'freekassa_sbp', 'freekassa_card'): + if not settings.is_freekassa_enabled(): + logger.warning('Freekassa is not enabled, cannot create guest payment') + return None + + result = await self.create_freekassa_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + payment_method=payment_method, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'freekassa') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('order_id'), + 'provider': 'freekassa', + } + return None + + # --- KassaAI ---------------------------------------------------------- + if payment_method == 'kassa_ai': + if not settings.is_kassa_ai_enabled(): + logger.warning('KassaAI is not enabled, cannot create guest payment') + return None + + result = await self.create_kassa_ai_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'kassa_ai') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('order_id'), + 'provider': 'kassa_ai', + } + return None + + # --- Unsupported provider --------------------------------------------- logger.warning( 'Guest payment requested for unsupported provider', payment_method=payment_method, - purchase_token=purchase_token, + purchase_token_prefix=purchase_token[:5], ) return None diff --git a/migrations/alembic/versions/0021_landing_localized_texts.py b/migrations/alembic/versions/0021_landing_localized_texts.py new file mode 100644 index 00000000..61050c3e --- /dev/null +++ b/migrations/alembic/versions/0021_landing_localized_texts.py @@ -0,0 +1,187 @@ +"""convert landing_pages text fields to JSON locale dicts + +Revision ID: 0021 +Revises: 0020 +Create Date: 2026-03-06 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0021' +down_revision: Union[str, None] = '0020' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- 1. Data migration: wrap existing plain-text values into {"ru": value} --- + + # title (NOT NULL) — always wrap + op.execute(""" + UPDATE landing_pages + SET title = jsonb_build_object('ru', title) + WHERE title IS NOT NULL AND title != ''; + """) + op.execute(""" + UPDATE landing_pages + SET title = '{"ru": ""}'::jsonb + WHERE title IS NULL OR title = ''; + """) + + # subtitle (nullable) + op.execute(""" + UPDATE landing_pages + SET subtitle = jsonb_build_object('ru', subtitle) + WHERE subtitle IS NOT NULL; + """) + + # footer_text (nullable) + op.execute(""" + UPDATE landing_pages + SET footer_text = jsonb_build_object('ru', footer_text) + WHERE footer_text IS NOT NULL; + """) + + # meta_title (nullable) + op.execute(""" + UPDATE landing_pages + SET meta_title = jsonb_build_object('ru', meta_title) + WHERE meta_title IS NOT NULL; + """) + + # meta_description (nullable) + op.execute(""" + UPDATE landing_pages + SET meta_description = jsonb_build_object('ru', meta_description) + WHERE meta_description IS NOT NULL; + """) + + # features — wrap title and description inside each array element + op.execute(""" + UPDATE landing_pages + SET features = ( + SELECT COALESCE(jsonb_agg( + jsonb_set( + jsonb_set( + elem, + '{title}', + jsonb_build_object('ru', COALESCE(elem->>'title', '')) + ), + '{description}', + jsonb_build_object('ru', COALESCE(elem->>'description', '')) + ) + ), '[]'::jsonb) + FROM jsonb_array_elements(features::jsonb) AS elem + ) + WHERE features IS NOT NULL + AND features::text != '[]' + AND features::text != 'null' + AND jsonb_array_length(features::jsonb) > 0; + """) + + # --- 2. ALTER COLUMN types: String/Text -> JSON --- + op.alter_column( + 'landing_pages', + 'title', + type_=sa.JSON(), + postgresql_using='title::jsonb', + server_default='{}', + nullable=False, + ) + op.alter_column( + 'landing_pages', + 'subtitle', + type_=sa.JSON(), + postgresql_using='subtitle::jsonb', + nullable=True, + ) + op.alter_column( + 'landing_pages', + 'footer_text', + type_=sa.JSON(), + postgresql_using='footer_text::jsonb', + nullable=True, + ) + op.alter_column( + 'landing_pages', + 'meta_title', + type_=sa.JSON(), + postgresql_using='meta_title::jsonb', + nullable=True, + ) + op.alter_column( + 'landing_pages', + 'meta_description', + type_=sa.JSON(), + postgresql_using='meta_description::jsonb', + nullable=True, + ) + + +def downgrade() -> None: + # --- 1. Extract 'ru' key back into plain strings --- + + # First, convert JSON columns back to text type + op.alter_column( + 'landing_pages', + 'title', + type_=sa.String(500), + postgresql_using="title->>'ru'", + server_default='', + nullable=False, + ) + op.alter_column( + 'landing_pages', + 'subtitle', + type_=sa.Text(), + postgresql_using="subtitle->>'ru'", + nullable=True, + ) + op.alter_column( + 'landing_pages', + 'footer_text', + type_=sa.Text(), + postgresql_using="footer_text->>'ru'", + nullable=True, + ) + op.alter_column( + 'landing_pages', + 'meta_title', + type_=sa.String(200), + postgresql_using="meta_title->>'ru'", + nullable=True, + ) + op.alter_column( + 'landing_pages', + 'meta_description', + type_=sa.Text(), + postgresql_using="meta_description->>'ru'", + nullable=True, + ) + + # Restore features array: extract 'ru' from nested title/description dicts + op.execute(""" + UPDATE landing_pages + SET features = ( + SELECT COALESCE(jsonb_agg( + jsonb_set( + jsonb_set( + elem, + '{title}', + to_jsonb(COALESCE(elem->'title'->>'ru', '')) + ), + '{description}', + to_jsonb(COALESCE(elem->'description'->>'ru', '')) + ) + ), '[]'::jsonb) + FROM jsonb_array_elements(features::jsonb) AS elem + ) + WHERE features IS NOT NULL + AND features::text != '[]' + AND features::text != 'null' + AND jsonb_array_length(features::jsonb) > 0; + """) diff --git a/migrations/alembic/versions/0022_payment_tables_user_id_nullable.py b/migrations/alembic/versions/0022_payment_tables_user_id_nullable.py new file mode 100644 index 00000000..4fcb46c6 --- /dev/null +++ b/migrations/alembic/versions/0022_payment_tables_user_id_nullable.py @@ -0,0 +1,42 @@ +"""make user_id nullable in all payment tables for guest purchases + +Extends 0019 (yookassa only) to all payment providers that need to +support guest (landing-page) purchases where no user exists yet. + +Revision ID: 0022 +Revises: 0021 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0022' +down_revision: Union[str, None] = '0021' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLES = [ + 'cryptobot_payments', + 'heleket_payments', + 'mulenpay_payments', + 'pal24_payments', + 'wata_payments', + 'platega_payments', + 'cloudpayments_payments', + 'freekassa_payments', + 'kassa_ai_payments', +] + + +def upgrade() -> None: + for table in _TABLES: + op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=True) + + +def downgrade() -> None: + # WARNING: Will fail if any rows have user_id=NULL (guest payments). + # Backfill required before downgrading. + for table in _TABLES: + op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=False)