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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user