django-cfg 1.2.23__py3-none-any.whl → 1.2.27__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.
Files changed (85) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/knowbase/tasks/archive_tasks.py +6 -6
  3. django_cfg/apps/knowbase/tasks/document_processing.py +3 -3
  4. django_cfg/apps/knowbase/tasks/external_data_tasks.py +2 -2
  5. django_cfg/apps/knowbase/tasks/maintenance.py +3 -3
  6. django_cfg/apps/payments/config/__init__.py +15 -37
  7. django_cfg/apps/payments/config/module.py +30 -122
  8. django_cfg/apps/payments/config/providers.py +28 -16
  9. django_cfg/apps/payments/config/settings.py +53 -93
  10. django_cfg/apps/payments/config/utils.py +10 -156
  11. django_cfg/apps/payments/management/__init__.py +3 -0
  12. django_cfg/apps/payments/management/commands/README.md +178 -0
  13. django_cfg/apps/payments/management/commands/__init__.py +3 -0
  14. django_cfg/apps/payments/management/commands/currency_stats.py +323 -0
  15. django_cfg/apps/payments/management/commands/populate_currencies.py +246 -0
  16. django_cfg/apps/payments/management/commands/update_currencies.py +336 -0
  17. django_cfg/apps/payments/managers/currency_manager.py +65 -14
  18. django_cfg/apps/payments/middleware/api_access.py +33 -0
  19. django_cfg/apps/payments/migrations/0001_initial.py +94 -1
  20. django_cfg/apps/payments/models/payments.py +110 -0
  21. django_cfg/apps/payments/services/__init__.py +7 -1
  22. django_cfg/apps/payments/services/core/balance_service.py +14 -16
  23. django_cfg/apps/payments/services/core/fallback_service.py +432 -0
  24. django_cfg/apps/payments/services/core/payment_service.py +212 -29
  25. django_cfg/apps/payments/services/core/subscription_service.py +15 -17
  26. django_cfg/apps/payments/services/internal_types.py +31 -0
  27. django_cfg/apps/payments/services/monitoring/__init__.py +22 -0
  28. django_cfg/apps/payments/services/monitoring/api_schemas.py +222 -0
  29. django_cfg/apps/payments/services/monitoring/provider_health.py +372 -0
  30. django_cfg/apps/payments/services/providers/__init__.py +3 -0
  31. django_cfg/apps/payments/services/providers/cryptapi.py +14 -3
  32. django_cfg/apps/payments/services/providers/cryptomus.py +310 -0
  33. django_cfg/apps/payments/services/providers/registry.py +4 -0
  34. django_cfg/apps/payments/services/security/__init__.py +34 -0
  35. django_cfg/apps/payments/services/security/error_handler.py +637 -0
  36. django_cfg/apps/payments/services/security/payment_notifications.py +342 -0
  37. django_cfg/apps/payments/services/security/webhook_validator.py +475 -0
  38. django_cfg/apps/payments/signals/api_key_signals.py +10 -0
  39. django_cfg/apps/payments/signals/payment_signals.py +3 -2
  40. django_cfg/apps/payments/tasks/__init__.py +12 -0
  41. django_cfg/apps/payments/tasks/webhook_processing.py +177 -0
  42. django_cfg/apps/payments/utils/__init__.py +7 -4
  43. django_cfg/apps/payments/utils/billing_utils.py +342 -0
  44. django_cfg/apps/payments/utils/config_utils.py +2 -0
  45. django_cfg/apps/payments/views/payment_views.py +40 -2
  46. django_cfg/apps/payments/views/webhook_views.py +266 -0
  47. django_cfg/apps/payments/viewsets.py +65 -0
  48. django_cfg/cli/README.md +2 -2
  49. django_cfg/cli/commands/create_project.py +1 -1
  50. django_cfg/cli/commands/info.py +1 -1
  51. django_cfg/cli/main.py +1 -1
  52. django_cfg/cli/utils.py +5 -5
  53. django_cfg/core/config.py +18 -4
  54. django_cfg/models/payments.py +547 -0
  55. django_cfg/models/tasks.py +51 -2
  56. django_cfg/modules/base.py +11 -5
  57. django_cfg/modules/django_currency/README.md +104 -269
  58. django_cfg/modules/django_currency/__init__.py +99 -41
  59. django_cfg/modules/django_currency/clients/__init__.py +11 -0
  60. django_cfg/modules/django_currency/clients/coingecko_client.py +257 -0
  61. django_cfg/modules/django_currency/clients/yfinance_client.py +246 -0
  62. django_cfg/modules/django_currency/core/__init__.py +42 -0
  63. django_cfg/modules/django_currency/core/converter.py +169 -0
  64. django_cfg/modules/django_currency/core/exceptions.py +28 -0
  65. django_cfg/modules/django_currency/core/models.py +54 -0
  66. django_cfg/modules/django_currency/database/__init__.py +25 -0
  67. django_cfg/modules/django_currency/database/database_loader.py +507 -0
  68. django_cfg/modules/django_currency/utils/__init__.py +9 -0
  69. django_cfg/modules/django_currency/utils/cache.py +92 -0
  70. django_cfg/registry/core.py +10 -0
  71. django_cfg/template_archive/__init__.py +0 -0
  72. django_cfg/template_archive/django_sample.zip +0 -0
  73. {django_cfg-1.2.23.dist-info → django_cfg-1.2.27.dist-info}/METADATA +10 -6
  74. {django_cfg-1.2.23.dist-info → django_cfg-1.2.27.dist-info}/RECORD +77 -51
  75. django_cfg/apps/agents/examples/__init__.py +0 -3
  76. django_cfg/apps/agents/examples/simple_example.py +0 -161
  77. django_cfg/apps/knowbase/examples/__init__.py +0 -3
  78. django_cfg/apps/knowbase/examples/external_data_usage.py +0 -191
  79. django_cfg/apps/knowbase/mixins/examples/vehicle_model_example.py +0 -199
  80. django_cfg/modules/django_currency/cache.py +0 -430
  81. django_cfg/modules/django_currency/converter.py +0 -324
  82. django_cfg/modules/django_currency/service.py +0 -277
  83. {django_cfg-1.2.23.dist-info → django_cfg-1.2.27.dist-info}/WHEEL +0 -0
  84. {django_cfg-1.2.23.dist-info → django_cfg-1.2.27.dist-info}/entry_points.txt +0 -0
  85. {django_cfg-1.2.23.dist-info → django_cfg-1.2.27.dist-info}/licenses/LICENSE +0 -0
