- 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.
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
The security hardening commit changed allow_headers from ['*'] to
['Authorization', 'Content-Type'], but the frontend sends X-CSRF-Token
on all POST/PUT/DELETE/PATCH requests and X-Telegram-Init-Data on all
requests. The missing headers caused preflight OPTIONS requests to fail
with 400 "Disallowed CORS origin".
Admin-created email template overrides were not substituting {tariff_name},
{period_days}, {cabinet_url} etc. because get_template_override returns raw
body_html. Switched to get_rendered_override which performs variable
substitution with html.escape. Also removed dead is_existing_user from
sample context.
- Register 4 guest purchase template types in admin email templates:
guest_subscription_delivered, guest_activation_required,
guest_gift_received, guest_cabinet_credentials
- Add sample contexts with placeholders for preview/test
- Add DB override support to send_guest_notification for all 4 types
Replace VPN subscription URLs with cabinet links in all email templates:
- GUEST_SUBSCRIPTION_DELIVERED: unified to always show cabinet link
- GUEST_GIFT_RECEIVED: replaced subscription URL with cabinet link
Both self-purchase and gift flows now only include cabinet links.
- Format long dict literal in admin_landings.py
- Add blank line after validator in admin_payment_methods.py
- Fix ruff E203 slice spacing in landing.py
- Fix long line wrapping in payment_service.py
- Add pattern=r'^[a-zA-Z0-9_-]+$' to referral_code in TelegramAuthRequest,
TelegramWidgetAuthRequest, and EmailRegisterStandaloneRequest for consistency
with TelegramOIDCAuthRequest
- Add IP-based rate limiting (10 req/min) to /email/login endpoint
- Add Retry-After: 60 header to /login/auto 429 response
- Revert OIDC flush to commit before _store_refresh_token
(matches widget/initData pattern, prevents rollback losing user updates)
- Fix CORS wildcard+credentials in webapi/app.py (same as unified_app fix)