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
@@ -0,0 +1,177 @@
1
+ """
2
+ Webhook Processing Tasks
3
+
4
+ Simple webhook processing with fallback to sync processing.
5
+ Uses existing Dramatiq configuration and graceful degradation.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, Any, Optional
10
+ from django.db import transaction
11
+ from django.utils import timezone
12
+
13
+ # Use existing dramatiq setup
14
+ import dramatiq
15
+
16
+ from ..services.core.payment_service import PaymentService
17
+ from ..models.events import PaymentEvent
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dramatiq.actor(
23
+ queue_name="payments",
24
+ priority=3 # High priority for webhooks
25
+ )
26
+ def process_webhook_async(
27
+ provider: str,
28
+ webhook_data: dict,
29
+ idempotency_key: str,
30
+ request_headers: Optional[dict] = None
31
+ ) -> Dict[str, Any]:
32
+ """
33
+ Process payment webhook asynchronously.
34
+
35
+ Args:
36
+ provider: Payment provider name (nowpayments, cryptapi, etc.)
37
+ webhook_data: Raw webhook payload from provider
38
+ idempotency_key: Unique key to prevent duplicate processing
39
+ request_headers: HTTP headers for webhook validation
40
+
41
+ Returns:
42
+ Processing results with success/error status
43
+
44
+ Raises:
45
+ Exception: If processing fails after retries
46
+ """
47
+ start_time = timezone.now()
48
+
49
+ try:
50
+ # Log task start
51
+ logger.info(f"🚀 Processing webhook async: {provider}, key: {idempotency_key}")
52
+
53
+ # Check for duplicate processing
54
+ if _is_webhook_already_processed(idempotency_key):
55
+ logger.info(f"✅ Webhook already processed: {idempotency_key}")
56
+ return {"success": True, "message": "Already processed", "duplicate": True}
57
+
58
+ # Process webhook
59
+ with transaction.atomic():
60
+ payment_service = PaymentService()
61
+ result = payment_service.process_webhook(
62
+ provider=provider,
63
+ webhook_data=webhook_data,
64
+ request_headers=request_headers
65
+ )
66
+
67
+ # Mark as processed
68
+ _mark_webhook_processed(idempotency_key, result.dict())
69
+
70
+ processing_time = (timezone.now() - start_time).total_seconds()
71
+
72
+ logger.info(
73
+ f"✅ Webhook processed successfully: {idempotency_key}, "
74
+ f"time: {processing_time:.2f}s"
75
+ )
76
+
77
+ return {
78
+ "success": True,
79
+ "idempotency_key": idempotency_key,
80
+ "processing_time_seconds": processing_time,
81
+ "result": result.dict()
82
+ }
83
+
84
+ except Exception as e:
85
+ processing_time = (timezone.now() - start_time).total_seconds()
86
+
87
+ logger.error(
88
+ f"❌ Webhook processing failed: {idempotency_key}, "
89
+ f"error: {str(e)}, time: {processing_time:.2f}s"
90
+ )
91
+
92
+ # Re-raise for Dramatiq retry mechanism
93
+ raise
94
+
95
+
96
+ def process_webhook_with_fallback(
97
+ provider: str,
98
+ webhook_data: dict,
99
+ idempotency_key: str,
100
+ request_headers: Optional[dict] = None,
101
+ force_sync: bool = False
102
+ ):
103
+ """
104
+ Process webhook with automatic async/sync fallback.
105
+
106
+ If Dramatiq is unavailable, processes synchronously.
107
+ If force_sync=True, skips async processing.
108
+ """
109
+ if force_sync:
110
+ logger.info(f"Processing webhook synchronously (forced): {provider}")
111
+ return _process_webhook_sync(provider, webhook_data, idempotency_key, request_headers)
112
+
113
+ try:
114
+ # Try async processing
115
+ process_webhook_async.send(
116
+ provider=provider,
117
+ webhook_data=webhook_data,
118
+ idempotency_key=idempotency_key,
119
+ request_headers=request_headers
120
+ )
121
+ logger.info(f"Webhook queued for async processing: {idempotency_key}")
122
+ return {"success": True, "mode": "async", "queued": True}
123
+
124
+ except Exception as e:
125
+ logger.warning(f"Async processing failed, falling back to sync: {e}")
126
+ return _process_webhook_sync(provider, webhook_data, idempotency_key, request_headers)
127
+
128
+
129
+ def _process_webhook_sync(
130
+ provider: str,
131
+ webhook_data: dict,
132
+ idempotency_key: str,
133
+ request_headers: Optional[dict] = None
134
+ ):
135
+ """Fallback sync webhook processing."""
136
+ logger.info(f"Processing webhook synchronously: {provider}")
137
+
138
+ try:
139
+ payment_service = PaymentService()
140
+ result = payment_service.process_webhook(
141
+ provider=provider,
142
+ webhook_data=webhook_data,
143
+ request_headers=request_headers
144
+ )
145
+
146
+ _mark_webhook_processed(idempotency_key, result.dict())
147
+
148
+ return {
149
+ "success": True,
150
+ "mode": "sync",
151
+ "result": result.dict()
152
+ }
153
+
154
+ except Exception as e:
155
+ logger.error(f"Sync webhook processing failed: {e}")
156
+ raise
157
+
158
+
159
+ def _is_webhook_already_processed(idempotency_key: str) -> bool:
160
+ """Check if webhook was already processed."""
161
+ return PaymentEvent.objects.filter(
162
+ idempotency_key=idempotency_key,
163
+ event_type=PaymentEvent.EventType.WEBHOOK_PROCESSED
164
+ ).exists()
165
+
166
+
167
+ def _mark_webhook_processed(idempotency_key: str, result_data: dict):
168
+ """Mark webhook as processed."""
169
+ import os
170
+
171
+ PaymentEvent.objects.create(
172
+ payment_id=result_data.get('payment_id', 'unknown'),
173
+ event_type=PaymentEvent.EventType.WEBHOOK_PROCESSED,
174
+ event_data=result_data,
175
+ idempotency_key=idempotency_key,
176
+ processed_by=f"worker-{os.getpid()}"
177
+ )
@@ -3,7 +3,7 @@ Utilities for universal payments.
3
3
  """
