ipulse-shared-core-ftredge 24.2.1__py3-none-any.whl → 26.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/dependencies/__init__.py +3 -1
- ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +0 -2
- ipulse_shared_core_ftredge/dependencies/authz_credit_extraction.py +67 -0
- ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +6 -6
- ipulse_shared_core_ftredge/models/__init__.py +3 -1
- ipulse_shared_core_ftredge/models/base_api_response.py +6 -43
- ipulse_shared_core_ftredge/models/credit_api_response.py +26 -0
- ipulse_shared_core_ftredge/models/custom_json_response.py +32 -0
- ipulse_shared_core_ftredge/models/user/user_subscription.py +7 -7
- ipulse_shared_core_ftredge/models/user/userstatus.py +1 -1
- ipulse_shared_core_ftredge/services/charging_processors.py +15 -15
- ipulse_shared_core_ftredge/services/user/__init__.py +1 -0
- ipulse_shared_core_ftredge/services/user/user_charging_operations.py +721 -0
- ipulse_shared_core_ftredge/services/user/user_core_service.py +123 -20
- ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +42 -52
- ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +1 -1
- ipulse_shared_core_ftredge/services/user/userstatus_operations.py +1 -1
- ipulse_shared_core_ftredge/services/user_charging_service.py +19 -19
- {ipulse_shared_core_ftredge-24.2.1.dist-info → ipulse_shared_core_ftredge-26.1.1.dist-info}/METADATA +1 -1
- {ipulse_shared_core_ftredge-24.2.1.dist-info → ipulse_shared_core_ftredge-26.1.1.dist-info}/RECORD +23 -19
- {ipulse_shared_core_ftredge-24.2.1.dist-info → ipulse_shared_core_ftredge-26.1.1.dist-info}/WHEEL +0 -0
- {ipulse_shared_core_ftredge-24.2.1.dist-info → ipulse_shared_core_ftredge-26.1.1.dist-info}/licenses/LICENCE +0 -0
- {ipulse_shared_core_ftredge-24.2.1.dist-info → ipulse_shared_core_ftredge-26.1.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User Charging Operations
|
|
3
|
+
|
|
4
|
+
Handles all credit-related operations for users including:
|
|
5
|
+
- Credit verification and charging transactions
|
|
6
|
+
- Single item and batch processing workflows
|
|
7
|
+
- Credit addition and management operations
|
|
8
|
+
|
|
9
|
+
Follows the established UserCoreService operation class pattern with dependency injection
|
|
10
|
+
and consistent error handling.
|
|
11
|
+
"""
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Dict, Any, List, Optional, Callable, Awaitable, TypeVar, Tuple
|
|
14
|
+
from google.cloud import firestore
|
|
15
|
+
|
|
16
|
+
from ...models.user.userstatus import UserStatus
|
|
17
|
+
from ipulse_shared_core_ftredge.exceptions import ValidationError
|
|
18
|
+
from .userstatus_operations import UserstatusOperations
|
|
19
|
+
|
|
20
|
+
T = TypeVar('T')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UserChargingOperations:
|
|
24
|
+
"""Handles all credit-related operations for users following UserCoreService patterns."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
userstatus_ops: UserstatusOperations,
|
|
29
|
+
logger,
|
|
30
|
+
timeout: float = 10.0,
|
|
31
|
+
bypass_credit_check: bool = False
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Initialize UserChargingOperations with dependency injection pattern.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
userstatus_ops: UserstatusOperations instance for user data access
|
|
38
|
+
logger: Logger instance for operation logging
|
|
39
|
+
timeout: Operation timeout in seconds
|
|
40
|
+
bypass_credit_check: If True, bypasses credit checks for debugging/testing
|
|
41
|
+
"""
|
|
42
|
+
self.userstatus_ops = userstatus_ops
|
|
43
|
+
self.db = userstatus_ops.db # Get firestore client from userstatus_ops
|
|
44
|
+
self.logger = logger
|
|
45
|
+
self.timeout = timeout
|
|
46
|
+
self.bypass_credit_check = bypass_credit_check
|
|
47
|
+
|
|
48
|
+
# Use UserStatus constants following established patterns
|
|
49
|
+
self.users_status_collection_name = UserStatus.COLLECTION_NAME
|
|
50
|
+
self.userstatus_doc_prefix = f"{UserStatus.OBJ_REF}_"
|
|
51
|
+
|
|
52
|
+
# ========================================================================
|
|
53
|
+
# LOW-LEVEL CREDIT OPERATIONS (from UserChargingService)
|
|
54
|
+
# ========================================================================
|
|
55
|
+
|
|
56
|
+
async def verify_enough_credits(
|
|
57
|
+
self,
|
|
58
|
+
user_uid: str,
|
|
59
|
+
required_credits_for_resource: float,
|
|
60
|
+
credits_extracted_from_authz_response: Optional[Dict[str, float]] = None
|
|
61
|
+
) -> Tuple[bool, Dict[str, Any]]:
|
|
62
|
+
"""
|
|
63
|
+
Verify if user has sufficient credits for a resource.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
user_uid: User's UID
|
|
67
|
+
required_credits_for_resource: Credits required for the operation
|
|
68
|
+
pre_fetched_user_credits: Optional pre-fetched credit information
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Tuple of (has_enough_credits: bool, user_credits: Dict)
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValidationError: If validation fails
|
|
75
|
+
"""
|
|
76
|
+
if required_credits_for_resource is None:
|
|
77
|
+
raise ValidationError(
|
|
78
|
+
resource_type="credit_cost",
|
|
79
|
+
detail="Credit cost is not configured for this resource (verify_enough_credits)",
|
|
80
|
+
resource_id=None,
|
|
81
|
+
additional_info={"user_uid": user_uid}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if required_credits_for_resource < 0:
|
|
85
|
+
raise ValidationError(
|
|
86
|
+
resource_type="credit_cost",
|
|
87
|
+
detail="Credit cost cannot be negative (verify_enough_credits)",
|
|
88
|
+
resource_id=user_uid,
|
|
89
|
+
additional_info={"user_uid": user_uid, "cost": required_credits_for_resource}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if required_credits_for_resource == 0:
|
|
93
|
+
self.logger.info(f"No credits required for user {user_uid}, bypassing credit verification")
|
|
94
|
+
return True, {"sbscrptn_based_insight_credits": 0, "extra_insight_credits": 0}
|
|
95
|
+
|
|
96
|
+
if credits_extracted_from_authz_response is not None:
|
|
97
|
+
self.logger.info("Using credits extracted from authorization response for user %s", user_uid)
|
|
98
|
+
subscription_credits = credits_extracted_from_authz_response.get("sbscrptn_based_insight_credits", 0)
|
|
99
|
+
extra_credits = credits_extracted_from_authz_response.get("extra_insight_credits", 0)
|
|
100
|
+
total_credits = subscription_credits + extra_credits
|
|
101
|
+
|
|
102
|
+
self.logger.info(
|
|
103
|
+
"User %s has %s total extracted credits (subscription: %s, extra: %s)",
|
|
104
|
+
user_uid, total_credits, subscription_credits, extra_credits
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
user_credits = {
|
|
108
|
+
"sbscrptn_based_insight_credits": subscription_credits,
|
|
109
|
+
"extra_insight_credits": extra_credits
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
has_enough_credits = total_credits >= required_credits_for_resource
|
|
113
|
+
return has_enough_credits, user_credits
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
self.logger.info(
|
|
117
|
+
"Fetching user status from Firestore for user %s (collection: %s)",
|
|
118
|
+
user_uid, self.users_status_collection_name
|
|
119
|
+
)
|
|
120
|
+
user_status = await self.userstatus_ops.get_userstatus(user_uid, convert_to_model=True)
|
|
121
|
+
if not user_status:
|
|
122
|
+
raise ValidationError(
|
|
123
|
+
resource_type="user_credit_verification",
|
|
124
|
+
detail="User status not found for user %s" % user_uid,
|
|
125
|
+
resource_id=user_uid,
|
|
126
|
+
additional_info={"user_uid": user_uid}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# user_status is a UserStatus model
|
|
130
|
+
subscription_credits = user_status.sbscrptn_based_insight_credits or 0
|
|
131
|
+
extra_credits = user_status.extra_insight_credits or 0
|
|
132
|
+
total_credits = subscription_credits + extra_credits
|
|
133
|
+
|
|
134
|
+
self.logger.info(
|
|
135
|
+
"User %s has %s total credits from Firestore (subscription: %s, extra: %s)",
|
|
136
|
+
user_uid, total_credits, subscription_credits, extra_credits
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
has_enough_credits = total_credits >= required_credits_for_resource
|
|
140
|
+
|
|
141
|
+
user_credits = {
|
|
142
|
+
"sbscrptn_based_insight_credits": subscription_credits,
|
|
143
|
+
"extra_insight_credits": extra_credits
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return has_enough_credits, user_credits
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
self.logger.error(f"Error verifying credits for user {user_uid}: {str(e)}", exc_info=True)
|
|
150
|
+
raise ValidationError(
|
|
151
|
+
resource_type="user_credit_verification",
|
|
152
|
+
detail=f"Failed to verify credits for user: {str(e)}",
|
|
153
|
+
resource_id=user_uid,
|
|
154
|
+
additional_info={"user_uid": user_uid, "required_credits": required_credits_for_resource}
|
|
155
|
+
) from e
|
|
156
|
+
|
|
157
|
+
async def debit_credits_transaction(
|
|
158
|
+
self,
|
|
159
|
+
user_uid: str,
|
|
160
|
+
credits_to_take: Optional[float],
|
|
161
|
+
operation_details: str
|
|
162
|
+
) -> Tuple[bool, Optional[Dict[str, float]]]:
|
|
163
|
+
"""
|
|
164
|
+
Charge credits from user account using Firestore transaction.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
user_uid: User's UID
|
|
168
|
+
credits_to_take: Amount of credits to charge
|
|
169
|
+
operation_details: Description of the operation for logging
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Tuple of (charge_successful: bool, updated_credits: Optional[Dict])
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValidationError: If charging fails due to validation issues
|
|
176
|
+
"""
|
|
177
|
+
if credits_to_take is None:
|
|
178
|
+
raise ValidationError(
|
|
179
|
+
resource_type="credit_cost",
|
|
180
|
+
detail="Credit cost is not configured for this resource (charge_credits)",
|
|
181
|
+
resource_id=None,
|
|
182
|
+
additional_info={"user_uid": user_uid}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if credits_to_take < 0:
|
|
186
|
+
raise ValidationError(
|
|
187
|
+
resource_type="credit_cost",
|
|
188
|
+
detail="Credit cost cannot be negative (charge_credits)",
|
|
189
|
+
resource_id=user_uid,
|
|
190
|
+
additional_info={"user_uid": user_uid, "cost": credits_to_take}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if credits_to_take == 0:
|
|
194
|
+
self.logger.info(f"No credits to charge for user {user_uid}, operation: {operation_details}")
|
|
195
|
+
return True, None
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
# Execute transaction with nested transactional function
|
|
199
|
+
@firestore.transactional
|
|
200
|
+
def debit_credits_transaction(transaction_obj):
|
|
201
|
+
"""Transactional function to debit (remove) user credits"""
|
|
202
|
+
userstatus_id = f"{self.userstatus_doc_prefix}{user_uid}"
|
|
203
|
+
user_ref = self.db.collection(self.users_status_collection_name).document(userstatus_id)
|
|
204
|
+
|
|
205
|
+
user_doc = user_ref.get(transaction=transaction_obj)
|
|
206
|
+
if not user_doc.exists:
|
|
207
|
+
self.logger.warning(
|
|
208
|
+
f"Cannot charge credits - user status not found for {user_uid} in {self.users_status_collection_name}"
|
|
209
|
+
)
|
|
210
|
+
return False, None
|
|
211
|
+
|
|
212
|
+
# Convert to UserStatus object for better handling
|
|
213
|
+
userstatus_data = user_doc.to_dict()
|
|
214
|
+
userstatus = UserStatus(**userstatus_data)
|
|
215
|
+
|
|
216
|
+
# Use object properties instead of dict access
|
|
217
|
+
current_subscription_credits = userstatus.sbscrptn_based_insight_credits or 0.0
|
|
218
|
+
current_extra_credits = userstatus.extra_insight_credits or 0.0
|
|
219
|
+
total_available_credits = current_subscription_credits + current_extra_credits
|
|
220
|
+
|
|
221
|
+
if total_available_credits < credits_to_take:
|
|
222
|
+
self.logger.warning(
|
|
223
|
+
f"Insufficient credits for user {user_uid} during transaction: "
|
|
224
|
+
f"has {total_available_credits}, needs {credits_to_take}"
|
|
225
|
+
)
|
|
226
|
+
return False, {
|
|
227
|
+
"sbscrptn_based_insight_credits": current_subscription_credits,
|
|
228
|
+
"extra_insight_credits": current_extra_credits
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
# Calculate deductions (subscription credits first, then extra)
|
|
232
|
+
subscription_credits_deducted = min(current_subscription_credits, credits_to_take)
|
|
233
|
+
remaining_charge = credits_to_take - subscription_credits_deducted
|
|
234
|
+
extra_credits_deducted = min(current_extra_credits, remaining_charge)
|
|
235
|
+
|
|
236
|
+
# Safety check
|
|
237
|
+
if (subscription_credits_deducted + extra_credits_deducted) < credits_to_take:
|
|
238
|
+
self.logger.error(
|
|
239
|
+
f"Credit calculation error for user {user_uid}. "
|
|
240
|
+
f"Required: {credits_to_take}, Calculated deduction: {subscription_credits_deducted + extra_credits_deducted}"
|
|
241
|
+
)
|
|
242
|
+
return False, {
|
|
243
|
+
"sbscrptn_based_insight_credits": current_subscription_credits,
|
|
244
|
+
"extra_insight_credits": current_extra_credits
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
new_subscription_credits = current_subscription_credits - subscription_credits_deducted
|
|
248
|
+
new_extra_credits = current_extra_credits - extra_credits_deducted
|
|
249
|
+
|
|
250
|
+
update_data: Dict[str, Any] = {
|
|
251
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
252
|
+
"updated_by": "charging_service__debit_credits_transaction"
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if subscription_credits_deducted > 0:
|
|
256
|
+
update_data["sbscrptn_based_insight_credits"] = firestore.Increment(-subscription_credits_deducted)
|
|
257
|
+
update_data["sbscrptn_based_insight_credits_updtd_on"] = datetime.now(timezone.utc).isoformat()
|
|
258
|
+
|
|
259
|
+
if extra_credits_deducted > 0:
|
|
260
|
+
update_data["extra_insight_credits"] = firestore.Increment(-extra_credits_deducted)
|
|
261
|
+
update_data["extra_insight_credits_updtd_on"] = datetime.now(timezone.utc).isoformat()
|
|
262
|
+
|
|
263
|
+
transaction_obj.update(user_ref, update_data)
|
|
264
|
+
|
|
265
|
+
return True, {
|
|
266
|
+
"sbscrptn_based_insight_credits": new_subscription_credits,
|
|
267
|
+
"extra_insight_credits": new_extra_credits
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
transaction = self.db.transaction()
|
|
271
|
+
charged, updated_credits = debit_credits_transaction(transaction)
|
|
272
|
+
|
|
273
|
+
if charged:
|
|
274
|
+
self.logger.info(
|
|
275
|
+
f"Successfully charged {credits_to_take} credits for user {user_uid}. "
|
|
276
|
+
f"Operation: {operation_details}"
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
self.logger.warning(f"Failed to charge credits for user {user_uid}. Operation: {operation_details}")
|
|
280
|
+
|
|
281
|
+
return charged, updated_credits
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
self.logger.error(f"Error charging credits for user {user_uid}: {str(e)}", exc_info=True)
|
|
285
|
+
raise ValidationError(
|
|
286
|
+
resource_type="user_credit_transaction",
|
|
287
|
+
detail=f"Failed to charge credits for user: {str(e)}",
|
|
288
|
+
resource_id=user_uid,
|
|
289
|
+
additional_info={"user_uid": user_uid, "credits_to_take": credits_to_take}
|
|
290
|
+
) from e
|
|
291
|
+
|
|
292
|
+
async def credit_credits_transaction(
|
|
293
|
+
self,
|
|
294
|
+
user_uid: str,
|
|
295
|
+
extra_credits_to_add: float = 0.0,
|
|
296
|
+
subscription_credits_to_add: float = 0.0,
|
|
297
|
+
reason: str = "",
|
|
298
|
+
updater_uid: str = "system"
|
|
299
|
+
) -> Tuple[bool, Optional[Dict[str, float]]]:
|
|
300
|
+
"""
|
|
301
|
+
Add credits to user account (extra and/or subscription credits).
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
user_uid: User's UID
|
|
305
|
+
extra_credits_to_add: Amount of extra credits to add (must be non-negative)
|
|
306
|
+
subscription_credits_to_add: Amount of subscription credits to add (must be non-negative)
|
|
307
|
+
reason: Reason for adding credits
|
|
308
|
+
updater_uid: UID of user/system adding credits
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Tuple of (success: bool, updated_credits: Optional[Dict])
|
|
312
|
+
|
|
313
|
+
Raises:
|
|
314
|
+
ValidationError: If adding credits fails
|
|
315
|
+
"""
|
|
316
|
+
if extra_credits_to_add < 0 or subscription_credits_to_add < 0:
|
|
317
|
+
raise ValidationError(
|
|
318
|
+
resource_type="credit_amount",
|
|
319
|
+
detail="Credit amounts must be non-negative",
|
|
320
|
+
resource_id=user_uid,
|
|
321
|
+
additional_info={
|
|
322
|
+
"user_uid": user_uid,
|
|
323
|
+
"extra_credits_to_add": extra_credits_to_add,
|
|
324
|
+
"subscription_credits_to_add": subscription_credits_to_add
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if extra_credits_to_add == 0 and subscription_credits_to_add == 0:
|
|
329
|
+
raise ValidationError(
|
|
330
|
+
resource_type="credit_amount",
|
|
331
|
+
detail="At least one credit type must have a positive amount",
|
|
332
|
+
resource_id=user_uid,
|
|
333
|
+
additional_info={
|
|
334
|
+
"user_uid": user_uid,
|
|
335
|
+
"extra_credits_to_add": extra_credits_to_add,
|
|
336
|
+
"subscription_credits_to_add": subscription_credits_to_add
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
# Execute transaction with nested transactional function
|
|
342
|
+
@firestore.transactional
|
|
343
|
+
def credit_credits_transaction(transaction_obj):
|
|
344
|
+
"""Transactional function to credit (add) extra and/or subscription credits"""
|
|
345
|
+
userstatus_id = f"{self.userstatus_doc_prefix}{user_uid}"
|
|
346
|
+
user_ref = self.db.collection(self.users_status_collection_name).document(userstatus_id)
|
|
347
|
+
|
|
348
|
+
user_doc = user_ref.get(transaction=transaction_obj)
|
|
349
|
+
if not user_doc.exists:
|
|
350
|
+
self.logger.warning(
|
|
351
|
+
f"Cannot add credits - user status not found for {user_uid} in {self.users_status_collection_name}"
|
|
352
|
+
)
|
|
353
|
+
return False, None
|
|
354
|
+
|
|
355
|
+
# Convert to UserStatus object for better handling
|
|
356
|
+
userstatus_data = user_doc.to_dict()
|
|
357
|
+
userstatus = UserStatus(**userstatus_data)
|
|
358
|
+
|
|
359
|
+
# Use object properties instead of dict access
|
|
360
|
+
current_extra_credits = userstatus.extra_insight_credits or 0.0
|
|
361
|
+
current_subscription_credits = userstatus.sbscrptn_based_insight_credits or 0.0
|
|
362
|
+
|
|
363
|
+
new_extra_credits = current_extra_credits + extra_credits_to_add
|
|
364
|
+
new_subscription_credits = current_subscription_credits + subscription_credits_to_add
|
|
365
|
+
|
|
366
|
+
update_data = {
|
|
367
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
368
|
+
"updated_by": f"charging_service__credit_credits_transaction__{updater_uid}"
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
# Add extra credits if specified
|
|
372
|
+
if extra_credits_to_add > 0:
|
|
373
|
+
update_data["extra_insight_credits"] = firestore.Increment(extra_credits_to_add)
|
|
374
|
+
update_data["extra_insight_credits_updtd_on"] = datetime.now(timezone.utc).isoformat()
|
|
375
|
+
|
|
376
|
+
# Add subscription credits if specified
|
|
377
|
+
if subscription_credits_to_add > 0:
|
|
378
|
+
update_data["sbscrptn_based_insight_credits"] = firestore.Increment(subscription_credits_to_add)
|
|
379
|
+
update_data["sbscrptn_based_insight_credits_updtd_on"] = datetime.now(timezone.utc).isoformat()
|
|
380
|
+
|
|
381
|
+
transaction_obj.update(user_ref, update_data)
|
|
382
|
+
|
|
383
|
+
return True, {
|
|
384
|
+
"sbscrptn_based_insight_credits": new_subscription_credits,
|
|
385
|
+
"extra_insight_credits": new_extra_credits
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
transaction = self.db.transaction()
|
|
389
|
+
success, updated_credits = credit_credits_transaction(transaction)
|
|
390
|
+
|
|
391
|
+
if success:
|
|
392
|
+
credit_details = []
|
|
393
|
+
if extra_credits_to_add > 0:
|
|
394
|
+
credit_details.append(f"{extra_credits_to_add} extra credits")
|
|
395
|
+
if subscription_credits_to_add > 0:
|
|
396
|
+
credit_details.append(f"{subscription_credits_to_add} subscription credits")
|
|
397
|
+
|
|
398
|
+
self.logger.info(
|
|
399
|
+
f"Successfully added {' and '.join(credit_details)} for user {user_uid}. "
|
|
400
|
+
f"Reason: {reason}. Updated by: {updater_uid}"
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
self.logger.warning(
|
|
404
|
+
f"Failed to add credits for user {user_uid}. "
|
|
405
|
+
f"Extra: {extra_credits_to_add}, Subscription: {subscription_credits_to_add}. "
|
|
406
|
+
f"Reason: {reason}"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
return success, updated_credits
|
|
410
|
+
|
|
411
|
+
except Exception as e:
|
|
412
|
+
self.logger.error(
|
|
413
|
+
f"Error adding credits for user {user_uid} "
|
|
414
|
+
f"(extra: {extra_credits_to_add}, subscription: {subscription_credits_to_add}): {str(e)}",
|
|
415
|
+
exc_info=True
|
|
416
|
+
)
|
|
417
|
+
raise ValidationError(
|
|
418
|
+
resource_type="user_credit_addition",
|
|
419
|
+
detail=f"Failed to add credits for user: {str(e)}",
|
|
420
|
+
resource_id=user_uid,
|
|
421
|
+
additional_info={
|
|
422
|
+
"user_uid": user_uid,
|
|
423
|
+
"extra_credits_to_add": extra_credits_to_add,
|
|
424
|
+
"subscription_credits_to_add": subscription_credits_to_add,
|
|
425
|
+
"reason": reason
|
|
426
|
+
}
|
|
427
|
+
) from e
|
|
428
|
+
|
|
429
|
+
# ========================================================================
|
|
430
|
+
# HIGH-LEVEL ORCHESTRATION OPERATIONS (from ChargingProcessor)
|
|
431
|
+
# ========================================================================
|
|
432
|
+
|
|
433
|
+
async def process_single_item_charging(
|
|
434
|
+
self,
|
|
435
|
+
user_uid: str,
|
|
436
|
+
item_id: str,
|
|
437
|
+
get_cost_func: Callable[[], Awaitable[Optional[float]]],
|
|
438
|
+
credits_extracted_from_authz_response: Optional[Dict[str, float]] = None,
|
|
439
|
+
operation_description: str = "Resource access"
|
|
440
|
+
) -> Dict[str, Any]:
|
|
441
|
+
"""
|
|
442
|
+
Process credit check and charging for a single item.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
user_uid: User's UID
|
|
446
|
+
item_id: ID of the item being accessed
|
|
447
|
+
get_cost_func: Async function that returns the cost for the item
|
|
448
|
+
credits_extracted_from_authz_response: Optional extracted credit information from authorization
|
|
449
|
+
operation_description: Description for the charging operation
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Dict with keys:
|
|
453
|
+
- access_granted: bool
|
|
454
|
+
- charge_successful: bool (only meaningful if access_granted is True)
|
|
455
|
+
- cost: Optional[float]
|
|
456
|
+
- reason: str (explanation if access denied)
|
|
457
|
+
- updated_user_credits: Optional[Dict] (credits after charging, if applicable)
|
|
458
|
+
"""
|
|
459
|
+
self.logger.info(f"Processing single item credit check for user {user_uid}, item {item_id}")
|
|
460
|
+
updated_user_credits = None
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
# Get the credit cost for this item
|
|
464
|
+
credit_cost = await get_cost_func()
|
|
465
|
+
|
|
466
|
+
# If item is free or cost not configured, allow access immediately
|
|
467
|
+
if credit_cost is None or credit_cost <= 0:
|
|
468
|
+
if credit_cost is None:
|
|
469
|
+
self.logger.info(f"Item {item_id} has no configured credit cost, treating as free.")
|
|
470
|
+
|
|
471
|
+
# For free items, no need to fetch or verify credits
|
|
472
|
+
return {
|
|
473
|
+
'access_granted': True,
|
|
474
|
+
'charge_successful': True, # No charge needed
|
|
475
|
+
'cost': credit_cost if credit_cost is not None else 0.0,
|
|
476
|
+
'reason': 'free_item',
|
|
477
|
+
'updated_user_credits': None # No credits involved for free items
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
# Check for debug mode bypass
|
|
481
|
+
if self.bypass_credit_check:
|
|
482
|
+
self.logger.info("Bypassing credit check for item %s due to debug mode", item_id)
|
|
483
|
+
if credits_extracted_from_authz_response:
|
|
484
|
+
updated_user_credits = credits_extracted_from_authz_response
|
|
485
|
+
else:
|
|
486
|
+
try:
|
|
487
|
+
_, current_user_credits_from_verify = await self.verify_enough_credits(user_uid, 0, None)
|
|
488
|
+
updated_user_credits = current_user_credits_from_verify
|
|
489
|
+
except Exception:
|
|
490
|
+
self.logger.warning("Could not fetch current credits for user %s during debug bypass.", user_uid)
|
|
491
|
+
return {
|
|
492
|
+
'access_granted': True,
|
|
493
|
+
'charge_successful': True,
|
|
494
|
+
'cost': credit_cost,
|
|
495
|
+
'reason': 'debug_bypass',
|
|
496
|
+
'updated_user_credits': updated_user_credits
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
# Verify user has enough credits
|
|
500
|
+
has_credits, current_user_credits_from_verify = await self.verify_enough_credits(
|
|
501
|
+
user_uid=user_uid,
|
|
502
|
+
required_credits_for_resource=credit_cost,
|
|
503
|
+
credits_extracted_from_authz_response=credits_extracted_from_authz_response
|
|
504
|
+
)
|
|
505
|
+
updated_user_credits = current_user_credits_from_verify
|
|
506
|
+
|
|
507
|
+
if not has_credits:
|
|
508
|
+
self.logger.warning(f"User {user_uid} has insufficient credits for item {item_id} (cost: {credit_cost})")
|
|
509
|
+
return {
|
|
510
|
+
'access_granted': False,
|
|
511
|
+
'charge_successful': False,
|
|
512
|
+
'cost': credit_cost,
|
|
513
|
+
'reason': 'insufficient_credits',
|
|
514
|
+
'updated_user_credits': updated_user_credits
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# Charge the user
|
|
518
|
+
charged, calculated_updated_credits = await self.debit_credits_transaction(
|
|
519
|
+
user_uid=user_uid,
|
|
520
|
+
credits_to_take=credit_cost,
|
|
521
|
+
operation_details=operation_description
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
if calculated_updated_credits is not None:
|
|
525
|
+
updated_user_credits = calculated_updated_credits
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
'access_granted': True,
|
|
529
|
+
'charge_successful': charged,
|
|
530
|
+
'cost': credit_cost,
|
|
531
|
+
'reason': 'charged' if charged else 'charge_failed',
|
|
532
|
+
'updated_user_credits': updated_user_credits
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
except ValidationError as ve:
|
|
536
|
+
self.logger.error("Validation error for item %s, user %s: %s", item_id, user_uid, str(ve))
|
|
537
|
+
try:
|
|
538
|
+
_, updated_user_credits = await self.verify_enough_credits(user_uid, 0, credits_extracted_from_authz_response)
|
|
539
|
+
except Exception:
|
|
540
|
+
pass
|
|
541
|
+
ve.additional_info = ve.additional_info or {}
|
|
542
|
+
ve.additional_info['updated_user_credits'] = updated_user_credits
|
|
543
|
+
raise
|
|
544
|
+
except Exception as e:
|
|
545
|
+
self.logger.error("Unexpected error during credit processing for item %s, user %s: %s", item_id, user_uid, str(e), exc_info=True)
|
|
546
|
+
current_user_credits_on_error = None
|
|
547
|
+
try:
|
|
548
|
+
_, current_user_credits_on_error = await self.verify_enough_credits(user_uid, 0, credits_extracted_from_authz_response)
|
|
549
|
+
except Exception:
|
|
550
|
+
pass
|
|
551
|
+
return {
|
|
552
|
+
'access_granted': False,
|
|
553
|
+
'charge_successful': False,
|
|
554
|
+
'cost': None,
|
|
555
|
+
'reason': f'error: {str(e)}',
|
|
556
|
+
'updated_user_credits': current_user_credits_on_error
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async def process_batch_items_charging(
|
|
560
|
+
self,
|
|
561
|
+
user_uid: str,
|
|
562
|
+
items: List[Dict[str, Any]],
|
|
563
|
+
credits_extracted_from_authz_response: Optional[Dict[str, float]] = None,
|
|
564
|
+
operation_description: str = "Batch resource access"
|
|
565
|
+
) -> Dict[str, Any]:
|
|
566
|
+
"""
|
|
567
|
+
Process credit check and charging for a batch of items.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
user_uid: User's UID
|
|
571
|
+
items: List of dicts with keys: 'id', 'data', 'get_cost_func'
|
|
572
|
+
credits_extracted_from_authz_response: Optional extracted credit information from authorization
|
|
573
|
+
operation_description: Description for the charging operation
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Dict with keys:
|
|
577
|
+
- accessible_items: List[Dict] (items user can access)
|
|
578
|
+
- charge_successful: bool
|
|
579
|
+
- total_cost: float
|
|
580
|
+
- paid_items_count: int
|
|
581
|
+
- free_items_count: int
|
|
582
|
+
- updated_user_credits: Optional[Dict] (credits after charging, if applicable)
|
|
583
|
+
"""
|
|
584
|
+
self.logger.info(f"Processing batch credit check for user {user_uid}, {len(items)} items")
|
|
585
|
+
updated_user_credits = None
|
|
586
|
+
|
|
587
|
+
if not items:
|
|
588
|
+
return {
|
|
589
|
+
'accessible_items': [],
|
|
590
|
+
'charge_successful': True,
|
|
591
|
+
'total_cost': 0.0,
|
|
592
|
+
'paid_items_count': 0,
|
|
593
|
+
'free_items_count': 0,
|
|
594
|
+
'updated_user_credits': None
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
# Separate free and paid items
|
|
599
|
+
free_items = []
|
|
600
|
+
paid_items = []
|
|
601
|
+
total_cost = 0.0
|
|
602
|
+
|
|
603
|
+
for item in items:
|
|
604
|
+
try:
|
|
605
|
+
cost = await item['get_cost_func']()
|
|
606
|
+
if cost is None or cost <= 0:
|
|
607
|
+
free_items.append(item)
|
|
608
|
+
else:
|
|
609
|
+
paid_items.append(item)
|
|
610
|
+
total_cost += cost
|
|
611
|
+
except Exception as cost_err:
|
|
612
|
+
self.logger.error(f"Error getting cost for item {item.get('id', 'unknown')}: {cost_err}")
|
|
613
|
+
free_items.append(item)
|
|
614
|
+
|
|
615
|
+
self.logger.info(f"User {user_uid}: {len(free_items)} free items, {len(paid_items)} paid items (total cost: {total_cost})")
|
|
616
|
+
|
|
617
|
+
# If no paid items, return all free items
|
|
618
|
+
if not paid_items:
|
|
619
|
+
if credits_extracted_from_authz_response:
|
|
620
|
+
updated_user_credits = credits_extracted_from_authz_response
|
|
621
|
+
else:
|
|
622
|
+
try:
|
|
623
|
+
_, current_user_credits_from_verify = await self.verify_enough_credits(user_uid, 0, None)
|
|
624
|
+
updated_user_credits = current_user_credits_from_verify
|
|
625
|
+
except Exception:
|
|
626
|
+
self.logger.warning("Could not fetch current credits for user %s for free batch.", user_uid)
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
'accessible_items': free_items,
|
|
630
|
+
'charge_successful': True,
|
|
631
|
+
'total_cost': 0.0,
|
|
632
|
+
'paid_items_count': 0,
|
|
633
|
+
'free_items_count': len(free_items),
|
|
634
|
+
'updated_user_credits': updated_user_credits
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
# Check for debug mode bypass
|
|
638
|
+
if self.bypass_credit_check:
|
|
639
|
+
self.logger.info("Bypassing credit check for %s paid items due to debug mode", len(paid_items))
|
|
640
|
+
if credits_extracted_from_authz_response:
|
|
641
|
+
updated_user_credits = credits_extracted_from_authz_response
|
|
642
|
+
else:
|
|
643
|
+
try:
|
|
644
|
+
_, current_user_credits_from_verify = await self.verify_enough_credits(user_uid, 0, None)
|
|
645
|
+
updated_user_credits = current_user_credits_from_verify
|
|
646
|
+
except Exception:
|
|
647
|
+
self.logger.warning("Could not fetch current credits for user %s during debug bypass for batch.", user_uid)
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
'accessible_items': free_items + paid_items,
|
|
651
|
+
'charge_successful': True,
|
|
652
|
+
'total_cost': total_cost,
|
|
653
|
+
'paid_items_count': len(paid_items),
|
|
654
|
+
'free_items_count': len(free_items),
|
|
655
|
+
'updated_user_credits': updated_user_credits
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
# Verify user has enough credits for total cost
|
|
659
|
+
has_credits, current_user_credits_from_verify = await self.verify_enough_credits(
|
|
660
|
+
user_uid,
|
|
661
|
+
total_cost,
|
|
662
|
+
credits_extracted_from_authz_response=credits_extracted_from_authz_response
|
|
663
|
+
)
|
|
664
|
+
updated_user_credits = current_user_credits_from_verify
|
|
665
|
+
|
|
666
|
+
if not has_credits:
|
|
667
|
+
self.logger.warning(f"User {user_uid} has insufficient credits for batch (cost: {total_cost}). Returning free items only.")
|
|
668
|
+
return {
|
|
669
|
+
'accessible_items': free_items,
|
|
670
|
+
'charge_successful': False,
|
|
671
|
+
'total_cost': total_cost,
|
|
672
|
+
'paid_items_count': len(paid_items),
|
|
673
|
+
'free_items_count': len(free_items),
|
|
674
|
+
'updated_user_credits': updated_user_credits
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
# Charge the user for all paid items
|
|
678
|
+
charged, calculated_updated_credits = await self.debit_credits_transaction(
|
|
679
|
+
user_uid,
|
|
680
|
+
total_cost,
|
|
681
|
+
f"{operation_description} ({len(paid_items)} items, total cost: {total_cost})"
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
if calculated_updated_credits is not None:
|
|
685
|
+
updated_user_credits = calculated_updated_credits
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
'accessible_items': free_items + paid_items,
|
|
689
|
+
'charge_successful': charged,
|
|
690
|
+
'total_cost': total_cost,
|
|
691
|
+
'paid_items_count': len(paid_items),
|
|
692
|
+
'free_items_count': len(free_items),
|
|
693
|
+
'updated_user_credits': updated_user_credits
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
except ValidationError as ve:
|
|
697
|
+
self.logger.error("Validation error during batch credit check for user %s: %s", user_uid, str(ve))
|
|
698
|
+
try:
|
|
699
|
+
_, current_user_credits_from_verify = await self.verify_enough_credits(user_uid, 0, credits_extracted_from_authz_response)
|
|
700
|
+
updated_user_credits = current_user_credits_from_verify
|
|
701
|
+
except Exception:
|
|
702
|
+
pass
|
|
703
|
+
ve.additional_info = ve.additional_info or {}
|
|
704
|
+
ve.additional_info['updated_user_credits'] = updated_user_credits
|
|
705
|
+
raise
|
|
706
|
+
except Exception as e:
|
|
707
|
+
self.logger.error("Unexpected error during batch credit check for user %s: %s", user_uid, str(e), exc_info=True)
|
|
708
|
+
current_credits_on_error = None
|
|
709
|
+
try:
|
|
710
|
+
_, current_credits_on_error = await self.verify_enough_credits(user_uid, 0, credits_extracted_from_authz_response)
|
|
711
|
+
updated_user_credits = current_credits_on_error
|
|
712
|
+
except Exception:
|
|
713
|
+
pass
|
|
714
|
+
return {
|
|
715
|
+
'accessible_items': [], # Safe default - no items accessible on error
|
|
716
|
+
'charge_successful': False,
|
|
717
|
+
'total_cost': 0.0, # Safe default
|
|
718
|
+
'paid_items_count': 0, # Safe default
|
|
719
|
+
'free_items_count': 0, # Safe default
|
|
720
|
+
'updated_user_credits': updated_user_credits
|
|
721
|
+
}
|