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.

Files changed (23) hide show
  1. ipulse_shared_core_ftredge/dependencies/__init__.py +3 -1
  2. ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +0 -2
  3. ipulse_shared_core_ftredge/dependencies/authz_credit_extraction.py +67 -0
  4. ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +6 -6
  5. ipulse_shared_core_ftredge/models/__init__.py +3 -1
  6. ipulse_shared_core_ftredge/models/base_api_response.py +6 -43
  7. ipulse_shared_core_ftredge/models/credit_api_response.py +26 -0
  8. ipulse_shared_core_ftredge/models/custom_json_response.py +32 -0
  9. ipulse_shared_core_ftredge/models/user/user_subscription.py +7 -7
  10. ipulse_shared_core_ftredge/models/user/userstatus.py +1 -1
  11. ipulse_shared_core_ftredge/services/charging_processors.py +15 -15
  12. ipulse_shared_core_ftredge/services/user/__init__.py +1 -0
  13. ipulse_shared_core_ftredge/services/user/user_charging_operations.py +721 -0
  14. ipulse_shared_core_ftredge/services/user/user_core_service.py +123 -20
  15. ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +42 -52
  16. ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +1 -1
  17. ipulse_shared_core_ftredge/services/user/userstatus_operations.py +1 -1
  18. ipulse_shared_core_ftredge/services/user_charging_service.py +19 -19
  19. {ipulse_shared_core_ftredge-24.2.1.dist-info → ipulse_shared_core_ftredge-26.1.1.dist-info}/METADATA +1 -1
  20. {ipulse_shared_core_ftredge-24.2.1.dist-info → ipulse_shared_core_ftredge-26.1.1.dist-info}/RECORD +23 -19
  21. {ipulse_shared_core_ftredge-24.2.1.dist-info → ipulse_shared_core_ftredge-26.1.1.dist-info}/WHEEL +0 -0
  22. {ipulse_shared_core_ftredge-24.2.1.dist-info → ipulse_shared_core_ftredge-26.1.1.dist-info}/licenses/LICENCE +0 -0
  23. {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
+ }