diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 31ec3eeb..e25a2982 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -4038,7 +4038,8 @@ async def switch_tariff( .with_for_update() .execution_options(populate_existing=True) ) - user.subscription = locked_result.scalar_one() + subscription = locked_result.scalar_one() + user.subscription = subscription # Use actual_status for correct status check (handles time-based expiration) actual_status = user.subscription.actual_status @@ -4238,38 +4239,43 @@ async def switch_tariff( # Preserve extra purchased devices above the old tariff's base limit from app.database.crud.subscription import calc_device_limit_on_tariff_switch - user.subscription.tariff_id = new_tariff.id - user.subscription.traffic_limit_gb = new_tariff.traffic_limit_gb - user.subscription.device_limit = calc_device_limit_on_tariff_switch( - current_device_limit=user.subscription.device_limit, + # Re-load subscription to avoid MissingGreenlet from expired lazy relationship + # (subtract_user_balance re-selects User with populate_existing=True which expires relationships) + await db.refresh(user, ['subscription']) + subscription = user.subscription + + subscription.tariff_id = new_tariff.id + subscription.traffic_limit_gb = new_tariff.traffic_limit_gb + subscription.device_limit = calc_device_limit_on_tariff_switch( + current_device_limit=subscription.device_limit, old_tariff_device_limit=current_tariff.device_limit if current_tariff else None, new_tariff_device_limit=new_tariff.device_limit, max_device_limit=new_tariff.max_device_limit, ) - user.subscription.connected_squads = new_tariff.allowed_squads or [] + subscription.connected_squads = new_tariff.allowed_squads or [] # Reset purchased traffic and delete TrafficPurchase records on tariff switch from sqlalchemy import delete as sql_delete from app.database.models import TrafficPurchase - await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id)) - user.subscription.purchased_traffic_gb = 0 - user.subscription.traffic_reset_at = None + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) + subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH: - user.subscription.traffic_used_gb = 0.0 + subscription.traffic_used_gb = 0.0 if switching_to_daily: # Switching TO daily - reset end_date to 1 day, set last_daily_charge_at - user.subscription.end_date = datetime.now(UTC) + timedelta(days=1) - user.subscription.last_daily_charge_at = datetime.now(UTC) - user.subscription.is_daily_paused = False + subscription.end_date = datetime.now(UTC) + timedelta(days=1) + subscription.last_daily_charge_at = datetime.now(UTC) + subscription.is_daily_paused = False elif switching_from_daily: - user.subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days) - user.subscription.is_daily_paused = False + subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days) + subscription.is_daily_paused = False - user.subscription.updated_at = datetime.now(UTC) + subscription.updated_at = datetime.now(UTC) await db.commit() # Emit deferred side-effects after atomic commit @@ -4287,19 +4293,22 @@ async def switch_tariff( # Sync with RemnaWave (optionally reset traffic based on admin setting) should_reset_traffic = settings.RESET_TRAFFIC_ON_TARIFF_SWITCH + # Refresh subscription after commit (all objects are expired) + await db.refresh(subscription) + try: subscription_service = SubscriptionService() if getattr(user, 'remnawave_uuid', None): await subscription_service.update_remnawave_user( db, - user.subscription, + subscription, reset_traffic=should_reset_traffic, reset_reason='смена тарифа', ) else: await subscription_service.create_remnawave_user( db, - user.subscription, + subscription, reset_traffic=should_reset_traffic, reset_reason='смена тарифа', ) @@ -4319,7 +4328,7 @@ async def switch_tariff( logger.error('Failed to reset devices on tariff switch', error=e) await db.refresh(user) - await db.refresh(user.subscription) + await db.refresh(subscription) # Отправляем уведомление админам о смене тарифа try: @@ -4334,7 +4343,7 @@ async def switch_tariff( await notification_service.send_subscription_purchase_notification( db=db, user=user, - subscription=user.subscription, + subscription=subscription, transaction=switch_transaction if upgrade_cost > 0 else None, period_days=remaining_days if remaining_days > 0 else new_period_days, was_trial_conversion=False, @@ -4350,7 +4359,7 @@ async def switch_tariff( 'success': True, 'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'" + (' (devices reset)' if devices_reset else ''), - 'subscription': _subscription_to_response(user.subscription), + 'subscription': _subscription_to_response(subscription), 'old_tariff_name': old_tariff_name, 'new_tariff_id': new_tariff.id, 'new_tariff_name': new_tariff.name, diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 77f51744..73106a4d 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -2323,8 +2323,8 @@ class RemnaWaveService: async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> list[dict[str, Any]]: try: async with self.get_api_client() as api: - start_str = start_date.isoformat() + 'Z' - end_str = end_date.isoformat() + 'Z' + start_str = start_date.isoformat().replace('+00:00', 'Z') + end_str = end_date.isoformat().replace('+00:00', 'Z') params = {'start': start_str, 'end': end_str} diff --git a/app/webserver/payments.py b/app/webserver/payments.py index ac57b569..79155842 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -705,8 +705,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute if success: return JSONResponse({'status': 'ok'}) - transaction_id = payload.get('transactionId', 'unknown') - logger.error('Platega webhook processing failed: transactionId', transaction_id=transaction_id) + transaction_id = ( + payload.get('id') or payload.get('transactionId') or payload.get('transaction_id') or 'unknown' + ) + logger.error('Platega webhook processing failed', transaction_id=transaction_id) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, diff --git a/uv.lock b/uv.lock index 5c3c373a..244025a1 100644 --- a/uv.lock +++ b/uv.lock @@ -551,6 +551,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -1115,7 +1116,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.30.0" +version = "3.31.0" source = { virtual = "." } dependencies = [ { name = "aiogram" },