1. Balance-mode gift purchase leaked full 64-char token in response.
Gateway path truncated to [:12] but balance path didn't. Fixed.
2. retry_stuck_pending_activation referenced GuestPurchase.updated_at
which doesn't exist on the model. Changed to paid_at (mirrors
retry_stuck_paid_purchases pattern).
3. clear_notifications() called db.commit() unconditionally, defeating
commit=False in replace_subscription. Added commit parameter with
default=True for backward compat, passed through from caller.
1. Truncate tokens to 12 chars in all API responses (SentGift,
PendingGift, ReceivedGift, PurchaseStatus, PurchaseResponse,
return URL) — full token no longer leaves the server
2. Status endpoint supports prefix-based token lookup
3. create_paid_subscription/replace_subscription accept commit=False
— activate_purchase now uses single atomic commit for subscription
+ purchase status update (fixes double-commit gap)
4. Bot handler uses flush() instead of commit() before svc_activate
— consistent with cabinet endpoint, allows rollback on failure
5. Add retry_stuck_pending_activation() for purchases stuck in
PENDING_ACTIVATION status (10 min threshold)
6. Add varchar_pattern_ops index for prefix queries on token column
- current_tier_name and is_current now determined by highest achieved
tier threshold instead of user's assigned promo group
- Backend update_promo_group converts threshold 0 to NULL for clean state
- Replace 501 stub with full gateway payment flow via PaymentService
- Move telegram username pre-check (DB-first) above gateway/balance branch
- Add recipient_warning column to GuestPurchase model + migration 0034
- Return warning in gift purchase status endpoint
- Add db.refresh(purchase) after commit in gateway branch
- Add source column (landing/cabinet) to track purchase origin
- Add buyer_user_id FK to link cabinet gift purchases to authenticated users
- Add GIFT_PAYMENT to TransactionType enum for balance deductions
- Add foreign_keys disambiguation to existing user relationship
- Migration 0032: adds columns, index on source, FK constraint
- Add try_resume_disabled_daily_after_topup() for instant resume when balance is topped up
- Fix all 5 resume paths to charge daily fee BEFORE activating subscription
- Remove unsafe inline auto-resume from add_user_balance() that bypassed fee charging
- Add NULL-safe is_daily_paused filter in subscription queries
- Use create_remnawave_user() instead of enable_remnawave_user() for full VPN panel sync
- Add DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP localization key (ru, en, fa, zh, ua)
- New gift_activation handler for gift_activate:{id} callback buttons
- Send Telegram notification to gift recipients with activate button
- Add skip_notification param to activate_purchase to prevent duplicates
- Fix telegram username regex minimum length (4→5 chars) in landing routes
- Add BOT_TOKEN guard in telegram gift notification sender
- Pre-resolve notification params before commit to avoid DetachedInstanceError
Migration 0001 uses Base.metadata.create_all() which creates ALL tables
from current models.py, causing subsequent migrations (0015+) to fail
with "already exists" errors when they try to re-create constraints,
indexes, columns, and tables.
Three-layer fix:
1. migrations.py: detect fresh DB (no tables) and bootstrap via
create_all() + stamp head, bypassing all migrations entirely.
2. models.py: add EmailTemplate model, CheckConstraints to LandingPage,
and indexes to GuestPurchase so create_all() produces a complete
schema identical to running all 30 migrations sequentially.
3. Idempotency guards in migrations 0015-0030: _has_unique_constraint,
_has_table, _has_index, _has_column, _has_check_constraint checks
before DDL operations, protecting against re-runs via make migrate.
Add time-bounded percentage discounts with per-tariff overrides
and countdown timer support for landing pages.
- Add 5 discount columns to LandingPage model (percent, overrides,
starts_at, ends_at, badge_text) with Alembic migrations 0027-0028
- Add DB CHECK constraints for discount_percent range and date ordering
- Add discount price calculation in validate_and_calculate() and
public landing config endpoint with consistent formula
- Add admin CRUD with Pydantic validation, cascade-clear on removal,
merged date validation on partial updates, size bounds
- Remove discount_overrides from public API (baked into prices)
- Add size limits for allowed_tariff_ids and allowed_periods
- Add external_squad_uuid column to Tariff model with Alembic migration
- Add external_squad_uuid parameter to RemnaWave API create_user/update_user
- Pass external squad from tariff to RemnaWave on subscription creation/update
- Sync external squad in monitoring service, sync service, admin user management
- Clear external squad when tariff has none (consistent across all call sites)
- Add GET /available-external-squads endpoint with UUID validation and response model
- Update tariff schemas with UUID pattern validation
- Fix db.refresh to include tariff relationship for async safety
- Add PENDING_ACTIVATION status for users with existing subscriptions
- Add activation endpoint POST /landing/activate/{token}
- Send email notifications on delivery and pending activation
- Add 3 email templates (delivered, activation required, gift received) in 5 languages
- Extract purchase status response builder to reusable helper
- Move activation logic to service layer
- Add header injection protection in email service
- Add Literal type guard for contact_type parameter
- Fix _mask_email crash on malformed input
- Pre-resolve notification params before commit to avoid DetachedInstanceError
Мультиязычность:
- Миграция 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
- Модели LandingPage и GuestPurchase + миграции 0018/0019
- CRUD для лендингов и гостевых покупок
- Публичные роуты: GET /{slug}, POST /{slug}/purchase, GET /purchase/{token}
- Админ-роуты: CRUD лендингов с RBAC (manage_landings)
- Сервис guest_purchase_service: валидация, создание, фулфилмент
- Интеграция с PaymentService (YooKassa card/SBP) для гостевых платежей
- Webhook-обработка с идемпотентностью и атомарными транзакциями
- Rate limiting на публичных эндпоинтах
- YooKassaPayment.user_id теперь nullable для гостевых платежей
- SELECT FOR UPDATE блокировка во всех 9 провайдерах (кроме YooKassa — свой паттерн)
- create_transaction(commit=False) + единый db.commit() для атомарности
- emit_transaction_side_effects() для отложенных событий после коммита
- Все link_*_payment_to_transaction используют db.flush() вместо db.commit()
- Freekassa/KassaAI: прямое присвоение transaction_id + flush вместо update_status
- MulenPay: прямая мутация balance_kopeks вместо add_user_balance
- Platega: блокировка перед чтением metadata, инлайн обновления полей
- CloudPayments: int(round(amount * 100)) для корректного округления
- Heleket добавлен в SUPPORTED_AUTO_CHECK_METHODS
- Удалены PII из логов yookassa webhook (заголовки, IP)
- UniqueConstraint(external_id, payment_method) на транзакциях + миграция 0017
- Cabinet: PaymentService(bot=bot) внутри try блока
- verify_payment_amount утилита для проверки суммы webhook
- broadcast_history.admin_id: CASCADE→SET NULL (column is nullable, preserve audit trail)
- Added nullable=True to broadcast_history.admin_id in model
- Added 27 missing FK constraints to _FK_CHANGES (were only cleaned for orphans
but not recreated with ondelete)
- All 53 FK→users.id now consistently handled in both orphan cleanup and constraint recreation
27 FK ссылающихся на users.id не имели ondelete — при физическом удалении
юзера или восстановлении бэкапа с сиротами FK constraints не создавались.
Миграция 0016:
1. Чистит сироты во всех 53 child-таблицах (DELETE для non-nullable, SET NULL для nullable)
2. Пересоздаёт 27 FK с ON DELETE CASCADE (user_id) или SET NULL (created_by, processed_by)
- trial подписки теперь конвертируются в платные вместо отказа (ошибка ~20 из 300 юзеров)
- extend_subscription: добавлен переход TRIAL→ACTIVE
- UniqueConstraint на PromoCodeUse(user_id, promocode_id) + миграция 0015 с дедупликацией
- create_promocode_use: begin_nested()+flush() вместо commit/rollback (без коррупции сессии)
- race condition: create_promocode_use вызывается ДО _apply_promocode_effects
- cleanup: удаление зарезервированной записи при ValueError от эффектов
- atomic SQL increment для current_uses (защита от lost-update)
- mark_user_as_had_paid_subscription: savepoint вместо commit/rollback
- удалён мёртвый код: use_promocode(), trial_subscription_not_eligible из маппингов
- float precision: int(round(amount * 100)) вместо int(amount * 100) для рублей→копейки
- порядок регистрации callback-хендлеров (специфичные startswith первыми)
- FSM state filter на callback хендлере для предотвращения случайных срабатываний
- upsert паттерн в add_contest_event вместо дубликатов
- расширенный SQL фильтр в get_contests_for_events (все активные конкурсы)
- нормализация end-of-day (23:59:59.999999) для границ конкурсных периодов
- guard is_completed в create_transaction
- expenses_kopeks: func.abs() handles WITHDRAWAL stored as negative by approve_request
- admin_users.py: abs() in display instead of sign flip for mixed-sign WITHDRAWAL/SUBSCRIPTION_PAYMENT
- referral_contest.py: func.abs() on get_contest_payment_stats total_amount sum
- admin_stats.py: abs() on RecentPaymentItem to prevent negative amounts in API
- stored_amount используется только для БД записи, оригинальный
amount_kopeks передаётся в event emitter и contest service через abs()
- Добавлен func.abs() в leaderboard конкурсов (referral_contest.py)
- Предотвращает негативные суммы в событиях и рейтинге конкурсов
- Убран WITHDRAWAL из автонегации в create_transaction (ломал profit,
expenses и display flip в admin_users)
- Добавлен func.abs() в by_type агрегацию (transaction.py)
- Добавлен func.abs() в total_spent user.py (_build_spending_stats_select)
- Исправлен all_time_stats в боте и webapi: передаём явный диапазон дат
вместо дефолтного текущего месяца
- Добавлен abs() на уровне API-ответов для subscription_income (защита от
негативных значений при несогласованных знаках SUBSCRIPTION_PAYMENT)
- Нормализация знаков в create_transaction: SUBSCRIPTION_PAYMENT и WITHDRAWAL
всегда сохраняются как отрицательные (дебет)
- Исправлен income_total в дашборде: показывал месячный доход вместо общего
(теперь используется отдельный запрос all_time_stats)