Commit Graph

6976 Commits

Author SHA1 Message Date
Fringg
d7a9d2bfba fix: reassign orphaned records on merge, eliminate TOCTOU race
- Reassign SubscriptionConversion, SubscriptionEvent, DiscountOffer
  from secondary to primary during merge (previously orphaned)
- Consume-first pattern: atomically GETDEL merge token before
  validation, restore on invalid input (eliminates TOCTOU window)
2026-03-04 16:05:23 +03:00
Fringg
531d5cff30 fix: negative balance transfer, linking state validation, referrer migration
- Transfer negative balances on merge (debt must not vanish)
- Validate OAuth state was initiated for account linking flow
- Transfer secondary's referrer to primary when primary has none
- Type MergePreviewSubscription schema (replace dict[str, Any])
- Cap restore_merge_token TTL to prevent clock-skew extension
- Add 4 new tests (negative balance, referrer transfer scenarios)
2026-03-04 15:57:03 +03:00
Fringg
8ee97ba1ba test: relax hardcoded execute count, add telegram_id conflict assertion
- Change == 17 to >= 17 so test doesn't break on new bulk updates
- Assert secondary.telegram_id is None in conflict test
2026-03-04 15:46:51 +03:00
Fringg
0e8c61a776 fix: use short TTL fallback in restore_merge_token on parse error
Fail closed with 60s instead of full 30min TTL when created_at cannot
be parsed, preventing accidental token lifetime extension.
2026-03-04 15:35:20 +03:00
Fringg
9582758d1c fix: restore merge token on DB failure, fix partner_status priority
- Add restore_merge_token() to re-store consumed token if execute_merge
  or db.commit fails, allowing the user to retry instead of being stuck
- Fix partner_status priority: PENDING (2) now beats REJECTED (1), so
  an active application is not lost during merge
- Add tests for pending-vs-rejected edge cases (47 tests total)
2026-03-04 15:29:50 +03:00
Fringg
f204b67880 fix: delete cross-referral earnings before bulk reassignment, clear secondary.referred_by_id
Prevents data corruption when merging accounts that have mutual referral
relationships. Cross-referral ReferralEarning rows are now deleted before
any bulk UPDATE to avoid self-referral records. Secondary's referred_by_id
is cleared during cleanup to prevent orphaned FK references.
2026-03-04 15:22:23 +03:00
Fringg
db61365e11 fix: prevent self-referral loops, invalidate all sessions on merge
- Add User.id != primary.id filter to referred_by_id reassignment to
  prevent self-referral loops when primary was referred by secondary
- Clear primary.referred_by_id if it pointed to secondary
- Add exclusion filter to ReferralEarning.referral_id reassignment to
  prevent user_id == referral_id rows
- Invalidate refresh tokens for BOTH primary and secondary during merge
  (primary gets a fresh session after merge)
- Fix duplicate step 4 comment numbering in execute_merge_endpoint
- Add referred_by_id field to test fixture _make_user
2026-03-04 15:01:51 +03:00
Fringg
bc1e6fb22c fix(merge): validate before consuming token, add flush, defensive balance
- Validate keep_subscription_from BEFORE consuming merge token (read
  first with get_merge_token_data, then consume) — prevents token loss
  on validation failure
- Add missing await db.flush() after db.delete(secondary_sub) in
  keep_subscription_from='primary' branch (consistency with 'secondary')
- Capture transferred_kopeks in local var before zeroing secondary
  balance (defensive against log reordering)
2026-03-04 08:12:10 +03:00
Fringg
64ee0459e4 fix: second round review fixes for account merge
- Rename _compute_auth_methods to compute_auth_methods (public API)
- Add Literal type to _handle_subscription_merge param
- Add Literal type to keep_from in route handler
- Add Path(min_length=32, max_length=64) on merge_token params
- Import Path and Literal in account_linking routes
2026-03-04 07:55:26 +03:00
Fringg
d855e9e47f fix: harden account merge security and correctness
- Clear ALL unique constraint fields on secondary user after merge
  (telegram_id, OAuth IDs, email, referral_code, remnawave_uuid)
