ipulse-shared-core-ftredge 19.0.1__py3-none-any.whl → 22.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ipulse-shared-core-ftredge might be problematic. Click here for more details.

Files changed (47) hide show
  1. ipulse_shared_core_ftredge/cache/shared_cache.py +1 -2
  2. ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +4 -4
  3. ipulse_shared_core_ftredge/exceptions/base_exceptions.py +23 -0
  4. ipulse_shared_core_ftredge/models/__init__.py +3 -7
  5. ipulse_shared_core_ftredge/models/base_data_model.py +17 -19
  6. ipulse_shared_core_ftredge/models/catalog/__init__.py +10 -0
  7. ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +273 -0
  8. ipulse_shared_core_ftredge/models/catalog/usertype.py +170 -0
  9. ipulse_shared_core_ftredge/models/user/__init__.py +5 -0
  10. ipulse_shared_core_ftredge/models/user/user_permissions.py +66 -0
  11. ipulse_shared_core_ftredge/models/{subscription.py → user/user_subscription.py} +66 -20
  12. ipulse_shared_core_ftredge/models/{user_auth.py → user/userauth.py} +19 -10
  13. ipulse_shared_core_ftredge/models/{user_profile.py → user/userprofile.py} +53 -21
  14. ipulse_shared_core_ftredge/models/user/userstatus.py +430 -0
  15. ipulse_shared_core_ftredge/monitoring/__init__.py +2 -2
  16. ipulse_shared_core_ftredge/monitoring/tracemon.py +320 -0
  17. ipulse_shared_core_ftredge/services/__init__.py +11 -13
  18. ipulse_shared_core_ftredge/services/base/__init__.py +3 -1
  19. ipulse_shared_core_ftredge/services/base/base_firestore_service.py +73 -14
  20. ipulse_shared_core_ftredge/services/{cache_aware_firestore_service.py → base/cache_aware_firestore_service.py} +46 -32
  21. ipulse_shared_core_ftredge/services/catalog/__init__.py +14 -0
  22. ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +273 -0
  23. ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +307 -0
  24. ipulse_shared_core_ftredge/services/charging_processors.py +25 -25
  25. ipulse_shared_core_ftredge/services/user/__init__.py +5 -25
  26. ipulse_shared_core_ftredge/services/user/firebase_auth_admin_helpers.py +160 -0
  27. ipulse_shared_core_ftredge/services/user/user_core_service.py +423 -515
  28. ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +726 -0
  29. ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +392 -0
  30. ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +484 -0
  31. ipulse_shared_core_ftredge/services/user/userauth_operations.py +928 -0
  32. ipulse_shared_core_ftredge/services/user/userprofile_operations.py +166 -0
  33. ipulse_shared_core_ftredge/services/user/userstatus_operations.py +212 -0
  34. ipulse_shared_core_ftredge/services/{charging_service.py → user_charging_service.py} +9 -9
  35. {ipulse_shared_core_ftredge-19.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/METADATA +3 -4
  36. ipulse_shared_core_ftredge-22.1.1.dist-info/RECORD +51 -0
  37. ipulse_shared_core_ftredge/models/user_status.py +0 -495
  38. ipulse_shared_core_ftredge/monitoring/microservmon.py +0 -483
  39. ipulse_shared_core_ftredge/services/user/iam_management_operations.py +0 -326
  40. ipulse_shared_core_ftredge/services/user/subscription_management_operations.py +0 -384
  41. ipulse_shared_core_ftredge/services/user/user_account_operations.py +0 -479
  42. ipulse_shared_core_ftredge/services/user/user_auth_operations.py +0 -305
  43. ipulse_shared_core_ftredge/services/user/user_holistic_operations.py +0 -436
  44. ipulse_shared_core_ftredge-19.0.1.dist-info/RECORD +0 -41
  45. {ipulse_shared_core_ftredge-19.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/WHEEL +0 -0
  46. {ipulse_shared_core_ftredge-19.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/licenses/LICENCE +0 -0
  47. {ipulse_shared_core_ftredge-19.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,484 @@
1
+ """
2
+ Subscription Management Operations - Handle user subscriptions and related operations
3
+ """
4
+ import logging
5
+ from typing import Optional
6
+ from datetime import datetime, timezone
7
+ from google.cloud import firestore
8
+ from google.cloud.exceptions import GoogleCloudError
9
+ from ipulse_shared_base_ftredge.enums import SubscriptionPlanName, SubscriptionStatus
10
+ from ...models import UserSubscription, SubscriptionPlan
11
+ from ...exceptions import SubscriptionError, UserStatusError, ServiceError
12
+ from .userstatus_operations import UserstatusOperations
13
+ from .user_permissions_operations import UserpermissionsOperations
14
+
15
+
16
+ class UsersubscriptionOperations:
17
+ """
18
+ Handles subscription-related operations for users
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ firestore_client: firestore.Client,
24
+ userstatus_ops: UserstatusOperations,
25
+ permissions_ops: UserpermissionsOperations,
26
+ logger: Optional[logging.Logger] = None,
27
+ timeout: float = 10.0
28
+ ):
29
+ self.db = firestore_client
30
+ self.userstatus_ops = userstatus_ops
31
+ self.permissions_ops = permissions_ops
32
+ self.logger = logger or logging.getLogger(__name__)
33
+ self.timeout = timeout
34
+
35
+ def _create_subscription_from_subscriptionplan(
36
+ self,
37
+ plan: SubscriptionPlan,
38
+ source: str,
39
+ granted_at: Optional[datetime] = None,
40
+ auto_renewal: Optional[bool] = None
41
+ ) -> UserSubscription:
42
+ """
43
+ Common helper function to create a UserSubscription from a SubscriptionPlan.
44
+
45
+ Args:
46
+ plan: The subscription plan to convert
47
+ source: Source identifier for the subscription
48
+ granted_at: Optional granted timestamp (defaults to now)
49
+ auto_renewal: Optional auto-renewal override
50
+
51
+ Returns:
52
+ UserSubscription object
53
+ """
54
+ # Use provided granted_at or default to now
55
+ start_date = granted_at or datetime.now(timezone.utc)
56
+
57
+ # Calculate end date based on plan validity
58
+ if not plan.plan_validity_cycle_length or not plan.plan_validity_cycle_unit:
59
+ raise SubscriptionError(
60
+ detail="Missing or invalid subscription duration fields",
61
+ plan_id=plan.id or "unknown",
62
+ operation="_create_subscription_from_plan"
63
+ )
64
+
65
+ end_date = UserSubscription.calculate_cycle_end_date(
66
+ start_date,
67
+ plan.plan_validity_cycle_length,
68
+ plan.plan_validity_cycle_unit
69
+ )
70
+
71
+ # Use provided auto_renewal or default from plan
72
+ effective_auto_renewal = auto_renewal if auto_renewal is not None else (
73
+ plan.plan_default_auto_renewal if plan.plan_default_auto_renewal is not None else False
74
+ )
75
+
76
+ try:
77
+ # Validate plan name
78
+ plan_name_enum = SubscriptionPlanName(plan.plan_name)
79
+ except ValueError as e:
80
+ raise SubscriptionError(
81
+ detail=f"Invalid plan name '{plan.plan_name}': {str(e)}",
82
+ plan_id=plan.id or "unknown",
83
+ operation="_create_subscription_from_plan",
84
+ original_error=e
85
+ ) from e
86
+
87
+ return UserSubscription(
88
+ plan_name=plan_name_enum,
89
+ plan_version=plan.plan_version,
90
+ plan_id=plan.id or f"{plan.plan_name}_{plan.plan_version}",
91
+ cycle_start_date=start_date,
92
+ cycle_end_date=end_date,
93
+ validity_time_length=plan.plan_validity_cycle_length,
94
+ validity_time_unit=plan.plan_validity_cycle_unit,
95
+ auto_renew=effective_auto_renewal,
96
+ status=SubscriptionStatus.ACTIVE,
97
+ granted_iam_permissions=plan.granted_iam_permissions or [],
98
+ fallback_plan_id=plan.fallback_plan_id_if_current_plan_expired,
99
+ price_paid_usd=float(plan.plan_per_cycle_price_usd if plan.plan_per_cycle_price_usd is not None else 0.0),
100
+ created_by=source,
101
+ updated_by=source,
102
+ subscription_based_insight_credits_per_update=int(plan.subscription_based_insight_credits_per_update if plan.subscription_based_insight_credits_per_update is not None else 0),
103
+ subscription_based_insight_credits_update_freq_h=int(plan.subscription_based_insight_credits_update_freq_h if plan.subscription_based_insight_credits_update_freq_h is not None else 24),
104
+ extra_insight_credits_per_cycle=int(plan.extra_insight_credits_per_cycle if plan.extra_insight_credits_per_cycle is not None else 0),
105
+ voting_credits_per_update=int(plan.voting_credits_per_update if plan.voting_credits_per_update is not None else 0),
106
+ voting_credits_update_freq_h=int(plan.voting_credits_update_freq_h if plan.voting_credits_update_freq_h is not None else 744),
107
+ )
108
+
109
+ async def fetch_subscriptionplan_and_apply_subscription_to_user(
110
+ self,
111
+ user_uid: str,
112
+ plan_id: str,
113
+ updater_uid: str,
114
+ source: str = "system_default_config",
115
+ granted_at: Optional[datetime] = None,
116
+ auto_renewal: Optional[bool] = None
117
+ ) -> UserSubscription:
118
+ """
119
+ Fetch a subscription plan from catalog service and apply to user.
120
+
121
+ Args:
122
+ user_uid: User ID to apply subscription to
123
+ plan_id: Plan ID to fetch and apply
124
+ updater_uid: Who is applying the subscription
125
+ source: Source identifier
126
+ granted_at: Optional granted timestamp (overrides plan defaults)
127
+ auto_renewal: Optional auto-renewal setting (overrides plan defaults)
128
+
129
+ Returns:
130
+ Applied UserSubscription
131
+
132
+ Raises:
133
+ SubscriptionError: If plan not found or application fails
134
+ """
135
+ self.logger.info("Fetching and applying subscription plan %s to user %s", plan_id, user_uid)
136
+
137
+ try:
138
+ # Import the catalog service (lazy import to avoid circular dependencies)
139
+ from ...services.catalog.catalog_subscriptionplan_service import CatalogSubscriptionPlanService
140
+
141
+ # Initialize catalog service using our existing firestore client
142
+ catalog_service = CatalogSubscriptionPlanService(firestore_client=self.db, logger=self.logger)
143
+
144
+ # Fetch the plan from catalog
145
+ catalog_plan = await catalog_service.get_subscriptionplan(plan_id)
146
+ if not catalog_plan:
147
+ raise SubscriptionError(
148
+ detail=f"Subscription plan '{plan_id}' not found in catalog",
149
+ user_uid=user_uid,
150
+ plan_id=plan_id,
151
+ operation="fetch_and_apply_subscriptionplan_to_user"
152
+ )
153
+
154
+ # Apply the subscription plan to user
155
+ return await self.apply_subscriptionplan(
156
+ user_uid=user_uid,
157
+ subscriptionplan=catalog_plan,
158
+ updater_uid=updater_uid,
159
+ source=source,
160
+ granted_at=granted_at,
161
+ auto_renewal=auto_renewal,
162
+ add_associated_permissions=True
163
+ )
164
+
165
+ except SubscriptionError:
166
+ # Re-raise subscription errors as-is
167
+ raise
168
+ except Exception as e:
169
+ self.logger.error("Failed to fetch and apply subscription plan %s: %s", plan_id, e, exc_info=True)
170
+ raise SubscriptionError(
171
+ detail=f"Failed to fetch and apply subscription plan: {str(e)}",
172
+ user_uid=user_uid,
173
+ plan_id=plan_id,
174
+ operation="fetch_and_apply_subscriptionplan_to_user",
175
+ original_error=e
176
+ ) from e
177
+
178
+ async def apply_subscriptionplan(
179
+ self,
180
+ user_uid: str,
181
+ subscriptionplan: SubscriptionPlan,
182
+ updater_uid: str,
183
+ source: str = "system_default_config",
184
+ granted_at: Optional[datetime] = None,
185
+ auto_renewal: Optional[bool] = None,
186
+ add_associated_permissions: bool = True
187
+ ) -> UserSubscription:
188
+ """
189
+ Apply a ready subscription plan to a user.
190
+
191
+ Args:
192
+ user_uid: User ID to apply subscription to
193
+ subscriptionplan: Ready SubscriptionPlan object
194
+ updater_uid: Who is applying the subscription
195
+ source: Source identifier
196
+ granted_at: Optional granted timestamp (overrides plan defaults)
197
+ auto_renewal: Optional auto-renewal setting (overrides plan defaults)
198
+ add_associated_permissions: If True, adds IAM permissions from the subscription
199
+
200
+ Returns:
201
+ Applied UserSubscription
202
+ """
203
+ self.logger.info("Applying subscription plan %s to user %s", subscriptionplan.id, user_uid)
204
+
205
+ # Get user status
206
+ userstatus = await self.userstatus_ops.get_userstatus(user_uid)
207
+ if not userstatus:
208
+ raise UserStatusError(
209
+ detail=f"Userstatus not found for user_uid {user_uid}",
210
+ user_uid=user_uid,
211
+ operation="apply_subscriptionplan"
212
+ )
213
+
214
+ try:
215
+ # Create subscription from plan using helper
216
+ subscription = self._create_subscription_from_subscriptionplan(
217
+ plan=subscriptionplan,
218
+ source=f"{source}:{updater_uid}",
219
+ granted_at=granted_at,
220
+ auto_renewal=auto_renewal
221
+ )
222
+
223
+ # Apply subscription to user (this will handle removing existing permissions and adding new ones)
224
+ permissions_added = userstatus.apply_subscription(
225
+ subscription,
226
+ add_associated_permissions=add_associated_permissions,
227
+ remove_existing_subscription_permissions=True,
228
+ granted_by=f"UsersubscriptionOperations.apply:{source}:{updater_uid}"
229
+ )
230
+
231
+ # Update user status metadata
232
+ userstatus.updated_at = datetime.now(timezone.utc)
233
+ userstatus.updated_by = f"UsersubscriptionOperations.apply:{source}:{updater_uid}"
234
+
235
+ # Save to database
236
+ await self.userstatus_ops.update_userstatus(
237
+ user_uid=user_uid,
238
+ status_data=userstatus.model_dump(exclude_none=True),
239
+ updater_uid=f"UsersubscriptionOperations:{source}:{updater_uid}"
240
+ )
241
+
242
+ self.logger.info("Successfully applied subscription %s for user %s", subscription.plan_id, user_uid)
243
+ if add_associated_permissions:
244
+ self.logger.info("Applied %d IAM permissions from new subscription for user %s", permissions_added, user_uid)
245
+
246
+ return subscription
247
+
248
+ except Exception as e:
249
+ self.logger.error("Failed to apply subscription to user status: %s", e, exc_info=True)
250
+ raise SubscriptionError(
251
+ detail=f"Failed to apply subscription to user: {str(e)}",
252
+ user_uid=user_uid,
253
+ plan_id=subscriptionplan.id,
254
+ operation="apply_subscriptionplan",
255
+ original_error=e
256
+ ) from e
257
+
258
+ async def get_user_active_subscription(self, user_uid: str) -> Optional[UserSubscription]:
259
+ """Get the user's currently active subscription"""
260
+ userstatus = await self.userstatus_ops.get_userstatus(user_uid)
261
+ if userstatus and userstatus.active_subscription and userstatus.active_subscription.is_active():
262
+ self.logger.info("Active subscription found for user %s: %s", user_uid, userstatus.active_subscription.plan_id)
263
+ return userstatus.active_subscription
264
+
265
+ self.logger.info("No active subscription found for user %s", user_uid)
266
+ return None
267
+
268
+ async def update_user_subscription(
269
+ self,
270
+ user_uid: str,
271
+ subscription_updates: dict,
272
+ updater_uid: str = "admin_update"
273
+ ) -> Optional[UserSubscription]:
274
+ """
275
+ Update user's active subscription with new values.
276
+ Useful for admin corrections or payment-related updates.
277
+
278
+ Args:
279
+ user_uid: User ID
280
+ subscription_updates: Dictionary of fields to update
281
+ updater_uid: Who is making the update
282
+
283
+ Returns:
284
+ Updated UserSubscription or None if no active subscription
285
+ """
286
+ self.logger.info("Updating subscription for user %s", user_uid)
287
+
288
+ userstatus = await self.userstatus_ops.get_userstatus(user_uid)
289
+ if not userstatus:
290
+ raise UserStatusError(
291
+ detail=f"Userstatus not found for user_uid {user_uid}",
292
+ user_uid=user_uid,
293
+ operation="update_user_subscription"
294
+ )
295
+
296
+ if not userstatus.active_subscription:
297
+ self.logger.info("No active subscription to update for user %s", user_uid)
298
+ return None
299
+
300
+ try:
301
+ # Update the subscription
302
+ subscription_dict = userstatus.active_subscription.model_dump()
303
+ subscription_dict.update(subscription_updates)
304
+ subscription_dict['updated_at'] = datetime.now(timezone.utc)
305
+ subscription_dict['updated_by'] = updater_uid
306
+
307
+ updated_subscription = UserSubscription(**subscription_dict)
308
+
309
+ # Apply updated subscription
310
+ userstatus.apply_subscription(updated_subscription, granted_by=f"UsersubscriptionOperations.update:{updater_uid}")
311
+ userstatus.updated_at = datetime.now(timezone.utc)
312
+ userstatus.updated_by = f"UsersubscriptionOperations.update:{updater_uid}"
313
+
314
+ await self.userstatus_ops.update_userstatus(
315
+ user_uid=user_uid,
316
+ status_data=userstatus.model_dump(exclude_none=True),
317
+ updater_uid=f"UsersubscriptionOperations:{updater_uid}"
318
+ )
319
+
320
+ self.logger.info("Successfully updated subscription for user %s", user_uid)
321
+ return updated_subscription
322
+
323
+ except Exception as e:
324
+ self.logger.error("Failed to update subscription for user %s: %s", user_uid, e, exc_info=True)
325
+ raise SubscriptionError(
326
+ detail=f"Failed to update subscription: {str(e)}",
327
+ user_uid=user_uid,
328
+ operation="update_user_subscription",
329
+ original_error=e
330
+ ) from e
331
+
332
+ async def cancel_user_subscription(
333
+ self,
334
+ user_uid: str,
335
+ updater_uid: str,
336
+ reason: Optional[str] = None
337
+ ) -> bool:
338
+ """Cancel a user's active subscription"""
339
+ self.logger.info("Attempting to cancel subscription for user %s. Reason: %s", user_uid, reason)
340
+
341
+ userstatus = await self.userstatus_ops.get_userstatus(user_uid)
342
+ if not userstatus:
343
+ raise UserStatusError(
344
+ detail=f"Userstatus not found for user_uid {user_uid}",
345
+ user_uid=user_uid,
346
+ operation="cancel_user_subscription"
347
+ )
348
+
349
+ effective_canceller = f"UsersubscriptionOperations.cancel:{updater_uid}:{reason or 'not_specified'}"
350
+
351
+ if userstatus.active_subscription and userstatus.active_subscription.status == SubscriptionStatus.ACTIVE:
352
+ try:
353
+ self.logger.info("Cancelling active subscription %s for user %s",
354
+ userstatus.active_subscription.plan_id, user_uid)
355
+
356
+ # Revoke the subscription and its associated IAM permissions in one call
357
+ revoked_permissions = userstatus.revoke_subscription(remove_associated_permissions=True)
358
+ if revoked_permissions > 0:
359
+ self.logger.info("Revoked %d IAM permissions from cancelled subscription for user %s", revoked_permissions, user_uid)
360
+ else:
361
+ self.logger.info("No IAM permissions to revoke from cancelled subscription for user %s", user_uid)
362
+
363
+ userstatus.updated_at = datetime.now(timezone.utc)
364
+ userstatus.updated_by = effective_canceller
365
+
366
+ await self.userstatus_ops.update_userstatus(
367
+ user_uid=user_uid,
368
+ status_data=userstatus.model_dump(exclude_none=True),
369
+ updater_uid=effective_canceller
370
+ )
371
+
372
+ self.logger.info("Successfully cancelled subscription for user %s", user_uid)
373
+ return True
374
+
375
+ except Exception as e:
376
+ self.logger.error("Failed to cancel subscription: %s", e, exc_info=True)
377
+ raise SubscriptionError(
378
+ detail=f"Failed to cancel subscription: {str(e)}",
379
+ user_uid=user_uid,
380
+ operation="cancel_user_subscription",
381
+ original_error=e
382
+ ) from e
383
+ else:
384
+ self.logger.info("No active subscription to cancel for user %s", user_uid)
385
+ return False
386
+
387
+ async def downgrade_user_subscription_to_fallback_subscriptionplan(
388
+ self,
389
+ user_uid: str,
390
+ reason: str = "subscription_expired"
391
+ ) -> Optional[UserSubscription]:
392
+ """
393
+ Downgrade user to their fallback plan.
394
+
395
+ Args:
396
+ user_uid: User ID
397
+ reason: Reason for downgrade
398
+
399
+ Returns:
400
+ New UserSubscription if fallback plan exists, None otherwise
401
+ """
402
+ self.logger.info("Attempting to downgrade user %s to fallback plan. Reason: %s", user_uid, reason)
403
+
404
+ userstatus = await self.userstatus_ops.get_userstatus(user_uid)
405
+ if not userstatus:
406
+ raise UserStatusError(
407
+ detail=f"Userstatus not found for user_uid {user_uid}",
408
+ user_uid=user_uid,
409
+ operation="downgrade_to_fallback_plan"
410
+ )
411
+
412
+ # Check if user has an active subscription with a fallback plan
413
+ if not userstatus.active_subscription:
414
+ self.logger.info("No active subscription for user %s - cannot downgrade", user_uid)
415
+ return None
416
+
417
+ fallback_plan_id = userstatus.active_subscription.fallback_plan_id
418
+ if not fallback_plan_id:
419
+ self.logger.info("No fallback plan configured for user %s", user_uid)
420
+ return None
421
+
422
+ try:
423
+ # Fetch the fallback plan using catalog service first
424
+ try:
425
+ # Import the catalog service (lazy import to avoid circular dependencies)
426
+ from ...services.catalog.catalog_subscriptionplan_service import CatalogSubscriptionPlanService
427
+
428
+ # Initialize catalog service using our existing firestore client
429
+ catalog_service = CatalogSubscriptionPlanService(firestore_client=self.db, logger=self.logger)
430
+
431
+ # Fetch the fallback plan from catalog
432
+ fallback_plan = await catalog_service.get_subscriptionplan(fallback_plan_id)
433
+ if not fallback_plan:
434
+ self.logger.error("Fallback plan %s not found in catalog", fallback_plan_id)
435
+ return None
436
+
437
+ except (GoogleCloudError, ServiceError) as e:
438
+ self.logger.error("Failed to fetch fallback plan %s from catalog: %s", fallback_plan_id, e)
439
+ return None
440
+
441
+ # Store the current subscription plan_id for logging
442
+ current_plan_id = userstatus.active_subscription.plan_id
443
+
444
+ # Create new subscription from fallback plan with updated granted_at
445
+ new_subscription = self._create_subscription_from_subscriptionplan(
446
+ plan=fallback_plan,
447
+ source=f"downgrade_from_{current_plan_id}:{reason}",
448
+ granted_at=datetime.now(timezone.utc), # Set to now for downgrade
449
+ auto_renewal=fallback_plan.plan_default_auto_renewal
450
+ )
451
+
452
+ # Apply the new subscription to user (this will handle revoking existing permissions and adding new ones)
453
+ permissions_added = userstatus.apply_subscription(
454
+ new_subscription,
455
+ add_associated_permissions=True,
456
+ remove_existing_subscription_permissions=True,
457
+ granted_by=f"UsersubscriptionOperations:downgrade:{reason}"
458
+ )
459
+ userstatus.updated_at = datetime.now(timezone.utc)
460
+ userstatus.updated_by = f"UsersubscriptionOperations:downgrade:{reason}"
461
+
462
+ await self.userstatus_ops.update_userstatus(
463
+ user_uid=user_uid,
464
+ status_data=userstatus.model_dump(exclude_none=True),
465
+ updater_uid=f"downgrade:{reason}"
466
+ )
467
+
468
+ self.logger.info("Applied %d IAM permissions from fallback subscription for user %s", permissions_added, user_uid)
469
+
470
+ self.logger.info(
471
+ "Successfully downgraded user %s from %s to fallback plan %s",
472
+ user_uid, current_plan_id, fallback_plan_id
473
+ )
474
+ return new_subscription
475
+
476
+ except Exception as e:
477
+ self.logger.error("Failed to downgrade user %s to fallback plan: %s", user_uid, e, exc_info=True)
478
+ raise SubscriptionError(
479
+ detail=f"Failed to downgrade to fallback plan: {str(e)}",
480
+ user_uid=user_uid,
481
+ plan_id=fallback_plan_id,
482
+ operation="downgrade_to_fallback_plan",
483
+ original_error=e
484
+ ) from e