@@ -8,22 +8,27 @@ and payment lifecycle management.
8
8
  import logging
9
9
  from typing import Optional, List
10
10
  from decimal import Decimal
11
- from datetime import timezone
12
-
13
11
  from django.db import transaction
14
12
  from django.contrib.auth import get_user_model
13
+ from django.utils import timezone
15
14
  from pydantic import BaseModel, Field, ValidationError
16
15
 
17
16
  from .balance_service import BalanceService
17
+ from .fallback_service import get_fallback_service
18
18
  from ...models import UniversalPayment, UserBalance, Transaction
19
19
  from ...utils.config_utils import get_payments_config
20
20
  from ..providers.registry import ProviderRegistry
21
+ from ..monitoring.provider_health import get_health_monitor
21
22
  from ..internal_types import (
22
23
  ProviderResponse, WebhookData, ServiceOperationResult,
23
24
  BalanceUpdateRequest, AccessCheckRequest, AccessCheckResult,
24
- PaymentCreationResult, WebhookProcessingResult, PaymentStatusResult
25
+ PaymentCreationResult, WebhookProcessingResult, PaymentStatusResult,
26
+ PaymentHistoryItem, ProviderInfo
25
27
  )
26
28
 
29
+ # Import django_currency module for currency conversion
30
+ from django_cfg.modules.django_currency import convert_currency, CurrencyError
31
+
27
32
  User = get_user_model()
28
33
  logger = logging.getLogger(__name__)
29
34
 