4
4
 
5
5
  from .middleware_utils import get_client_ip, is_api_request, extract_api_key
6
- # from .billing_utils import calculate_usage_cost, create_billing_transaction # TODO: Implement when needed
6
+ from .billing_utils import calculate_usage_cost, create_billing_transaction, calculate_subscription_refund, process_subscription_billing, get_billing_summary
7
7
  from .validation_utils import validate_api_key, check_subscription_access
8
8
 
9
9
  # Configuration utilities
@@ -23,9 +23,12 @@ __all__ = [
23
23
  'is_api_request',
24
24
  'extract_api_key',
25
25
 
26
- # Billing utilities (TODO: Implement when needed)
27
- # 'calculate_usage_cost',
28
- # 'create_billing_transaction',
26
+ # Billing utilities
27
+ 'calculate_usage_cost',
28
+ 'create_billing_transaction',
29
+ 'calculate_subscription_refund',
30
+ 'process_subscription_billing',
31
+ 'get_billing_summary',
29
32
 
30
33
  # Validation utilities
31
34
  'validate_api_key',
@@ -0,0 +1,342 @@
1
+ """
2
+ Basic billing utilities for production use.
3
+
4
+ Provides essential billing calculations and transaction management
5
+ without over-engineering.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, Any, Optional, Tuple
10
+ from decimal import Decimal, ROUND_HALF_UP
11
+ from datetime import datetime, timedelta
12
+ from django.utils import timezone
13
+ from django.db import transaction
14
+ from django.contrib.auth import get_user_model
15
+
16
+ from ..models import UserBalance, Transaction, Subscription
17
+
18
+ User = get_user_model()
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def calculate_usage_cost(
23
+ subscription: Subscription,
24
+ usage_count: int,
25
+ billing_period: str = 'monthly'
26
+ ) -> Decimal:
27
+ """
28
+ Calculate cost for API usage.
29
+
30
+ Args:
31
+ subscription: User subscription
32
+ usage_count: Number of API calls
33
+ billing_period: Billing period (monthly/yearly)
34
+
35
+ Returns:
36
+ Cost in USD
37
+ """
38
+ try:
39
+ endpoint_group = subscription.endpoint_group
40
+
41
+ # Get base price
42
+ if billing_period == 'monthly':
43
+ base_price = endpoint_group.monthly_price_usd
44
+ limit = endpoint_group.monthly_request_limit
45
+ else:
46
+ base_price = endpoint_group.yearly_price_usd
47
+ limit = endpoint_group.yearly_request_limit or (endpoint_group.monthly_request_limit * 12)
48
+
49
+ # If usage is within limit, cost is covered by subscription
50
+ if usage_count <= limit:
51
+ return Decimal('0.00')
52
+
53
+ # Calculate overage cost
54
+ overage = usage_count - limit
55
+ overage_rate = getattr(endpoint_group, 'overage_rate_per_request', Decimal('0.01'))
56
+
57
+ overage_cost = Decimal(overage) * overage_rate
58
+
59
+ # Round to 2 decimal places
60
+ return overage_cost.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
61
+
62
+ except Exception as e:
63
+ logger.error(f"Error calculating usage cost: {e}")
64
+ return Decimal('0.00')
65
+
66
+
67
+ def create_billing_transaction(
68
+ user: User,
69
+ amount: Decimal,
70
+ transaction_type: str,
71
+ source: str = 'billing',
72
+ description: Optional[str] = None,
73
+ reference_id: Optional[str] = None,
74
+ metadata: Optional[Dict[str, Any]] = None
75
+ ) -> Tuple[bool, Optional[Transaction]]:
76
+ """
77
+ Create a billing transaction with balance update.
78
+
79
+ Args:
80
+ user: User object
81
+ amount: Transaction amount (positive for credit, negative for debit)
82
+ transaction_type: Type of transaction
83
+ source: Source of transaction
84
+ description: Human-readable description
85
+ reference_id: External reference ID
86
+ metadata: Additional metadata
87
+
88
+ Returns:
89
+ Tuple of (success, transaction)
90
+ """
91
+ try:
92
+ with transaction.atomic():
93
+ # Get or create user balance
94
+ balance, created = UserBalance.objects.get_or_create(
95
+ user=user,
96
+ currency_id=1, # Assuming USD currency has ID 1
97
+ defaults={
98
+ 'available_amount': Decimal('0.00'),
99
+ 'held_amount': Decimal('0.00')
100
+ }
101
+ )
102
+
103
+ # Check if debit is possible
104
+ if amount < 0 and not balance.can_debit(abs(amount)):
105
+ logger.warning(f"Insufficient balance for user {user.id}: {balance.available_amount} < {abs(amount)}")
106
+ return False, None
107
+
108
+ # Calculate new balance
109
+ old_balance = balance.available_amount
110
+ new_balance = old_balance + amount
111
+
112
+ # Update balance
113
+ balance.available_amount = new_balance
114
+
115
+ # Update totals
116
+ if amount > 0:
117
+ balance.total_earned += amount
118
+ else:
119
+ balance.total_spent += abs(amount)
120
+
121
+ balance.save()
122
+
123
+ # Create transaction record
124
+ txn = Transaction.objects.create(
125
+ user=user,
126
+ balance=balance,
127
+ transaction_type=transaction_type,
128
+ amount=amount,
129
+ balance_before=old_balance,
130
+ balance_after=new_balance,
131
+ source=source,
132
+ description=description or f"{transaction_type} transaction",
133
+ reference_id=reference_id,
134
+ metadata=metadata or {}
135
+ )
136
+
137
+ logger.info(f"Created billing transaction: {txn.id} for user {user.id}, amount: {amount}")
138
+ return True, txn
139
+
140
+ except Exception as e:
141
+ logger.error(f"Error creating billing transaction for user {user.id}: {e}")
142
+ return False, None
143
+
144
+
145
+ def calculate_subscription_refund(
146
+ subscription: Subscription,
147
+ refund_strategy: str = 'prorated',
148
+ cancellation_date: Optional[datetime] = None
149
+ ) -> Dict[str, Any]:
150
+ """
151
+ Calculate refund amount for cancelled subscription.
152
+
153
+ Args:
154
+ subscription: Subscription to refund
155
+ refund_strategy: 'prorated', 'full', or 'none'
156
+ cancellation_date: Date of cancellation (defaults to now)
157
+
158
+ Returns:
159
+ Dict with refund calculation details
160
+ """
161
+ try:
162
+ if not cancellation_date:
163
+ cancellation_date = timezone.now()
164
+
165
+ # Get subscription details
166
+ start_date = subscription.starts_at
167
+ end_date = subscription.expires_at
168
+
169
+ if subscription.billing_period == 'monthly':
170
+ original_amount = subscription.endpoint_group.monthly_price_usd
171
+ else:
172
+ original_amount = subscription.endpoint_group.yearly_price_usd
173
+
174
+ # Calculate refund based on strategy
175
+ if refund_strategy == 'none':
176
+ refund_amount = Decimal('0.00')
177
+ refund_reason = "No refund policy"
178
+
179
+ elif refund_strategy == 'full':
180
+ refund_amount = original_amount
181
+ refund_reason = "Full refund"
182
+
183
+ elif refund_strategy == 'prorated':
184
+ # Calculate prorated refund
185
+ total_days = (end_date - start_date).days
186
+ used_days = (cancellation_date - start_date).days
187
+ remaining_days = max(0, total_days - used_days)
188
+
189
+ if total_days > 0:
190
+ refund_percentage = Decimal(remaining_days) / Decimal(total_days)
191
+ refund_amount = (original_amount * refund_percentage).quantize(
192
+ Decimal('0.01'), rounding=ROUND_HALF_UP
193
+ )
194
+ else:
195
+ refund_amount = Decimal('0.00')
196
+
197
+ refund_reason = f"Prorated refund: {remaining_days}/{total_days} days remaining"
198
+
199
+ else:
200
+ refund_amount = Decimal('0.00')
201
+ refund_reason = "Unknown refund strategy"
202
+
203
+ return {
204
+ 'refund_amount': refund_amount,
205
+ 'original_amount': original_amount,
206
+ 'refund_strategy': refund_strategy,
207
+ 'refund_reason': refund_reason,
208
+ 'calculation_date': cancellation_date.isoformat(),
209
+ 'subscription_id': str(subscription.id),
210
+ 'billing_period': subscription.billing_period
211
+ }
212
+
213
+ except Exception as e:
214
+ logger.error(f"Error calculating refund for subscription {subscription.id}: {e}")
215
+ return {
216
+ 'refund_amount': Decimal('0.00'),
217
+ 'original_amount': Decimal('0.00'),
218
+ 'refund_strategy': refund_strategy,
219
+ 'refund_reason': f"Calculation error: {str(e)}",
220
+ 'error': True
221
+ }
222
+
223
+
224
+ def process_subscription_billing(subscription: Subscription) -> Dict[str, Any]:
225
+ """
226
+ Process billing for subscription renewal.
227
+
228
+ Args:
229
+ subscription: Subscription to bill
230
+
231
+ Returns:
232
+ Dict with billing results
233
+ """
234
+ try:
235
+ # Calculate billing amount
236
+ if subscription.billing_period == 'monthly':
237
+ amount = subscription.endpoint_group.monthly_price_usd
238
+ billing_period_days = 30
239
+ else:
240
+ amount = subscription.endpoint_group.yearly_price_usd
241
+ billing_period_days = 365
242
+
243
+ # Create billing transaction
244
+ success, txn = create_billing_transaction(
245
+ user=subscription.user,
246
+ amount=-amount, # Negative for debit
247
+ transaction_type='subscription_billing',
248
+ source='subscription_renewal',
249
+ description=f"Subscription renewal: {subscription.endpoint_group.display_name}",
250
+ reference_id=str(subscription.id),
251
+ metadata={
252
+ 'subscription_id': str(subscription.id),
253
+ 'billing_period': subscription.billing_period,
254
+ 'endpoint_group': subscription.endpoint_group.name
255
+ }
256
+ )
257
+
258
+ if success:
259
+ # Update subscription
260
+ subscription.next_billing_at = timezone.now() + timedelta(days=billing_period_days)
261
+ subscription.current_usage = 0 # Reset usage
262
+ subscription.save()
263
+
264
+ logger.info(f"Successfully billed subscription {subscription.id} for ${amount}")
265
+
266
+ return {
267
+ 'success': True,
268
+ 'amount_billed': amount,
269
+ 'transaction_id': str(txn.id),
270
+ 'next_billing_at': subscription.next_billing_at.isoformat()
271
+ }
272
+ else:
273
+ logger.warning(f"Failed to bill subscription {subscription.id}: insufficient balance")
274
+
275
+ return {
276
+ 'success': False,
277
+ 'error': 'Insufficient balance',
278
+ 'amount_required': amount,
279
+ 'user_balance': UserBalance.objects.get(user=subscription.user).available_amount
280
+ }
281
+
282
+ except Exception as e:
283
+ logger.error(f"Error processing subscription billing {subscription.id}: {e}")
284
+ return {
285
+ 'success': False,
286
+ 'error': str(e)
287
+ }
288
+
289
+
290
+ def get_billing_summary(user: User, days: int = 30) -> Dict[str, Any]:
291
+ """
292
+ Get billing summary for user over specified period.
293
+
294
+ Args:
295
+ user: User object
296
+ days: Number of days to include
297
+
298
+ Returns:
299
+ Dict with billing summary
300
+ """
301
+ try:
302
+ cutoff_date = timezone.now() - timedelta(days=days)
303
+
304
+ # Get transactions
305
+ transactions = Transaction.objects.filter(
306
+ user=user,
307
+ created_at__gte=cutoff_date
308
+ )
309
+
310
+ # Calculate totals
311
+ from django.db import models
312
+
313
+ total_credits = transactions.filter(amount__gt=0).aggregate(
314
+ total=models.Sum('amount')
315
+ )['total'] or Decimal('0.00')
316
+
317
+ total_debits = transactions.filter(amount__lt=0).aggregate(
318
+ total=models.Sum('amount')
319
+ )['total'] or Decimal('0.00')
320
+
321
+ # Get current balance
322
+ try:
323
+ balance = UserBalance.objects.get(user=user)
324
+ current_balance = balance.available_amount
325
+ except UserBalance.DoesNotExist:
326
+ current_balance = Decimal('0.00')
327
+
328
+ return {
329
+ 'period_days': days,
330
+ 'total_credits': total_credits,
331
+ 'total_debits': abs(total_debits),
332
+ 'net_change': total_credits + total_debits, # total_debits is negative
333
+ 'current_balance': current_balance,
334
+ 'transaction_count': transactions.count()
335
+ }
336
+
337
+ except Exception as e:
338
+ logger.error(f"Error getting billing summary for user {user.id}: {e}")
339
+ return {
340
+ 'error': str(e),
341
+ 'period_days': days
342
+ }
@@ -199,6 +199,8 @@ class ProviderConfigHelper(PaymentsConfigMixin):
199
199
  return True # api_key is sufficient
200
200
  elif provider_name == 'cryptapi':
201
201
  return hasattr(provider_config, 'own_address') and provider_config.own_address
202
+ elif provider_name == 'cryptomus':
203
+ return hasattr(provider_config, 'merchant_uuid') and provider_config.merchant_uuid
202
204
 
203
205
  return True
204
206
 
@@ -46,8 +46,46 @@ class UserPaymentViewSet(viewsets.ModelViewSet):
46
46
  def check_status(self, request, user_pk=None, pk=None):
47
47
  """Check payment status via provider API."""
48
48
  payment = self.get_object()
49
- # TODO: Implement provider status check
50
- return Response({'status': payment.status})
49
+
50
+ # Import PaymentService to check status with provider
51
+ from ..services.core.payment_service import PaymentService
52
+
53
+ try:
54
+ payment_service = PaymentService()
55
+ status_result = payment_service.get_payment_status(str(payment.id))
56
+
57
+ if status_result.success:
58
+ # Update local payment status if it changed
59
+ if payment.status != status_result.status:
60
+ payment.status = status_result.status
61
+ payment.save(update_fields=['status', 'updated_at'])
62
+
63
+ return Response({
64
+ 'payment_id': str(payment.id),
65
+ 'status': status_result.status,
66
+ 'provider_status': status_result.provider_status,
67
+ 'updated': payment.status != status_result.status
68
+ })
69
+ else:
70
+ return Response({
71
+ 'payment_id': str(payment.id),
72
+ 'status': payment.status,
73
+ 'error': status_result.error_message,
74
+ 'provider_check_failed': True
75
+ }, status=status.HTTP_400_BAD_REQUEST)
76
+
77
+ except Exception as e:
78
+ # Log error but don't fail completely
79
+ import logging
80
+ logger = logging.getLogger(__name__)
81
+ logger.error(f"Payment status check failed for {payment.id}: {e}")
82
+
83
+ return Response({
84
+ 'payment_id': str(payment.id),
85
+ 'status': payment.status,
86
+ 'error': 'Status check temporarily unavailable',
87
+ 'provider_check_failed': True
88
+ })
51
89
 
52
90
  @action(detail=False, methods=['get'])
53
91
  def summary(self, request, user_pk=None):