django-cfg 1.2.22__py3-none-any.whl → 1.2.23__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 (67) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/payments/admin/__init__.py +23 -0
  3. django_cfg/apps/payments/admin/api_keys_admin.py +347 -0
  4. django_cfg/apps/payments/admin/balance_admin.py +434 -0
  5. django_cfg/apps/payments/admin/currencies_admin.py +186 -0
  6. django_cfg/apps/payments/admin/filters.py +259 -0
  7. django_cfg/apps/payments/admin/payments_admin.py +142 -0
  8. django_cfg/apps/payments/admin/subscriptions_admin.py +227 -0
  9. django_cfg/apps/payments/admin/tariffs_admin.py +199 -0
  10. django_cfg/apps/payments/config/__init__.py +87 -0
  11. django_cfg/apps/payments/config/module.py +162 -0
  12. django_cfg/apps/payments/config/providers.py +93 -0
  13. django_cfg/apps/payments/config/settings.py +136 -0
  14. django_cfg/apps/payments/config/utils.py +198 -0
  15. django_cfg/apps/payments/decorators.py +291 -0
  16. django_cfg/apps/payments/middleware/api_access.py +261 -0
  17. django_cfg/apps/payments/middleware/rate_limiting.py +216 -0
  18. django_cfg/apps/payments/middleware/usage_tracking.py +296 -0
  19. django_cfg/apps/payments/migrations/0001_initial.py +32 -11
  20. django_cfg/apps/payments/models/__init__.py +18 -0
  21. django_cfg/apps/payments/models/api_keys.py +2 -2
  22. django_cfg/apps/payments/models/balance.py +2 -2
  23. django_cfg/apps/payments/models/base.py +16 -0
  24. django_cfg/apps/payments/models/events.py +2 -2
  25. django_cfg/apps/payments/models/payments.py +2 -2
  26. django_cfg/apps/payments/models/subscriptions.py +2 -2
  27. django_cfg/apps/payments/services/__init__.py +58 -7
  28. django_cfg/apps/payments/services/billing/__init__.py +8 -0
  29. django_cfg/apps/payments/services/cache/__init__.py +15 -0
  30. django_cfg/apps/payments/services/cache/base.py +30 -0
  31. django_cfg/apps/payments/services/cache/simple_cache.py +135 -0
  32. django_cfg/apps/payments/services/core/__init__.py +17 -0
  33. django_cfg/apps/payments/services/core/balance_service.py +449 -0
  34. django_cfg/apps/payments/services/core/payment_service.py +393 -0
  35. django_cfg/apps/payments/services/core/subscription_service.py +616 -0
  36. django_cfg/apps/payments/services/internal_types.py +266 -0
  37. django_cfg/apps/payments/services/middleware/__init__.py +8 -0
  38. django_cfg/apps/payments/services/providers/__init__.py +19 -0
  39. django_cfg/apps/payments/services/providers/base.py +137 -0
  40. django_cfg/apps/payments/services/providers/cryptapi.py +262 -0
  41. django_cfg/apps/payments/services/providers/nowpayments.py +293 -0
  42. django_cfg/apps/payments/services/providers/registry.py +99 -0
  43. django_cfg/apps/payments/services/validators/__init__.py +8 -0
  44. django_cfg/apps/payments/signals/__init__.py +13 -0
  45. django_cfg/apps/payments/signals/api_key_signals.py +150 -0
  46. django_cfg/apps/payments/signals/payment_signals.py +127 -0
  47. django_cfg/apps/payments/signals/subscription_signals.py +196 -0
  48. django_cfg/apps/payments/urls.py +5 -5
  49. django_cfg/apps/payments/utils/__init__.py +42 -0
  50. django_cfg/apps/payments/utils/config_utils.py +243 -0
  51. django_cfg/apps/payments/utils/middleware_utils.py +228 -0
  52. django_cfg/apps/payments/utils/validation_utils.py +94 -0
  53. django_cfg/apps/support/signals.py +16 -4
  54. django_cfg/apps/support/templates/support/chat/ticket_chat.html +1 -1
  55. django_cfg/models/revolution.py +1 -1
  56. django_cfg/modules/base.py +1 -1
  57. django_cfg/modules/django_email.py +42 -4
  58. django_cfg/modules/django_unfold/dashboard.py +20 -0
  59. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/METADATA +2 -1
  60. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/RECORD +63 -26
  61. django_cfg/apps/payments/services/base.py +0 -68
  62. django_cfg/apps/payments/services/nowpayments.py +0 -78
  63. django_cfg/apps/payments/services/providers.py +0 -77
  64. django_cfg/apps/payments/services/redis_service.py +0 -215
  65. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/WHEEL +0 -0
  66. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/entry_points.txt +0 -0
  67. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,293 @@