@@ -281,7 +286,7 @@ class PaymentService:
281
286
  status: Optional[str] = None,
282
287
  limit: int = 50,
283
288
  offset: int = 0
284
- ) -> List[dict]:
289
+ ) -> List[PaymentHistoryItem]:
285
290
  """
286
291
  Get user's payment history.
287
292
 
@@ -292,7 +297,7 @@ class PaymentService:
292
297
  offset: Pagination offset
293
298
 
294
299
  Returns:
295
- List of payment dictionaries
300
+ List of PaymentHistoryItem objects
296
301
  """
297
302
  try:
298
303
  queryset = UniversalPayment.objects.filter(user=user)
@@ -303,16 +308,18 @@ class PaymentService:
303
308
  payments = queryset.order_by('-created_at')[offset:offset+limit]
304
309
 
305
310
  return [
306
- {
307
- 'id': str(payment.id),
308
- 'status': payment.status,
309
- 'amount_usd': str(payment.amount_usd),
310
- 'pay_amount': str(payment.pay_amount) if payment.pay_amount else str(payment.amount_usd),
311
- 'currency_code': payment.currency_code,
312
- 'provider': payment.provider.name if payment.provider else None,
313
- 'created_at': payment.created_at.isoformat(),
314
- 'processed_at': payment.processed_at.isoformat() if payment.processed_at else None
315
- }
311
+ PaymentHistoryItem(
312
+ id=str(payment.id),
313
+ user_id=payment.user.id,
314
+ amount=payment.pay_amount if payment.pay_amount else payment.amount_usd,
315
+ currency=payment.currency_code,
316
+ status=payment.status,
317
+ provider=payment.provider.name if payment.provider else 'unknown',
318
+ provider_payment_id=payment.provider_payment_id,
319
+ created_at=payment.created_at,
320
+ updated_at=payment.updated_at,
321
+ metadata=payment.metadata or {}
322
+ )
316
323
  for payment in payments
317
324
  ]
318
325
 
@@ -356,7 +363,7 @@ class PaymentService:
356
363
 
357
364
  def _convert_to_usd(self, amount: Decimal, currency: str) -> Decimal:
