- subscription_renewed: new_end_date → new_expires_at
- subscription_activated: end_date → expires_at
- Added traffic_limit_gb and device_limit to both types
- Fixed SAMPLE_CONTEXTS keys to match
- Defense-in-depth: strip \r\n from context values in subject line
substitution to prevent email header injection at composition layer
- Add missing tariff_name to classic mode subscription notification context
(prevents literal {tariff_name} in DB override templates)
- Remove SQLAlchemy model object from notification context dict
(str() on model produces garbage in DB override templates)
Same bug as notification_delivery_service — send_test_email used
get_template_override (raw template) instead of get_rendered_override.
Admins testing custom templates saw literal {days_left} placeholders
instead of sample values.
The send_notification method was calling get_template_override which
returns raw template HTML without variable substitution. Placeholders
like {days_left} and {expires_at} were sent to users as literal text.
Switched to get_rendered_override which properly substitutes context
variables via str.replace before wrapping in the base email template.
Missed the create_paid_subscription branch (line 275) in the previous
commit — now all 4 call sites consistently use `or []` to guard against
None from tariff.allowed_squads JSON column.
- Remove `or settings.DEFAULT_DEVICE_LIMIT` from fulfill_purchase expired
subscription branch — tariff.device_limit is NOT NULL so the `or` pattern
would incorrectly convert 0 (unlimited) to DEFAULT_DEVICE_LIMIT
- Add `or []` guard to connected_squads in activate_purchase to match
replace_subscription's list[str] type contract (tariff.allowed_squads
can be None from JSON column)
When a user with an expired subscription makes a landing page purchase,
the code tried to INSERT a new subscription, violating the user_id
unique constraint. Now uses replace_subscription for expired/inactive
subscriptions instead of create_paid_subscription.
Add total_amount as an alias for cart_total in format() calls so custom
locale overrides using {total_amount} are properly substituted instead
of appearing as literal text in user messages.
Replace pg_class lookup with information_schema.table_constraints query
that is schema-qualified and consistent with migration 0028 pattern.
Fixes constraint detection on fresh installs where create_all() creates
constraints that pg_class lookups could miss.
inspector.get_unique_constraints() fails to detect constraints created
by Base.metadata.create_all() on fresh installs, causing
DuplicateTableError. Query pg_class directly for reliable detection.
The column was left over from the old schema before prize_type/prize_value
refactoring. Its NOT NULL constraint caused INSERT failures since the
SQLAlchemy model no longer includes it.
event_object was referenced in _process_successful_yookassa_payment
but never passed to the method, causing all YooKassa webhook payments
to fail. Use payment.amount_kopeks from the database model instead.
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 GET /admin/landings/{id}/purchases with offset/limit pagination,
optional status filter (validated against GuestPurchaseStatus enum),
tariff name join, and truncated token display.
Add GET /admin/landings/{id}/stats endpoint returning:
- Summary stats (purchases, revenue, gifts, conversion rate)
- Daily breakdown for last 30 days (purchases, revenue, gifts per day)
- Tariff distribution (purchases and revenue per tariff)
Uses case() expressions for SQLite compatibility, timezone-safe
date grouping via func.timezone('UTC', ...), and composite index
(landing_id, status, paid_at) for query performance.
After removing overrides from the public LandingDiscountInfo response,
_load_landing_tariffs still referenced discount.overrides which no
longer exists. Read from landing.discount_overrides directly.
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
Append ?activate=1 to success page URL in recipient notification email
for gift purchases with pending_activation status, so the frontend can
distinguish buyer from recipient and show the activate button only to
the recipient.
- 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
- Remove double html.escape on payment_method (already escaped in helper)
- Use attribute_names=['landing'] keyword arg in db.refresh for consistency
- Use async with Bot() for guaranteed session cleanup
- New send_guest_purchase_notification() method in AdminNotificationService
with blockquote for payment details, landing slug, buyer/recipient info
- Called from fulfill_purchase() for both DELIVERED and PENDING_ACTIVATION
- Called from activate_purchase() when pending purchase is activated
- Different titles: regular purchase, gift purchase, pending activation
- Properly typed GuestPurchase, html.escape on all user data
- Refresh purchase with ['landing'] relationship after commit
- html.escape fallback in _get_payment_method_display
- ruff formatting fixes