django-cfg 1.2.23__py3-none-any.whl → 1.2.25__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.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/knowbase/tasks/archive_tasks.py +6 -6
- django_cfg/apps/knowbase/tasks/document_processing.py +3 -3
- django_cfg/apps/knowbase/tasks/external_data_tasks.py +2 -2
- django_cfg/apps/knowbase/tasks/maintenance.py +3 -3
- django_cfg/apps/payments/config/__init__.py +15 -37
- django_cfg/apps/payments/config/module.py +30 -122
- django_cfg/apps/payments/config/providers.py +22 -0
- django_cfg/apps/payments/config/settings.py +53 -93
- django_cfg/apps/payments/config/utils.py +10 -156
- django_cfg/apps/payments/management/__init__.py +3 -0
- django_cfg/apps/payments/management/commands/README.md +178 -0
- django_cfg/apps/payments/management/commands/__init__.py +3 -0
- django_cfg/apps/payments/management/commands/currency_stats.py +323 -0
- django_cfg/apps/payments/management/commands/populate_currencies.py +246 -0
- django_cfg/apps/payments/management/commands/update_currencies.py +336 -0
- django_cfg/apps/payments/managers/currency_manager.py +65 -14
- django_cfg/apps/payments/middleware/api_access.py +33 -0
- django_cfg/apps/payments/migrations/0001_initial.py +94 -1
- django_cfg/apps/payments/models/payments.py +110 -0
- django_cfg/apps/payments/services/__init__.py +7 -1
- django_cfg/apps/payments/services/core/balance_service.py +14 -16
- django_cfg/apps/payments/services/core/fallback_service.py +432 -0
- django_cfg/apps/payments/services/core/payment_service.py +212 -29
- django_cfg/apps/payments/services/core/subscription_service.py +15 -17
- django_cfg/apps/payments/services/internal_types.py +31 -0
- django_cfg/apps/payments/services/monitoring/__init__.py +22 -0
- django_cfg/apps/payments/services/monitoring/api_schemas.py +222 -0
- django_cfg/apps/payments/services/monitoring/provider_health.py +372 -0
- django_cfg/apps/payments/services/providers/__init__.py +3 -0
- django_cfg/apps/payments/services/providers/cryptapi.py +14 -3
- django_cfg/apps/payments/services/providers/cryptomus.py +310 -0
- django_cfg/apps/payments/services/providers/registry.py +4 -0
- django_cfg/apps/payments/services/security/__init__.py +34 -0
- django_cfg/apps/payments/services/security/error_handler.py +637 -0
- django_cfg/apps/payments/services/security/payment_notifications.py +342 -0
- django_cfg/apps/payments/services/security/webhook_validator.py +475 -0
- django_cfg/apps/payments/signals/api_key_signals.py +10 -0
- django_cfg/apps/payments/signals/payment_signals.py +3 -2
- django_cfg/apps/payments/tasks/__init__.py +12 -0
- django_cfg/apps/payments/tasks/webhook_processing.py +177 -0
- django_cfg/apps/payments/utils/__init__.py +7 -4
- django_cfg/apps/payments/utils/billing_utils.py +342 -0
- django_cfg/apps/payments/utils/config_utils.py +2 -0
- django_cfg/apps/payments/views/payment_views.py +40 -2
- django_cfg/apps/payments/views/webhook_views.py +266 -0
- django_cfg/apps/payments/viewsets.py +65 -0
- django_cfg/cli/README.md +2 -2
- django_cfg/cli/commands/create_project.py +1 -1
- django_cfg/cli/commands/info.py +1 -1
- django_cfg/cli/main.py +1 -1
- django_cfg/cli/utils.py +5 -5
- django_cfg/core/config.py +18 -4
- django_cfg/models/payments.py +546 -0
- django_cfg/models/tasks.py +51 -2
- django_cfg/modules/base.py +11 -5
- django_cfg/modules/django_currency/README.md +104 -269
- django_cfg/modules/django_currency/__init__.py +99 -41
- django_cfg/modules/django_currency/clients/__init__.py +11 -0
- django_cfg/modules/django_currency/clients/coingecko_client.py +257 -0
- django_cfg/modules/django_currency/clients/yfinance_client.py +246 -0
- django_cfg/modules/django_currency/core/__init__.py +42 -0
- django_cfg/modules/django_currency/core/converter.py +169 -0
- django_cfg/modules/django_currency/core/exceptions.py +28 -0
- django_cfg/modules/django_currency/core/models.py +54 -0
- django_cfg/modules/django_currency/database/__init__.py +25 -0
- django_cfg/modules/django_currency/database/database_loader.py +507 -0
- django_cfg/modules/django_currency/utils/__init__.py +9 -0
- django_cfg/modules/django_currency/utils/cache.py +92 -0
- django_cfg/registry/core.py +10 -0
- django_cfg/template_archive/__init__.py +0 -0
- django_cfg/template_archive/django_sample.zip +0 -0
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/METADATA +10 -6
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/RECORD +77 -51
- django_cfg/apps/agents/examples/__init__.py +0 -3
- django_cfg/apps/agents/examples/simple_example.py +0 -161
- django_cfg/apps/knowbase/examples/__init__.py +0 -3
- django_cfg/apps/knowbase/examples/external_data_usage.py +0 -191
- django_cfg/apps/knowbase/mixins/examples/vehicle_model_example.py +0 -199
- django_cfg/modules/django_currency/cache.py +0 -430
- django_cfg/modules/django_currency/converter.py +0 -324
- django_cfg/modules/django_currency/service.py +0 -277
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.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
|
-
|
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
|
27
|
-
|
28
|
-
|
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
|
-
|
50
|
-
|
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):
|