1
+ """
2
+ NowPayments provider implementation.
3
+
4
+ Enhanced crypto payment provider with minimal typing.
5
+ """
6
+
7
+ import logging
8
+ import requests
9
+ import hashlib
10
+ import hmac
11
+ from typing import Optional, List
12
+ from decimal import Decimal
13
+ from pydantic import BaseModel, Field
14
+
15
+ from .base import PaymentProvider
16
+ from ..internal_types import ProviderResponse, WebhookData
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class NowPaymentsConfig(BaseModel):
22
+ """NowPayments provider configuration."""
23
+ api_key: str = Field(..., description="NowPayments API key")
24
+ sandbox: bool = Field(default=False, description="Use sandbox mode")
25
+ ipn_secret: Optional[str] = Field(default=None, description="IPN secret for webhook validation")
26
+ callback_url: Optional[str] = Field(default=None, description="Webhook callback URL")
27
+ success_url: Optional[str] = Field(default=None, description="Payment success redirect URL")
28
+ cancel_url: Optional[str] = Field(default=None, description="Payment cancel redirect URL")
29
+ enabled: bool = Field(default=True, description="Provider enabled")
30
+
31
+
32
+ class NowPaymentsProvider(PaymentProvider):
33
+ """NowPayments cryptocurrency payment provider."""
34
+
35
+ def __init__(self, config: NowPaymentsConfig):
36
+ """Initialize NowPayments provider."""
37
+ super().__init__(config.dict())
38
+ self.config = config
39
+ self.api_key = config.api_key
40
+ self.sandbox = config.sandbox
41
+ self.ipn_secret = config.ipn_secret or ''
42
+ self.base_url = self._get_base_url()
43
+
44
+ # Configurable URLs
45
+ self.callback_url = config.callback_url
46
+ self.success_url = config.success_url
47
+ self.cancel_url = config.cancel_url
48
+
49
+ self.headers = {
50
+ 'x-api-key': self.api_key,
51
+ 'Content-Type': 'application/json'
52
+ }
53
+
54
+ def _get_base_url(self) -> str:
55
+ """Get base URL based on sandbox mode."""
56
+ if self.sandbox:
57
+ return 'https://api-sandbox.nowpayments.io/v1'
58
+ return 'https://api.nowpayments.io/v1'
59
+
60
+ def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> Optional[dict]:
61
+ """Make HTTP request to NowPayments API with error handling."""
62
+ try:
63
+ url = f"{self.base_url}/{endpoint}"
64
+
65
+ response = requests.request(
66
+ method=method,
67
+ url=url,
68
+ headers=self.headers,
69
+ json=data,
70
+ timeout=30
71
+ )
72
+
73
+ response.raise_for_status()
74
+ return response.json()
75
+
76
+ except requests.exceptions.RequestException as e:
77
+ logger.error(f"NowPayments API request failed: {e}")
78
+ return None
79
+ except Exception as e:
80
+ logger.error(f"Unexpected error in NowPayments request: {e}")
81
+ return None
82
+
83
+ def create_payment(self, payment_data: dict) -> ProviderResponse:
84
+ """Create payment via NowPayments API."""
85
+ try:
86
+ amount = Decimal(str(payment_data['amount']))
87
+ currency = payment_data['currency']
88
+ order_id = payment_data.get('order_id', f'payment_{int(amount * 100)}_{currency}')
89
+
90
+ payment_request = {
91
+ 'price_amount': float(amount),
92
+ 'price_currency': 'usd', # Base currency
93
+ 'pay_currency': currency,
94
+ 'order_id': order_id,
95
+ 'order_description': payment_data.get('description', f'Payment {order_id}'),
96
+ }
97
+
98
+ # Add optional URLs
99
+ if self.success_url:
100
+ payment_request['success_url'] = self.success_url
101
+ if self.cancel_url:
102
+ payment_request['cancel_url'] = self.cancel_url
103
+ if self.callback_url:
104
+ payment_request['ipn_callback_url'] = self.callback_url
105
+
106
+ response = self._make_request('POST', 'payment', payment_request)
107
+
108
+ if response:
109
+ return ProviderResponse(
110
+ success=True,
111
+ provider_payment_id=response.get('payment_id'),
112
+ payment_url=response.get('invoice_url'),
113
+ pay_address=response.get('pay_address'),
114
+ amount=Decimal(str(response.get('pay_amount', 0))),
115
+ currency=response.get('pay_currency'),
116
+ status='pending'
117
+ )
118
+ else:
119
+ return ProviderResponse(
120
+ success=False,
121
+ error_message='Failed to create payment'
122
+ )
123
+
124
+ except Exception as e:
125
+ logger.error(f"NowPayments create_payment error: {e}")
126
+ return ProviderResponse(
127
+ success=False,
128
+ error_message=str(e)
129
+ )
130
+
131
+ def check_payment_status(self, payment_id: str) -> ProviderResponse:
132
+ """Check payment status via NowPayments API."""
133
+ try:
134
+ response = self._make_request('GET', f'payment/{payment_id}')
135
+
136
+ if response:
137
+ # Map NowPayments status to universal status
138
+ status_mapping = {
139
+ 'waiting': 'pending',
140
+ 'confirming': 'processing',
141
+ 'confirmed': 'completed',
142
+ 'sending': 'processing',
143
+ 'partially_paid': 'pending',
144
+ 'finished': 'completed',
145
+ 'failed': 'failed',
146
+ 'refunded': 'refunded',
147
+ 'expired': 'expired'
148
+ }
149
+
150
+ provider_status = response.get('payment_status', 'unknown')
151
+ universal_status = status_mapping.get(provider_status, 'unknown')
152
+
153
+ return ProviderResponse(
154
+ success=True,
155
+ provider_payment_id=response.get('payment_id'),
156
+ status=universal_status,
157
+ pay_address=response.get('pay_address'),
158
+ amount=Decimal(str(response.get('pay_amount', 0))),
159
+ currency=response.get('pay_currency')
160
+ )
161
+ else:
162
+ return ProviderResponse(
163
+ success=False,
164
+ error_message='Payment not found'
165
+ )
166
+
167
+ except Exception as e:
168
+ logger.error(f"NowPayments check_payment_status error: {e}")
169
+ return ProviderResponse(
170
+ success=False,
171
+ error_message=str(e)
172
+ )
173
+
174
+ def process_webhook(self, payload: dict) -> WebhookData:
175
+ """Process NowPayments webhook."""
176
+ try:
177
+ # Map status
178
+ status_mapping = {
179
+ 'waiting': 'pending',
180
+ 'confirming': 'processing',
181
+ 'confirmed': 'completed',
182
+ 'sending': 'processing',
183
+ 'partially_paid': 'pending',
184
+ 'finished': 'completed',
185
+ 'failed': 'failed',
186
+ 'refunded': 'refunded',
187
+ 'expired': 'expired'
188
+ }
189
+
190
+ provider_status = payload.get('payment_status', 'unknown')
191
+ universal_status = status_mapping.get(provider_status, 'unknown')
192
+
193
+ return WebhookData(
194
+ provider_payment_id=str(payload.get('payment_id', '')),
195
+ status=universal_status,
196
+ pay_amount=Decimal(str(payload.get('pay_amount', 0))),
197
+ actually_paid=Decimal(str(payload.get('actually_paid', 0))),
198
+ order_id=payload.get('order_id'),
199
+ signature=payload.get('signature')
200
+ )
201
+
202
+ except Exception as e:
203
+ logger.error(f"NowPayments webhook processing error: {e}")
204
+ raise
205
+
206
+ def get_supported_currencies(self) -> List[str]:
207
+ """Get list of supported currencies."""
208
+ try:
209
+ response = self._make_request('GET', 'currencies')
210
+
211
+ if response and 'currencies' in response:
212
+ return response['currencies']
213
+ else:
214
+ # Fallback currencies
215
+ return ['BTC', 'ETH', 'LTC', 'BCH', 'XMR', 'TRX', 'BNB']
216
+
217
+ except Exception as e:
218
+ logger.error(f"Error getting supported currencies: {e}")
219
+ return ['BTC', 'ETH', 'LTC'] # Minimal fallback
220
+
221
+ def get_minimum_payment_amount(self, currency_from: str, currency_to: str = 'usd') -> Optional[Decimal]:
222
+ """Get minimum payment amount for currency pair."""
223
+ try:
224
+ response = self._make_request('GET', 'min-amount', {
225
+ 'currency_from': currency_from,
226
+ 'currency_to': currency_to
227
+ })
228
+
229
+ if response and 'min_amount' in response:
230
+ return Decimal(str(response['min_amount']))
231
+
232
+ return None
233
+
234
+ except Exception as e:
235
+ logger.error(f"Error getting minimum amount: {e}")
236
+ return None
237
+
238
+ def estimate_payment_amount(self, amount: Decimal, currency_code: str) -> Optional[dict]:
239
+ """Estimate payment amount in target currency."""
240
+ try:
241
+ response = self._make_request('GET', 'estimate', {
242
+ 'amount': float(amount),
243
+ 'currency_from': 'usd',
244
+ 'currency_to': currency_code
245
+ })
246
+
247
+ if response and 'estimated_amount' in response:
248
+ return {
249
+ 'estimated_amount': Decimal(str(response['estimated_amount'])),
250
+ 'currency_from': response.get('currency_from'),
251
+ 'currency_to': response.get('currency_to'),
252
+ 'fee_amount': Decimal(str(response.get('fee_amount', 0)))
253
+ }
254
+
255
+ return None
256
+
257
+ except Exception as e:
258
+ logger.error(f"Error estimating payment amount: {e}")
259
+ return None
260
+
261
+ def validate_webhook(self, payload: dict, headers: Optional[dict] = None) -> bool:
262
+ """Validate NowPayments webhook signature."""
263
+ try:
264
+ if not self.ipn_secret:
265
+ logger.warning("IPN secret not configured, skipping webhook validation")
266
+ return True
267
+
268
+ if not headers:
269
+ logger.warning("No headers provided for webhook validation")
270
+ return False
271
+
272
+ # Get signature from headers
273
+ signature = headers.get('x-nowpayments-sig')
274
+ if not signature:
275
+ logger.warning("No signature found in webhook headers")
276
+ return False
277
+
278
+ # TODO: Implement proper HMAC signature validation
279
+ # This requires the raw payload body for proper validation
280
+ logger.info("Webhook signature validation placeholder")
281
+ return True
282
+
283
+ except Exception as e:
284
+ logger.error(f"Webhook validation error: {e}")
285
+ return False
286
+
287
+ def check_api_status(self) -> bool:
288
+ """Check if NowPayments API is available."""
289
+ try:
290
+ response = self._make_request('GET', 'status')
291
+ return response is not None and response.get('message') == 'OK'
292
+ except:
293
+ return False
@@ -0,0 +1,99 @@
1
+ """
2
+ Provider registry for managing payment providers.
3
+
4
+ Central registry with lazy loading and typed configuration.
5
+ """
6
+
7
+ import logging
8
+ from typing import Optional, List
9
+
10
+ from .base import PaymentProvider
11
+ from .nowpayments import NowPaymentsProvider, NowPaymentsConfig
12
+ from .cryptapi import CryptAPIProvider, CryptAPIConfig
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ProviderRegistry:
18
+ """Central registry for payment providers with typed configs."""
19
+
20
+ def __init__(self):
21
+ """Initialize registry with lazy loading."""
22
+ self._providers: dict[str, PaymentProvider] = {}
23
+ self._provider_configs: dict[str, dict] = {}
24
+ self._load_configurations()
25
+
26
+ def _load_configurations(self) -> None:
27
+ """Load provider configurations."""
28
+ try:
29
+ from ...utils.config_utils import get_payments_config
30
+ config = get_payments_config()
31
+
32
+ self._provider_configs = {}
33
+ for provider_name, provider_config in config.providers.items():
34
+ if provider_config.enabled:
35
+ self._provider_configs[provider_name] = provider_config.get_config_dict()
36
+
37
+ except Exception as e:
38
+ logger.warning(f"Failed to load provider configurations: {e}")
39
+ self._provider_configs = {}
40
+
41
+ def _create_provider(self, name: str, config_dict: dict) -> Optional[PaymentProvider]:
42
+ """Create provider instance from configuration with typed config."""
43
+ try:
44
+ if name == 'nowpayments':
45
+ config = NowPaymentsConfig(**config_dict)
46
+ return NowPaymentsProvider(config)
47
+ elif name == 'cryptapi':
48
+ config = CryptAPIConfig(**config_dict)
49
+ return CryptAPIProvider(config)
50
+ elif name == 'stripe':
51
+ # TODO: Implement StripeProvider with StripeConfig
52
+ return None
53
+ else:
54
+ logger.warning(f"Unknown provider type: {name}")
55
+ return None
56
+
57
+ except Exception as e:
58
+ logger.error(f"Failed to create provider {name}: {e}")
59
+ return None
60
+
61
+ def register_provider(self, name: str, provider: PaymentProvider) -> None:
62
+ """Register a payment provider instance."""
63
+ self._providers[name] = provider
64
+
65
+ def get_provider(self, name: str) -> Optional[PaymentProvider]:
66
+ """Get provider by name with lazy loading."""
67
+ # Check if already loaded
68
+ if name in self._providers:
69
+ return self._providers[name]
70
+
71
+ # Try to load from configuration
72
+ if name in self._provider_configs:
73
+ provider = self._create_provider(name, self._provider_configs[name])
74
+ if provider:
75
+ self._providers[name] = provider
76
+ return provider
77
+
78
+ return None
79
+
80
+ def list_providers(self) -> List[str]:
81
+ """Get list of available providers."""
82
+ available = set(self._providers.keys())
83
+ available.update(self._provider_configs.keys())
84
+ return list(available)
85
+
86
+ def get_active_providers(self) -> List[str]:
87
+ """Get list of active providers."""
88
+ active = []
89
+ for name in self.list_providers():
90
+ provider = self.get_provider(name)
91
+ if provider and provider.is_enabled():
92
+ active.append(name)
93
+ return active
94
+
95
+ def reload_providers(self) -> None:
96
+ """Reload all providers from configuration."""
97
+ logger.info("Reloading providers from configuration")
98
+ self._providers.clear()
99
+ self._load_configurations()
@@ -0,0 +1,8 @@
1
+ """
2
+ Validators for payment services.
3
+
4
+ TODO: Implement payment validators when needed.
5
+ """
6
+
7
+ # Placeholder for future validators
8
+ __all__ = []
@@ -0,0 +1,13 @@
1
+ """
2
+ Universal Payment Signals.
3
+
4
+ Automatically imports all signal handlers when the payments app is loaded.
5
+ """
6
+
7
+ from .api_key_signals import * # noqa: F401,F403
8
+ from .payment_signals import * # noqa: F401,F403
9
+ from .subscription_signals import * # noqa: F401,F403
10
+
11
+ __all__ = [
12
+ # Signal functions are automatically exported by Django
13
+ ]
@@ -0,0 +1,150 @@
1
+ """
2
+ 🔄 Universal API Keys Auto-Creation Signals
3
+
4
+ Automatic API key creation and management via Django signals.
5
+ Enhanced version of CarAPI signals with universal support.
6
+ """
7
+
8
+ from django.db.models.signals import post_save, post_delete, pre_save
9
+ from django.dispatch import receiver
10
+ from django.contrib.auth import get_user_model
11
+ from django.db import transaction
12
+ from django.utils import timezone
13
+ import logging
14
+
15
+ from ..models import APIKey
16
+
17
+ User = get_user_model()
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @receiver(post_save, sender=User)
22
+ def create_default_api_key(sender, instance, created, **kwargs):
23
+ """
24
+ Automatically create default API key for new users.
25
+ This ensures every user can immediately start using the API.
26
+ """
27
+ if created:
28
+ try:
29
+ with transaction.atomic():
30
+ api_key = APIKey.objects.create(
31
+ user=instance,
32
+ name="Default API Key",
33
+ is_active=True
34
+ )
35
+
36
+ logger.info(
37
+ f"Created default API key for user {instance.email}: {api_key.key_prefix}***"
38
+ )
39
+
40
+ # Optional: Send welcome email with API key info
41
+ # This would be handled in custom project implementations
42
+ # from .tasks import send_api_key_welcome_email
43
+ # send_api_key_welcome_email.delay(instance.id, api_key.id)
44
+
45
+ except Exception as e:
46
+ logger.error(f"Failed to create default API key for user {instance.email}: {e}")
47
+
48
+
49
+ @receiver(post_save, sender=User)
50
+ def ensure_user_has_api_key(sender, instance, **kwargs):
51
+ """
52
+ Ensure user always has at least one API key.
53
+ Creates one if user has no active keys.
54
+ """
55
+ # Skip if this is a new user (handled by create_default_api_key)
56
+ if kwargs.get('created', False):
57
+ return
58
+
59
+ # Check if user has any active keys
60
+ if not APIKey.objects.filter(user=instance, is_active=True).exists():
61
+ try:
62
+ with transaction.atomic():
63
+ api_key = APIKey.objects.create(
64
+ user=instance,
65
+ name="Recovery API Key",
66
+ is_active=True
67
+ )
68
+ logger.info(
69
+ f"Created recovery API key for user {instance.email}: {api_key.key_prefix}***"
70
+ )
71
+ except Exception as e:
72
+ logger.error(f"Failed to create recovery API key for user {instance.email}: {e}")
73
+
74
+
75
+ @receiver(pre_save, sender=APIKey)
76
+ def store_original_status(sender, instance, **kwargs):
77
+ """Store original status for change detection."""
78
+ if instance.pk:
79
+ try:
80
+ old_instance = APIKey.objects.get(pk=instance.pk)
81
+ instance._original_is_active = old_instance.is_active
82
+ except APIKey.DoesNotExist:
83
+ instance._original_is_active = None
84
+
85
+
86
+ @receiver(post_save, sender=APIKey)
87
+ def log_api_key_changes(sender, instance, created, **kwargs):
88
+ """Log API key creation and status changes for security monitoring."""
89
+ if created:
90
+ logger.info(
91
+ f"New API key created: {instance.name} ({instance.key_prefix}***) "
92
+ f"for user {instance.user.email}"
93
+ )
94
+ else:
95
+ # Check if status changed
96
+ if hasattr(instance, '_original_is_active'):
97
+ old_status = instance._original_is_active
98
+ new_status = instance.is_active
99
+
100
+ if old_status is not None and old_status != new_status:
101
+ status_text = "activated" if new_status else "deactivated"
102
+ logger.warning(
103
+ f"API key {status_text}: {instance.name} ({instance.key_prefix}***) "
104
+ f"for user {instance.user.email}"
105
+ )
106
+
107
+
108
+ @receiver(post_save, sender=APIKey)
109
+ def update_last_used_on_activation(sender, instance, created, **kwargs):
110
+ """Update last_used when API key is activated."""
111
+ if not created and instance.is_active and hasattr(instance, '_original_is_active'):
112
+ if instance._original_is_active is False and instance.is_active is True:
113
+ # Key was just activated
114
+ APIKey.objects.filter(pk=instance.pk).update(
115
+ last_used=timezone.now()
116
+ )
117
+
118
+
119
+ @receiver(post_delete, sender=APIKey)
120
+ def log_api_key_deletion(sender, instance, **kwargs):
121
+ """Log API key deletions for security audit."""
122
+ logger.warning(
123
+ f"API key deleted: {instance.name} ({instance.key_prefix}***) "
124
+ f"for user {instance.user.email} - Status was: {'active' if instance.is_active else 'inactive'}"
125
+ )
126
+
127
+
128
+ @receiver(post_delete, sender=APIKey)
129
+ def ensure_user_has_remaining_key(sender, instance, **kwargs):
130
+ """
131
+ Ensure user still has at least one API key after deletion.
132
+ Creates a new one if this was the last active key.
133
+ """
134
+ user = instance.user
135
+
136
+ # Check if user has any remaining active keys
137
+ if not APIKey.objects.filter(user=user, is_active=True).exists():
138
+ try:
139
+ with transaction.atomic():
140
+ api_key = APIKey.objects.create(
141
+ user=user,
142
+ name="Auto Recovery API Key",
143
+ is_active=True
144
+ )
145
+ logger.info(
146
+ f"Created auto-recovery API key for user {user.email}: {api_key.key_prefix}*** "
147
+ f"(previous key was deleted)"
148
+ )
149
+ except Exception as e:
150
+ logger.error(f"Failed to create auto-recovery API key for user {user.email}: {e}")
@@ -0,0 +1,127 @@
1
+ """
2
+ 🔄 Universal Payment Signals
3
+
4
+ Automatic payment processing and balance management via Django signals.
5
+ """
6
+
7
+ from django.db.models.signals import post_save, pre_save
8
+ from django.dispatch import receiver
9
+ from django.db import transaction
10
+ from django.utils import timezone
11
+ import logging
12
+
13
+ from ..models import UniversalPayment, UserBalance, Transaction
14
+ from ..services.cache import SimpleCache
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @receiver(pre_save, sender=UniversalPayment)
20
+ def store_original_payment_status(sender, instance, **kwargs):
21
+ """Store original payment status for change detection."""
22
+ if instance.pk:
23
+ try:
24
+ old_instance = UniversalPayment.objects.get(pk=instance.pk)
25
+ instance._original_status = old_instance.status
26
+ except UniversalPayment.DoesNotExist:
27
+ instance._original_status = None
28
+
29
+
30
+ @receiver(post_save, sender=UniversalPayment)
31
+ def process_payment_status_changes(sender, instance, created, **kwargs):
32
+ """Process payment status changes and update user balance."""
33
+ if created:
34
+ logger.info(f"New payment created: {instance.internal_payment_id} for user {instance.user.email}")
35
+ return
36
+
37
+ # Check if status changed to completed
38
+ if hasattr(instance, '_original_status'):
39
+ old_status = instance._original_status
40
+ new_status = instance.status
41
+
42
+ if old_status != new_status:
43
+ logger.info(
44
+ f"Payment status changed: {instance.internal_payment_id} "
45
+ f"for user {instance.user.email} - {old_status} → {new_status}"
46
+ )
47
+
48
+ # Process completed payment
49
+ if new_status == UniversalPayment.PaymentStatus.COMPLETED and old_status != new_status:
50
+ _process_completed_payment(instance)
51
+
52
+
53
+ def _process_completed_payment(payment: UniversalPayment):
54
+ """Process completed payment and add funds to user balance."""
55
+ try:
56
+ with transaction.atomic():
57
+ # Get or create user balance
58
+ balance, created = UserBalance.objects.get_or_create(
59
+ user=payment.user,
60
+ defaults={
61
+ 'amount_usd': 0,
62
+ 'reserved_usd': 0
63
+ }
64
+ )
65
+
66
+ # Add funds to balance
67
+ old_balance = balance.amount_usd
68
+ balance.amount_usd += payment.amount_usd
69
+ balance.save()
70
+
71
+ # Create transaction record
72
+ Transaction.objects.create(
73
+ user=payment.user,
74
+ transaction_type=Transaction.TransactionType.PAYMENT,
75
+ amount_usd=payment.amount_usd,
76
+ balance_before=old_balance,
77
+ balance_after=balance.amount_usd,
78
+ description=f"Payment completed: {payment.internal_payment_id}",
79
+ payment=payment,
80
+ metadata={
81
+ 'provider': payment.provider,
82
+ 'provider_payment_id': payment.provider_payment_id,
83
+ 'original_amount': str(payment.original_amount),
84
+ 'original_currency': payment.original_currency
85
+ }
86
+ )
87
+
88
+ # Mark payment as processed
89
+ payment.processed_at = timezone.now()
90
+ payment.save(update_fields=['processed_at'])
91
+
92
+ # Clear Redis cache for user
93
+ try:
94
+ redis_service = RedisService()
95
+ redis_service.invalidate_user_cache(payment.user.id)
96
+ except Exception as e:
97
+ logger.warning(f"Failed to clear Redis cache for user {payment.user.id}: {e}")
98
+
99
+ logger.info(
100
+ f"Payment {payment.internal_payment_id} processed successfully. "
101
+ f"User {payment.user.email} balance: ${balance.amount_usd}"
102
+ )
103
+
104
+ except Exception as e:
105
+ logger.error(f"Error processing completed payment {payment.internal_payment_id}: {e}")
106
+ raise
107
+
108
+
109
+ @receiver(post_save, sender=UniversalPayment)
110
+ def log_payment_webhook_data(sender, instance, created, **kwargs):
111
+ """Log webhook data for audit purposes."""
112
+ if not created and instance.webhook_data:
113
+ logger.info(
114
+ f"Webhook data received for payment {instance.internal_payment_id}: "
115
+ f"status={instance.status}, provider={instance.provider}"
116
+ )
117
+
118
+
119
+ @receiver(post_save, sender=Transaction)
120
+ def log_transaction_creation(sender, instance, created, **kwargs):
121
+ """Log transaction creation for audit trail."""
122
+ if created:
123
+ logger.info(
124
+ f"New transaction: {instance.transaction_type} "
125
+ f"${instance.amount_usd} for user {instance.user.email} "
126
+ f"(balance: ${instance.balance_after})"
127
+ )