- Add Literal type + runtime validation for keep_subscription_from
- Reject merge when primary user is deleted
- Validate OAuth state user_id matches authenticated user in link callback
- Replace leaked ValueError messages with generic error detail
- Fix exc_info usage for idiomatic structlog
- Fix _get_remnawave_api return type to AsyncIterator
- Remove unnecessary from __future__ import annotations
- Add 3 new tests (42 total, all passing)
2026-03-04 07:46:07 +03:00
Fringg
dc7b8dc72a feat: account linking and merge system for cabinet
Add OAuth provider linking/unlinking endpoints, merge token service
(Redis-backed, 30-min TTL), and atomic account merge executor that
transfers OAuth IDs, telegram_id, email, balance, subscriptions,
transactions, payments, referral data, and partner status between
two user accounts. Unchosen subscription is deleted from RemnaWave
with disable as fallback.

Includes 39 unit tests covering all merge scenarios.
2026-03-04 07:24:15 +03:00
Fringg
57aaca82f5 fix: empty JSONB values exported as None in backup
`if value` treated [] and {} as falsy, losing empty JSONB arrays/dicts
during backup export. Changed to `if value is not None`.
2026-03-04 06:11:15 +03:00
Fringg
ff1c8722c9 fix: backup restore fails on FK constraints and transaction poisoning
Root cause: 7 tables (admin_audit_log, admin_roles, user_roles,
access_policies, partner_applications, required_channels,
user_channel_subscriptions) were missing from backup/restore.
DELETE FROM users hit FK constraint from admin_audit_log, poisoning
the entire PostgreSQL transaction — all subsequent operations failed.

Fixes:
- Add 8 missing models to backup (+ CabinetRefreshToken)
- Replace individual DELETE FROM with TRUNCATE ... CASCADE
  (handles FK deps automatically, resets sequences)
- Fallback: per-table TRUNCATE with savepoints if batch fails
- Fix _restore_users_without_referrals: wrap flush in savepoint
  instead of db.rollback() which killed entire transaction
- Add sync_postgres_sequences() after ORM restore to prevent
  PK conflicts before bot restart
2026-03-04 06:03:14 +03:00
Fringg
018f18fa0c fix: MissingGreenlet on campaign registrations access
Access registrations from instance dict instead of ORM descriptor
to avoid lazy load triggering MissingGreenlet in async context.
2026-03-04 05:55:44 +03:00
Fringg
eaeee7a765 fix: handle duplicate remnawave_uuid on email sync
- Check if another user already owns the panel UUID before assigning
- Rollback + refresh user on sync failure instead of leaving session dirty
- Save user.email before try block for safe error logging
2026-03-04 05:49:38 +03:00
Fringg
618c936ac9 fix: close remaining daily subscription expire paths
- get_all_subscriptions: add selectinload(tariff) so validate_and_fix guard works
- get_subscriptions_batch: add selectinload(tariff) for sync flows
- get_expiring_subscriptions: exclude active daily subs (prevents spurious notifications)
- update_remnawave_user: add daily guard to prevent expire during panel sync
- _handle_user_disabled webhook: add daily guard to prevent deactivation
2026-03-04 05:46:37 +03:00
Fringg
0ed6397fa9 fix: prevent daily subscriptions from being expired by middleware/CRUD/webhook
Daily subscriptions have end_date = +24h, and between 30-min check cycles
they would get expired by 5 different code paths before DailySubscriptionService
could charge and renew them. Users saw "subscription expired" while having balance.

Root cause fixes (6 paths protected):
- subscription_checker middleware: skip active daily subscriptions
- check_and_update_subscription_status CRUD: skip active daily subscriptions
- monitoring_service: run autopay BEFORE expired check, expand query to
  include recently-expired subscriptions (2h window)
- remnawave_webhook_service: _handle_user_expired skips daily tariffs
- remnawave_service: validate_and_fix_subscriptions skips daily tariffs

Recovery mechanisms:
- New get_expired_daily_subscriptions_for_recovery() CRUD function
- DailySubscriptionService.process_auto_resume() restores DISABLED (balance
  topped up) and EXPIRED (incorrectly expired) daily subscriptions
