feat: мультиязычные лендинги + гостевые платежи для всех провайдеров

Мультиязычность:
- Миграция 0021: текстовые поля лендингов → JSON с ключами локалей
- Утилита resolve_locale_text с fallback-цепочкой (lang → ru → en → first)
- Админ API: dict[str, str] для title/subtitle/footer/meta/features
- Публичный API: ?lang= параметр, резолвит в плоские строки
- Обратная совместимость: plain strings → {"ru": value}

Гостевые платежи:
- Миграция 0022: user_id nullable во всех платёжных таблицах
- Поддержка всех провайдеров кроме Stars
- Общий хелпер try_fulfill_guest_purchase в common.py
- YooKassa переведена на общий хелпер

Исправления по ревью:
- CryptoBot: guest fulfillment после FOR UPDATE lock
- _patch_guest_metadata: commit вместо flush
- Freekassa/KassaAI: metadata как dict вместо JSON-строки
- purchase_token маскирован в логах
- None → "guest" в order_id
- Rate limit на GET /landing/{slug}
- Маскирование contact_value
This commit is contained in:
Fringg
2026-03-06 16:10:09 +03:00
parent ef450955e6
commit 6deab7dd8c
27 changed files with 1030 additions and 166 deletions
+138 -22
View File
@@ -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,
+32 -10
View File
@@ -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,
)
+43
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+3 -4
View File
@@ -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,
)
+1 -1
View File
@@ -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,
+3 -4
View File
@@ -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,
)
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+14 -14
View File
@@ -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())
+22 -1
View File
@@ -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'
+113
View File
@@ -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
+34 -1
View File
@@ -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
+17 -3
View File
@@ -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:
+17 -2
View File
@@ -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,
+21 -5
View File
@@ -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:
+16 -2
View File
@@ -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',
+16 -2
View File
@@ -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(
+15 -1
View File
@@ -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
+20 -3
View File
@@ -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)
+11 -75
View File
@@ -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 ------------------------------------
+259 -10
View File
@@ -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
@@ -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;
""")
@@ -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)