358
365
  """
359
- Convert amount to USD using current exchange rates.
366
+ Convert amount to USD using django_currency module.
360
367
 
361
368
  Args:
362
369
  amount: Amount to convert
@@ -368,26 +375,202 @@ class PaymentService:
368
375
  if currency == 'USD':
369
376
  return amount
370
377
 
371
- # TODO: Implement currency conversion using exchange rate API
372
- # For now, return the same amount (assuming USD)
373
- logger.warning(f"Currency conversion not implemented for {currency}, using 1:1 rate")
374
- return amount
378
+ try:
379
+ # Use django_currency module for conversion
380
+ converted_amount = convert_currency(
381
+ amount=float(amount),
382
+ from_currency=currency,
383
+ to_currency='USD'
384
+ )
385
+
386
+ logger.info(f"Currency conversion: {amount} {currency} = {converted_amount} USD")
387
+ return Decimal(str(converted_amount))
388
+
389
+ except CurrencyError as e:
390
+ logger.error(f"Currency conversion failed for {amount} {currency} to USD: {e}")
391
+ # Fallback to 1:1 rate if conversion fails
392
+ logger.warning(f"Using 1:1 fallback rate for {currency} to USD")
393
+ return amount
394
+
395
+ except Exception as e:
396
+ logger.error(f"Unexpected error in currency conversion: {e}")
397
+ # Fallback to 1:1 rate for any other errors
398
+ logger.warning(f"Using 1:1 fallback rate for {currency} to USD due to error")
399
+ return amount
400
+
401
+ def process_webhook(self, provider: str, webhook_data: dict, headers: dict = None) -> 'WebhookProcessingResult':
402
+ """
403
+ Process webhook from payment provider.
404
+
405
+ Args:
406
+ provider: Provider name
407
+ webhook_data: Webhook payload
408
+ headers: Request headers for validation
409
+
410
+ Returns:
411
+ WebhookProcessingResult with processing status
412
+ """
413
+ try:
414
+ # Get provider instance for validation
415
+ provider_instance = self.provider_registry.get_provider(provider)
416
+ if not provider_instance:
417
+ return WebhookProcessingResult(
418
+ success=False,
419
+ error_message=f"Unknown provider: {provider}"
420
+ )
421
+
422
+ # Validate webhook
423
+ if hasattr(provider_instance, 'validate_webhook'):
424
+ is_valid = provider_instance.validate_webhook(webhook_data, headers)
425
+ if not is_valid:
426
+ logger.warning(f"Invalid webhook from {provider}: {webhook_data}")
427
+ return WebhookProcessingResult(
428
+ success=False,
429
+ error_message="Webhook validation failed"
430
+ )
431
+
432
+ # Process webhook data
433
+ processed_data = provider_instance.process_webhook(webhook_data)
434
+
435
+ # Find payment record
436
+ payment_id = processed_data.payment_id
437
+ if not payment_id:
438
+ return WebhookProcessingResult(
439
+ success=False,
440
+ error_message="No payment ID found in webhook"
441
+ )
442
+
443
+ # Update payment
444
+ with transaction.atomic():
445
+ try:
446
+ payment = UniversalPayment.objects.get(
447
+ provider_payment_id=payment_id,
448
+ provider=provider
449
+ )
450
+
451
+ # Update payment status and data
452
+ old_status = payment.status
453
+ payment.update_from_webhook(webhook_data)
454
+
455
+ # Create event for audit trail
456
+ self._create_payment_event(
457
+ payment=payment,
458
+ event_type='webhook_processed',
459
+ data={
460
+ 'provider': provider,
461
+ 'old_status': old_status,
462
+ 'new_status': payment.status,
463
+ 'webhook_data': webhook_data
464
+ }
465
+ )
466
+
467
+ # Process completion if needed
468
+ if payment.is_completed and old_status != payment.status:
469
+ success = self._process_payment_completion(payment)
470
+ if success:
471
+ payment.processed_at = timezone.now()
472
+ payment.save()
473
+
474
+ return WebhookProcessingResult(
475
+ success=True,
476
+ payment_id=str(payment.id),
477
+ new_status=payment.status
478
+ )
479
+
480
+ except UniversalPayment.DoesNotExist:
481
+ logger.error(f"Payment not found for webhook: provider={provider}, payment_id={payment_id}")
482
+ return WebhookProcessingResult(
483
+ success=False,
484
+ error_message="Payment not found"
485
+ )
486
+
487
+ except Exception as e:
488
+ logger.error(f"Error processing webhook from {provider}: {e}")
489
+ return WebhookProcessingResult(
490
+ success=False,
491
+ error_message=str(e)
492
+ )
375
493
 
494
+ def _create_payment_event(self, payment: UniversalPayment, event_type: str, data: dict):
495
+ """
496
+ Create payment event for audit trail.
497
+
498
+ Args:
499
+ payment: Payment object
500
+ event_type: Type of event
501
+ data: Event data
502
+ """
503
+ try:
504
+ from ...models.events import PaymentEvent
505
+
506
+ # Get next sequence number
507
+ last_event = PaymentEvent.objects.filter(
508
+ payment_id=str(payment.id)
509
+ ).order_by('-sequence_number').first()
510
+
511
+ sequence_number = (last_event.sequence_number + 1) if last_event else 1
512
+
513
+ PaymentEvent.objects.create(
514
+ payment_id=str(payment.id),
515
+ event_type=event_type,
516
+ sequence_number=sequence_number,
517
+ event_data=data,
518
+ processed_by=f"payment_service_{timezone.now().timestamp()}",
519
+ correlation_id=data.get('correlation_id'),
520
+ idempotency_key=f"{payment.id}_{event_type}_{sequence_number}"
521
+ )
522
+
523
+ except Exception as e:
524
+ logger.error(f"Failed to create payment event: {e}")
376
525
 
377
- def list_available_providers(self) -> List[dict]:
526
+ def get_payment_events(self, payment_id: str) -> List[dict]:
527
+ """
528
+ Get all events for a payment.
529
+
530
+ Args:
531
+ payment_id: Payment ID
532
+
533
+ Returns:
534
+ List of payment events
535
+ """
536
+ try:
537
+ from ...models.events import PaymentEvent
538
+
539
+ events = PaymentEvent.objects.filter(
540
+ payment_id=payment_id
541
+ ).order_by('sequence_number')
542
+
543
+ return [
544
+ {
545
+ 'id': str(event.id),
546
+ 'event_type': event.event_type,
547
+ 'sequence_number': event.sequence_number,
548
+ 'event_data': event.event_data,
549
+ 'created_at': event.created_at,
550
+ 'processed_by': event.processed_by
551
+ }
552
+ for event in events
553
+ ]
554
+
555
+ except Exception as e:
556
+ logger.error(f"Error getting payment events for {payment_id}: {e}")
557
+ return []
558
+
559
+
560
+ def list_available_providers(self) -> List[ProviderInfo]:
378
561
  """