- Runs before daily charges in each monitoring cycle
2026-03-04 05:12:19 +03:00
Fringg
dce9eaa597 fix: reset traffic purchases on expired subscription renewal + pricing fixes
- Reset TrafficPurchase records and purchased_traffic_gb when renewing
  expired subscriptions (was incorrectly preserving stale purchases,
  inflating traffic limit e.g. 100GB+20GB=120GB instead of fresh 100GB)
- Fix in extend_subscription() CRUD, cabinet /renew, bot handlers,
  simple_subscription handlers
- Add RemnaWave sync to cabinet /renew endpoint after subscription changes
- Fix device_price_kopeks=0 falsy-zero bug (11+ instances, or → is not None)
- Fix double-increment of purchased_traffic_gb in cabinet traffic purchase
- Fix orphaned TrafficPurchase records in 5 locations (replace_subscription,
  extend_subscription fixed_with_topup, classic mode, switch_tariff,
  purchase.py is_traffic_fixed)
- Fix admin_users.py UnboundLocalError from TrafficPurchase inline import
  shadowing module-level import
- Standardize pricing order: base + devices → promo_group → promo_offer
  across all 5+ pricing paths (cabinet, miniapp, autopay, auto-purchase,
  monitoring)
- Fix exception handlers in calculate_renewal_price (raise instead of
  returning fallback 0)
- Fix monitoring_service double-discount (promo_offer applied twice)
- Fix auto-purchase _get_tariff_price_for_period return type to tuple
  (base_price, discount_percent) so callers add devices before discount
- Pass traffic_limit_gb/device_limit to extend_subscription in
  simple_subscription.py instead of manual overwrites
2026-03-04 04:46:29 +03:00
Fringg
628a99e7aa fix: classic mode prices overridden by active tariff prices
Root cause: ensure_tariffs_synced runs BEFORE bot_configuration_service.initialize(),
so SALES_MODE from system_settings is not yet applied. If SALES_MODE=classic is set
via cabinet (not .env), load_period_prices_from_db sees tariffs mode and loads tariff
prices into _DB_PERIOD_PRICES. Then refresh_period_prices() always prefers
_DB_PERIOD_PRICES over settings.PRICE_*_DAYS, even in classic mode.

Three fixes:
1. refresh_period_prices() now checks settings.is_tariffs_mode() before using
   _DB_PERIOD_PRICES — classic mode always uses settings.PRICE_*_DAYS
2. initialize() calls refresh_period_prices() after all DB overrides are applied,
   so SALES_MODE is correct when prices are recalculated
