ipulse-shared-core-ftredge 20.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.
- ipulse_shared_core_ftredge/cache/shared_cache.py +1 -2
- ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +4 -4
- ipulse_shared_core_ftredge/exceptions/base_exceptions.py +23 -0
- ipulse_shared_core_ftredge/models/__init__.py +3 -7
- ipulse_shared_core_ftredge/models/base_data_model.py +17 -19
- ipulse_shared_core_ftredge/models/catalog/__init__.py +10 -0
- ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +273 -0
- ipulse_shared_core_ftredge/models/catalog/usertype.py +170 -0
- ipulse_shared_core_ftredge/models/user/__init__.py +5 -0
- ipulse_shared_core_ftredge/models/user/user_permissions.py +66 -0
- ipulse_shared_core_ftredge/models/{subscription.py → user/user_subscription.py} +66 -20
- ipulse_shared_core_ftredge/models/{user_auth.py → user/userauth.py} +19 -10
- ipulse_shared_core_ftredge/models/{user_profile.py → user/userprofile.py} +53 -21
- ipulse_shared_core_ftredge/models/user/userstatus.py +430 -0
- ipulse_shared_core_ftredge/monitoring/__init__.py +0 -2
- ipulse_shared_core_ftredge/monitoring/tracemon.py +6 -6
- ipulse_shared_core_ftredge/services/__init__.py +11 -13
- ipulse_shared_core_ftredge/services/base/__init__.py +3 -1
- ipulse_shared_core_ftredge/services/base/base_firestore_service.py +73 -14
- ipulse_shared_core_ftredge/services/{cache_aware_firestore_service.py → base/cache_aware_firestore_service.py} +46 -32
- ipulse_shared_core_ftredge/services/catalog/__init__.py +14 -0
- ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +273 -0
- ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +307 -0
- ipulse_shared_core_ftredge/services/charging_processors.py +25 -25
- ipulse_shared_core_ftredge/services/user/__init__.py +5 -25
- ipulse_shared_core_ftredge/services/user/firebase_auth_admin_helpers.py +160 -0
- ipulse_shared_core_ftredge/services/user/user_core_service.py +423 -515
- ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +726 -0
- ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +392 -0
- ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +484 -0
- ipulse_shared_core_ftredge/services/user/userauth_operations.py +928 -0
- ipulse_shared_core_ftredge/services/user/userprofile_operations.py +166 -0
- ipulse_shared_core_ftredge/services/user/userstatus_operations.py +212 -0
- ipulse_shared_core_ftredge/services/{charging_service.py → user_charging_service.py} +9 -9
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/METADATA +3 -4
- ipulse_shared_core_ftredge-22.1.1.dist-info/RECORD +51 -0
- ipulse_shared_core_ftredge/models/user_status.py +0 -495
- ipulse_shared_core_ftredge/monitoring/microservmon.py +0 -526
- ipulse_shared_core_ftredge/services/user/iam_management_operations.py +0 -326
- ipulse_shared_core_ftredge/services/user/subscription_management_operations.py +0 -384
- ipulse_shared_core_ftredge/services/user/user_account_operations.py +0 -479
- ipulse_shared_core_ftredge/services/user/user_auth_operations.py +0 -305
- ipulse_shared_core_ftredge/services/user/user_holistic_operations.py +0 -436
- ipulse_shared_core_ftredge-20.0.1.dist-info/RECORD +0 -42
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/WHEEL +0 -0
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/licenses/LICENCE +0 -0
- {ipulse_shared_core_ftredge-20.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
|