379
562
  List all available payment providers.
380
563
 
381
564
  Returns:
382
- List of provider information
565
+ List of ProviderInfo objects
383
566
  """
384
567
  return [
385
- {
386
- 'name': name,
387
- 'display_name': provider.get_display_name(),
388
- 'supported_currencies': provider.get_supported_currencies(),
389
- 'is_active': provider.is_active(),
390
- 'provider_type': provider.get_provider_type()
391
- }
568
+ ProviderInfo(
569
+ name=name,
570
+ display_name=provider.get_display_name(),
571
+ supported_currencies=provider.get_supported_currencies(),
572
+ is_active=provider.is_active(),
573
+ features={'provider_type': provider.get_provider_type()}
574
+ )
392
575
  for name, provider in self.provider_registry.get_all_providers().items()
393
576
  ]
@@ -91,7 +91,7 @@ class SubscriptionService:
91
91
  existing = Subscription.objects.filter(
92
92
  user=user,
93
93
  endpoint_group=endpoint_group,
94
- status='active',
94
+ status=Subscription.SubscriptionStatus.ACTIVE,
95
95
  expires_at__gt=timezone.now()
96
96
  ).first()
97
97
 
@@ -105,8 +105,8 @@ class SubscriptionService:
105
105
  subscription = Subscription.objects.create(
106
106
  user=user,
107
107
  endpoint_group=endpoint_group,
108
- tier='basic',
109
- status='active',
108
+ tier=Subscription.SubscriptionTier.BASIC,
109
+ status=Subscription.SubscriptionStatus.ACTIVE,
110
110
  monthly_price=endpoint_group.basic_price,
111
111
  usage_limit=endpoint_group.basic_limit,
112
112
  usage_current=0,
@@ -268,7 +268,7 @@ class SubscriptionService:
268
268
 
269
269
  if active_only:
270
270
  queryset = queryset.filter(
271
- status='active',
271
+ status=Subscription.SubscriptionStatus.ACTIVE,
272
272
  expires_at__gt=timezone.now()
273
273
  )
274
274
 
@@ -393,20 +393,18 @@ class SubscriptionService:
393
393
  error_message="Subscription not found"
394
394
  )
395
395
 
396
- # Update billing period if provided
397
- if billing_period:
398
- subscription.billing_period = billing_period
399
-
400
- # Calculate new expiry
396
+ # Calculate new expiry based on billing period
401
397
  now = timezone.now()
402
- # Always renew for monthly (30 days) as we don't have billing_period field
403
- new_expiry = now + timedelta(days=30)
398
+ if billing_period == 'yearly':
399
+ new_expiry = now + timedelta(days=365)
400
+ else: # Default to monthly
401
+ new_expiry = now + timedelta(days=30)
404
402
 
405
- # Update subscription
403
+ # Update subscription using correct enum
406
404
  subscription.expires_at = new_expiry
407
- subscription.next_billing = new_expiry # Model uses next_billing, not next_billing_at
408
- subscription.status = 'active' # Use string instead of enum
409
- subscription.usage_current = 0 # Reset usage - correct field name
405
+ subscription.next_billing = new_expiry
406
+ subscription.status = subscription.SubscriptionStatus.ACTIVE # Use proper enum
407
+ subscription.usage_current = 0 # Reset usage counter
410
408
  subscription.save()
411
409
 
412
410
 
@@ -527,7 +525,7 @@ class SubscriptionService:
527
525
  subscription = Subscription.objects.select_related('endpoint_group').get(
528
526
  user_id=user_id,
529
527
  endpoint_group__name=endpoint_group,
530
- status='active',
528
+ status=Subscription.SubscriptionStatus.ACTIVE,
531
529
  expires_at__gt=timezone.now()
532
530
  )
533
531
 
@@ -586,7 +584,7 @@ class SubscriptionService:
586
584
  subscription = Subscription.objects.select_related('endpoint_group').get(
587
585
  user_id=user_id,
588
586
  endpoint_group__name=endpoint_group,
589
- status='active',
587
+ status=Subscription.SubscriptionStatus.ACTIVE,
590
588
  expires_at__gt=timezone.now()
591
589
  )
592
590
 
@@ -264,3 +264,34 @@ class SubscriptionAnalytics(BaseModel):
264
264
  new_subscriptions: int
265
265
  churned_subscriptions: int
266
266
  error: Optional[str] = None
267
+
268
+
269
+ # =============================================================================
270
+ # ADDITIONAL RESPONSE MODELS - Missing Pydantic models
271
+ # =============================================================================
272
+
273
+ class PaymentHistoryItem(BaseModel):
274
+ """Single payment item for history lists"""
275
+ model_config = ConfigDict(validate_assignment=True, extra="forbid")
276
+
277
+ id: str
278
+ user_id: int
279
+ amount: Decimal
280
+ currency: str
281
+ status: str
282
+ provider: str
283
+ provider_payment_id: Optional[str] = None
284
+ created_at: datetime
285
+ updated_at: datetime
286
+ metadata: Dict[str, Any] = Field(default_factory=dict)
287
+
288
+
289
+ class ProviderInfo(BaseModel):
290
+ """Payment provider information"""
291
+ model_config = ConfigDict(validate_assignment=True, extra="forbid")
292
+
293
+ name: str
294
+ display_name: str
295
+ supported_currencies: list[str] = Field(default_factory=list)
296
+ is_active: bool
297
+ features: Dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,22 @@
1
+ """
2
+ Payment system monitoring services.
3
+
4
+ Provides health monitoring, alerting, and fallback mechanisms
5
+ for payment providers and system components.
6
+ """
7
+
8
+ from .provider_health import (
9
+ ProviderHealthMonitor,
10
+ ProviderHealthCheck,
11
+ ProviderHealthSummary,
12
+ HealthStatus,
13
+ get_health_monitor
14
+ )
15
+
16
+ __all__ = [
17
+ 'ProviderHealthMonitor',
18
+ 'ProviderHealthCheck',
19
+ 'ProviderHealthSummary',
20
+ 'HealthStatus',
21
+ 'get_health_monitor'
22
+ ]
@@ -0,0 +1,222 @@
1
+ """
2
+ Pydantic schemas for provider API responses.
3
+
4
+ Type-safe models for validating and parsing responses
5
+ from payment provider health check endpoints.
6
+ """
7
+
8
+ from typing import Dict, Optional, Any
9
+ from decimal import Decimal
10
+ from datetime import datetime
11
+ from pydantic import BaseModel, Field, validator
12
+
13
+
14
+ class CryptAPIInfoResponse(BaseModel):
15
+ """CryptAPI /btc/info/ response schema."""
16
+
17
+ coin: str = Field(..., description="Cryptocurrency name")
18
+ logo: str = Field(..., description="Logo URL")
19
+ ticker: str = Field(..., description="Currency ticker")
20
+ minimum_transaction: int = Field(..., description="Minimum transaction in satoshis")
21
+ minimum_transaction_coin: str = Field(..., description="Minimum transaction in coin units")
22
+ minimum_fee: int = Field(..., description="Minimum fee in satoshis")
23
+ minimum_fee_coin: str = Field(..., description="Minimum fee in coin units")
24
+ fee_percent: str = Field(..., description="Fee percentage")
25
+ network_fee_estimation: str = Field(..., description="Network fee estimation")
26
+ status: str = Field(..., description="API status")
27
+ prices: Dict[str, str] = Field(..., description="Prices in various fiat currencies")
28
+ prices_updated: str = Field(..., description="Prices last updated timestamp")
29
+
30
+ @validator('status')
31
+ def validate_status(cls, v):
32
+ """Validate that status is success."""
33
+ if v != 'success':
34
+ raise ValueError(f"Expected status 'success', got '{v}'")
35
+ return v
36
+
37
+ @validator('prices')
38
+ def validate_prices_not_empty(cls, v):
39
+ """Validate that prices dict is not empty."""
40
+ if not v:
41
+ raise ValueError("Prices dictionary cannot be empty")
42
+ return v
43
+
44
+ def get_usd_price(self) -> Optional[Decimal]:
45
+ """Get USD price as Decimal."""
46
+ usd_price = self.prices.get('USD')
47
+ if usd_price:
48
+ try:
49
+ return Decimal(usd_price)
50
+ except:
51
+ return None
52
+ return None
53
+
54
+
55
+ class NowPaymentsStatusResponse(BaseModel):
56
+ """NowPayments /v1/status response schema."""
57
+
58
+ message: str = Field(..., description="Status message")
59
+
60
+ @validator('message')
61
+ def validate_message_ok(cls, v):
62
+ """Validate that message is OK."""
63
+ if v.upper() != 'OK':
64
+ raise ValueError(f"Expected message 'OK', got '{v}'")
65
+ return v
66
+
67
+
68
+ class StripeErrorResponse(BaseModel):
69
+ """Stripe API error response schema."""
70
+
71
+ class StripeError(BaseModel):
72
+ message: str = Field(..., description="Error message")
73
+ type: str = Field(..., description="Error type")
74
+
75
+ error: StripeError = Field(..., description="Error details")
76
+
77
+ @validator('error')
78
+ def validate_auth_error(cls, v):
79
+ """Validate this is an authentication error (meaning API is healthy)."""
80
+ if v.type != 'invalid_request_error':
81
+ raise ValueError(f"Expected auth error, got '{v.type}'")
82
+ return v
83
+
84
+
85
+ class CryptomusErrorResponse(BaseModel):
86
+ """Cryptomus API error response schema."""
87
+
88
+ error: str = Field(..., description="Error message")
89
+
90
+ @validator('error')
91
+ def validate_not_found_error(cls, v):
92
+ """Validate this is a not found error (meaning API is responding)."""
93
+ if v.lower() not in ['not found', 'unauthorized', 'forbidden']:
94
+ raise ValueError(f"Unexpected error: {v}")
95
+ return v
96
+
97
+
98
+ class GenericAPIHealthResponse(BaseModel):
99
+ """Generic API health response for unknown formats."""
100
+
101
+ status_code: int = Field(..., description="HTTP status code")
102
+ response_body: str = Field(..., description="Raw response body")
103
+ response_time_ms: float = Field(..., description="Response time in milliseconds")
104
+
105
+ def is_healthy(self) -> bool:
106
+ """Determine if API is healthy based on status code."""
107
+ # 2xx = healthy, 401/403 = healthy (auth required), 4xx = degraded, 5xx = unhealthy
108
+ if 200 <= self.status_code < 300:
109
+ return True
110
+ elif self.status_code in [401, 403]:
111
+ return True # Auth required but API responding
112
+ else:
113
+ return False
114
+
115
+
116
+ class ProviderHealthResponse(BaseModel):
117
+ """Unified health response model for all providers."""
118
+
119
+ provider_name: str = Field(..., description="Provider name")
120
+ is_healthy: bool = Field(..., description="Is provider healthy")
121
+ status_code: int = Field(..., description="HTTP status code")
122
+ response_time_ms: float = Field(..., description="Response time in milliseconds")
123
+ error_message: Optional[str] = Field(None, description="Error message if unhealthy")
124
+ parsed_response: Optional[Dict[str, Any]] = Field(None, description="Parsed API response")
125
+ raw_response: Optional[str] = Field(None, description="Raw response body")
126
+ checked_at: datetime = Field(default_factory=datetime.now, description="Check timestamp")
127
+
128
+ class Config:
129
+ json_encoders = {
130
+ datetime: lambda v: v.isoformat()
131
+ }
132
+
133
+
134
+ def parse_provider_response(provider_name: str, status_code: int, response_body: str, response_time_ms: float) -> ProviderHealthResponse:
135
+ """
136
+ Parse provider API response using appropriate schema.
137
+
138
+ Args:
139
+ provider_name: Name of the provider
140
+ status_code: HTTP status code
141
+ response_body: Raw response body
142
+ response_time_ms: Response time in milliseconds
143
+
144
+ Returns:
145
+ ProviderHealthResponse with parsed data
146
+ """
147
+ parsed_response = None
148
+ error_message = None
149
+ is_healthy = False
150
+
151
+ try:
152
+ import json
153
+ response_json = json.loads(response_body) if response_body else {}
154
+
155
+ if provider_name == 'cryptapi':
156
+ if status_code == 200:
157
+ cryptapi_response = CryptAPIInfoResponse(**response_json)
158
+ parsed_response = cryptapi_response.dict()
159
+ is_healthy = True
160
+ else:
161
+ error_message = f"CryptAPI returned status {status_code}"
162
+
163
+ elif provider_name == 'nowpayments':
164
+ if status_code == 200:
165
+ nowpayments_response = NowPaymentsStatusResponse(**response_json)
166
+ parsed_response = nowpayments_response.dict()
167
+ is_healthy = True
168
+ else:
169
+ error_message = f"NowPayments returned status {status_code}"
170
+
171
+ elif provider_name == 'stripe':
172
+ if status_code == 401:
173
+ stripe_response = StripeErrorResponse(**response_json)
174
+ parsed_response = stripe_response.dict()
175
+ is_healthy = True # Auth error = API responding
176
+ elif 200 <= status_code < 300:
177
+ parsed_response = response_json
178
+ is_healthy = True
179
+ else:
180
+ error_message = f"Stripe returned unexpected status {status_code}"
181
+
182
+ elif provider_name == 'cryptomus':
183
+ if status_code == 404 and response_json.get('error') == 'Not found':
184
+ cryptomus_response = CryptomusErrorResponse(**response_json)
185
+ parsed_response = cryptomus_response.dict()
186
+ is_healthy = True # Not found = API responding
187
+ elif status_code == 204:
188
+ # No Content = API responding and healthy
189
+ parsed_response = {'status': 'no_content', 'message': 'API responding correctly'}
190
+ is_healthy = True
191
+ elif status_code in [401, 403]:
192
+ is_healthy = True # Auth required = API responding
193
+ parsed_response = response_json
194
+ elif 200 <= status_code < 300:
195
+ parsed_response = response_json
196
+ is_healthy = True
197
+ else:
198
+ error_message = f"Cryptomus returned status {status_code}"
199
+
200
+ else:
201
+ # Generic handling for unknown providers
202
+ generic_response = GenericAPIHealthResponse(
203
+ status_code=status_code,
204
+ response_body=response_body,
205
+ response_time_ms=response_time_ms
206
+ )
207
+ parsed_response = generic_response.dict()
208
+ is_healthy = generic_response.is_healthy()
209
+
210
+ except Exception as e:
211
+ error_message = f"Failed to parse {provider_name} response: {str(e)}"
212
+ is_healthy = False
213
+
214
+ return ProviderHealthResponse(
215
+ provider_name=provider_name,
216
+ is_healthy=is_healthy,
217
+ status_code=status_code,
218
+ response_time_ms=response_time_ms,
219
+ error_message=error_message,
220
+ parsed_response=parsed_response,
221
+ raw_response=response_body
222
+ )