3. Switching SALES_MODE to classic via cabinet now clears _DB_PERIOD_PRICES
2026-03-04 03:12:34 +03:00
Fringg
4d74afd711 fix: add selectinload for campaign registrations in list query
MissingGreenlet error when accessing campaign.registrations
in show_campaigns_list handler — lazy load not supported in async.
2026-03-04 02:53:29 +03:00
Fringg
e2c9aab7ba chore: sync uv.lock with pyproject.toml version 2026-03-03 01:57:13 +03:00
Fringg
e23d69fcec feat: replace pip with uv in Dockerfile
- Use uv 0.10.7 with pyproject.toml + uv.lock instead of pip + requirements.txt
- Bind mounts for pyproject.toml/uv.lock with BuildKit cache for faster rebuilds
- UV_COMPILE_BYTECODE=1 for pre-compiled .pyc, UV_LINK_MODE=copy for multi-stage
- UV_PYTHON_DOWNLOADS=never to prevent uv from downloading its own Python
- Replace wget healthcheck with Python stdlib (removes apt layer from runtime)
- Increase start-period to 60s for migration headroom
- Fix redundant chown -R on entire /app
- Add .venv, tests, .mypy_cache, .ruff_cache to .dockerignore
2026-03-03 01:55:39 +03:00
Fringg
310edae013 fix: use float instead of int | float (PYI041) 2026-03-02 22:25:06 +03:00
Fringg
8fb97d9359 chore: ruff format 3 files 2026-03-02 22:23:43 +03:00
Fringg
d33c5d6c07 feat: add daily deposits by payment method breakdown
Add daily_by_method field to deposits endpoint with GROUP BY
(date, payment_method) query. Uses raw column instead of coalesce
since base_filter already excludes NULLs via .in_(REAL_PAYMENT_METHODS).
2026-03-02 22:05:57 +03:00
Fringg
2449a5cbbe feat: add daily device purchases chart to addons stats
- Add DailyDeviceItem schema and daily_devices field to AddonsStatsResponse
- Query device transactions grouped by date reusing existing device_filter
2026-03-02 21:54:34 +03:00
Fringg
e5f29eb041 fix: resolve GROUP BY mismatch for daily_by_tariff query
Use a single coalesce expression object shared across SELECT, GROUP BY,
and ORDER BY clauses so PostgreSQL sees the same expression reference
instead of separately parameterized literals.
2026-03-02 21:45:55 +03:00
Fringg
31c7e2e9c1 feat: enhance sales stats with device purchases, per-tariff daily breakdown, and registration tracking
- Add device purchase count and revenue to addons endpoint (filter by 'устройств' in transaction descriptions)
- Add daily_by_tariff series to sales endpoint (group subscriptions by date and tariff name)
- Split trials daily data into separate registrations and trials series with date union merge
- Add total_registrations count to trials stats response
2026-03-02 21:41:50 +03:00
Fringg
e25fcfc6ef fix: renewals stats empty on all-time filter
For "all time" period, define renewals as users with >1 subscription
payment (repeat customers) instead of filtering by created_at < 2020
which always yields empty results.
2026-03-02 21:15:04 +03:00
Fringg
b2cf4aaa91 fix: eliminate double panel API call on tariff change, harden cart notification
Bug 1 improvement: Replaced double API call pattern (sync + update_remnawave_user)
with single _sync_subscription_to_panel call that accepts reset_traffic parameter.
This prevents TRIAL status being overwritten to EXPIRED by the second call's
different status computation logic.

Bug 2 improvement: Moved keyboard construction inside try block to prevent
AttributeError crash if locale keys are missing. Switched button text from
attribute access (texts.KEY) to defensive texts.get('KEY', fallback).
Added empty template guard to prevent sending empty messages to Telegram API.
2026-03-02 20:53:59 +03:00
Fringg
1256ddcd1a fix: restore panel user discovery on admin tariff change, localize cart reminder
Bug 1: Admin tariff change used update_remnawave_user() which returns
early when user has no remnawave_uuid. Restored _sync_subscription_to_panel()
which discovers/creates panel users via telegram_id/email fallback, then
applies traffic reset if RESET_TRAFFIC_ON_TARIFF_SWITCH is enabled.

Bug 2: Post-topup cart reminder in payment/common.py had hardcoded Russian
text sent to all users regardless of language. Replaced with localized
BALANCE_TOPPED_UP_CART_SUFFICIENT/INSUFFICIENT keys and used existing
MY_BALANCE_BUTTON/MAIN_MENU_BUTTON for inline keyboard buttons.
Added new i18n keys to all 5 locales (ru, en, ua, zh, fa).
2026-03-02 20:48:02 +03:00
Fringg
58faf9eaec feat: add admin sales statistics API with 6 analytics endpoints
- Add /cabinet/admin/stats/sales/* endpoints: summary, trials,
  subscriptions, renewals, addons, deposits
- Period params: days preset or custom start_date/end_date range
- MAX_PERIOD_DAYS=730 validation with proper date parsing
- Conversion rate capped at 100% to handle cross-period conversions
- Use EXTRACT(epoch)/86400 for accurate interval day calculation
- Consolidated subscription queries with CASE expressions
- Renewals with period-over-period comparison and trend detection
- Permission-gated with require_permission('stats:read')
- Shared link utilities in cabinet/utils/links.py
2026-03-02 20:35:09 +03:00
Fringg
ded5c899f7 fix: improve campaign routes, schemas, and add database indexes
- Use PartnerStatus.APPROVED.value instead of hardcoded 'approved'
- Extract shared deep_link/web_link helpers to cabinet/utils/links.py
- Add _safe_div() helper for None-safe division
- Add try/except error handling on campaign endpoints
- Use model_fields_set for PATCH-style field detection
- Replace deprecated class Config with ConfigDict(from_attributes=True)
- Remove unnecessary selectinload(registrations) from campaign list
- Extract _calc_change to module-level in partner_stats_service
- Add composite indexes for stats queries on Subscription, Transaction,
  SubscriptionConversion, and TrafficPurchase models
2026-03-02 20:34:57 +03:00
Fringg
fa7de589c1 feat: add admin campaign chart data endpoint with deposits/spending split
- Add get_admin_campaign_chart_data() to PartnerStatsService with daily registrations, revenue trends, period comparison, and top registrations
- Add total_deposits_kopeks and total_spending_kopeks as separate aggregates
- Add 6 Pydantic schemas for admin chart data response
- Add GET /{campaign_id}/chart-data endpoint with campaigns:stats permission
- Add partner application endpoints and schemas for campaign detailed stats
2026-03-02 06:10:30 +03:00
Fringg
69868418e5 style: format 6 files with ruff 2026-03-02 04:35:16 +03:00
Fringg
062c4865db fix: add min_length to state field, use exc_info for referral warning 2026-03-02 04:34:18 +03:00
Fringg
1dfa78013c fix: migrate VK OAuth to VK ID OAuth 2.1 with PKCE
VK deprecated oauth.vk.com on Sep 30, 2025. Migrate to VK ID (id.vk.ru)
with mandatory PKCE S256 and device_id support.

- Rewrite VKProvider: new endpoints, PKCE code_verifier/challenge, user_info format
- Add prepare_auth_state() hook for provider-specific state (PKCE)
- Use atomic Redis GETDEL for OAuth state validation (prevent TOCTOU race)
- Add CacheService.getdel() method
- Check cache.set() result in generate_oauth_state
- Filter ephemeral keys (_prefix) from Redis storage
- Fix garbled log messages, use exc_info for tracebacks
- Add input validation (min_length, max_length on code/state)
- Generic error messages (no provider name leakage)
2026-03-02 04:10:01 +03:00
Fringg
60c97f778b fix: eliminate referral system inconsistencies
- Fix balance history display: referral_reward, refund, poll_reward now
  shown as credits (💰 +amount) instead of expenses
- Fix double-counting: remove all Transaction-based REFERRAL_REWARD sum
  queries from crud/referral.py, admin_stats.py, admin_users.py —
  ReferralEarning is now the single source of truth
- Unify "active referrals" definition across cabinet, bot, and admin:
  JOIN Subscription WHERE status=ACTIVE AND end_date > now()
- Add payment_method IS NOT NULL guard to get_user_own_deposits() to
  exclude referral rewards historically mistyped as deposits
- Replace hardcoded transaction type strings with TransactionType enum
  values in referral_withdrawal_service.py
- Add Alembic data migration (0014) to fix historical transactions:
  UPDATE deposit → referral_reward WHERE payment_method IS NULL and
  description matches referral patterns
2026-03-02 02:25:32 +03:00
Fringg
83c6db4834 fix: correct referral withdrawal balance formula and commission transaction type
The available_referral formula incorrectly treated all post-earning spending
as spent from referral balance, making withdrawable balance stay at 0 even
as earnings increased. Changed to min(wallet_balance, earned - withdrawn - pending).

- Fix available_referral in withdrawal service and referral info endpoint
- Use TransactionType.REFERRAL_REWARD for all commission/bonus balance additions
- Gate create_referral_earning behind add_user_balance success check
- Move notifications inside balance_ok guards to prevent false confirmations
2026-03-02 01:35:24 +03:00
Fringg
ed3ae14d0c fix: partner system — CRUD nullable fields, per-campaign stats, atomic unassign, diagnostic logging
- Fix update_campaign() CRUD to allow setting nullable fields (partner_user_id, tariff_id, etc.) to None
- Add per-campaign statistics (registrations, referrals, earnings) to partner detail page
- Scope registrations_count to partner-referred users only (JOIN with User.referred_by_id)
- Make unassign_campaign atomic (UPDATE...WHERE) to prevent TOCTOU race condition
- Add audit logging to campaign assign/unassign with admin_id
- Add diagnostic logging to process_referral_topup and commission resolution
- Document process_referral_purchase as intentionally unused (no double-commission)
2026-03-02 01:09:47 +03:00
Fringg
69a9899d40 fix: use direct is_trial access, add missing error codes to promo APIs
- Use subscription.is_trial instead of getattr for reliable check
- Fix structlog key typo: format_user_log → _format_user_log
- Add missing error codes (active_discount_exists, not_first_purchase,
  daily_limit) to miniapp and cabinet promo code endpoints
2026-03-01 23:43:04 +03:00
Fringg
e32e2f779d fix: reject promo codes for days when user has no subscription or trial
SUBSCRIPTION_DAYS promo codes now require an active or expired non-trial
subscription. Users without any subscription or with a trial subscription
get a clear error message instead of silently creating/extending.
2026-03-01 23:39:02 +03:00
Fringg
ccb61d6473 chore: remove dead BALANCE_TOPUP_CART_REMINDER_DETAILED keys and unused cryptobot cart payload 2026-03-01 23:28:26 +03:00
Fringg
2fab50c340 fix: correct cart notification after balance top-up
- Remove misleading "Важно" and "При наличии корзины" warnings from all
  payment success notifications
- Fix cart total bug: show actual cart price from Redis instead of top-up
  amount, and suppress "insufficient funds" when balance is enough
- Extract shared send_cart_notification_after_topup() in common.py to
  replace duplicated code across all 10 payment providers
2026-03-01 23:09:49 +03:00
Fringg
69b5ca0670 fix: use .is_(True) and add or 0 guards per code review 2026-03-01 21:24:32 +03:00
Fringg
06c3996da4 fix: count sales from completed payment transactions instead of subscription created_at
Previously 'Продажи' stats counted by Subscription.created_at which only
reflects initial creation date. Renewals update end_date on existing record
without changing created_at, so renewals were never counted as sales.

Now counts completed SUBSCRIPTION_PAYMENT transactions which are created
for every purchase and renewal. Also standardized date boundaries to use
explicit midnight UTC datetime instead of date objects.
2026-03-01 21:17:05 +03:00
Fringg
faba3a8ed6 fix: enforce user restrictions in cabinet API and fix poll history crash
- Add restriction_topup check to POST /cabinet/balance/topup
- Add restriction_subscription check to 6 subscription endpoints:
  /renew, /purchase, /purchase-tariff, /traffic, /devices/purchase, /devices (legacy)
- All restricted endpoints return 403 Forbidden
- Fix TypeError in broadcast history when message_text is None (polls)
2026-03-01 20:59:06 +03:00
Fringg
4c72058d4a fix: generate missing crypto link on the fly and skip unresolved templates
Root cause: sync uses enrich_happ_links=False so subscription_crypto_link
is empty for 31k+ synced users. RemnaWave config buttons use
{{HAPP_CRYPT4_LINK}} template which stays unresolved, and since
the unresolved template is truthy it prevents the subscriptionUrl fallback
in the frontend — isValidDeepLink fails (no ://) and button is not rendered.

Fixes:
- /app-config endpoint: generate crypto link via encrypt API when missing,
  persist to DB so it's only generated once per user
- Template enrichment: skip setting resolvedUrl when templates remain
  unresolved, allowing frontend to fall through to subscriptionUrl
2026-02-27 23:04:58 +03:00
Fringg
9c004791f2 fix: prevent sync from overwriting subscription URLs with empty strings
- Guard sync update to only overwrite subscription_url when panel_url is non-empty
- Add fallback in /app-config and /subscription endpoints to fetch subscription URL
  from RemnaWave panel when missing in local DB (auto-heals synced users on access)
2026-02-27 22:42:04 +03:00
Fringg
cdcabee80d fix: handle NULL used_promocodes for migrated users
Migrated EvoVPN users have NULL used_promocodes in DB.
Pydantic v2 doesn't apply field default when None is passed explicitly.
2026-02-27 22:06:42 +03:00