django-cfg 1.2.21__py3-none-any.whl → 1.2.22__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/newsletter/signals.py +9 -8
- django_cfg/apps/payments/__init__.py +8 -0
- django_cfg/apps/payments/apps.py +22 -0
- django_cfg/apps/payments/managers/__init__.py +22 -0
- django_cfg/apps/payments/managers/api_key_manager.py +35 -0
- django_cfg/apps/payments/managers/balance_manager.py +361 -0
- django_cfg/apps/payments/managers/currency_manager.py +32 -0
- django_cfg/apps/payments/managers/payment_manager.py +44 -0
- django_cfg/apps/payments/managers/subscription_manager.py +37 -0
- django_cfg/apps/payments/managers/tariff_manager.py +29 -0
- django_cfg/apps/payments/middleware/__init__.py +13 -0
- django_cfg/apps/payments/migrations/0001_initial.py +982 -0
- django_cfg/apps/payments/migrations/__init__.py +1 -0
- django_cfg/apps/payments/models/__init__.py +49 -0
- django_cfg/apps/payments/models/api_keys.py +96 -0
- django_cfg/apps/payments/models/balance.py +209 -0
- django_cfg/apps/payments/models/base.py +14 -0
- django_cfg/apps/payments/models/currencies.py +138 -0
- django_cfg/apps/payments/models/events.py +73 -0
- django_cfg/apps/payments/models/payments.py +301 -0
- django_cfg/apps/payments/models/subscriptions.py +270 -0
- django_cfg/apps/payments/models/tariffs.py +102 -0
- django_cfg/apps/payments/serializers/__init__.py +56 -0
- django_cfg/apps/payments/serializers/api_keys.py +51 -0
- django_cfg/apps/payments/serializers/balance.py +59 -0
- django_cfg/apps/payments/serializers/currencies.py +55 -0
- django_cfg/apps/payments/serializers/payments.py +62 -0
- django_cfg/apps/payments/serializers/subscriptions.py +71 -0
- django_cfg/apps/payments/serializers/tariffs.py +56 -0
- django_cfg/apps/payments/services/__init__.py +14 -0
- django_cfg/apps/payments/services/base.py +68 -0
- django_cfg/apps/payments/services/nowpayments.py +78 -0
- django_cfg/apps/payments/services/providers.py +77 -0
- django_cfg/apps/payments/services/redis_service.py +215 -0
- django_cfg/apps/payments/urls.py +78 -0
- django_cfg/apps/payments/views/__init__.py +62 -0
- django_cfg/apps/payments/views/api_key_views.py +164 -0
- django_cfg/apps/payments/views/balance_views.py +75 -0
- django_cfg/apps/payments/views/currency_views.py +111 -0
- django_cfg/apps/payments/views/payment_views.py +111 -0
- django_cfg/apps/payments/views/subscription_views.py +135 -0
- django_cfg/apps/payments/views/tariff_views.py +131 -0
- django_cfg/core/config.py +6 -0
- django_cfg/models/revolution.py +14 -0
- django_cfg/modules/base.py +9 -0
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/METADATA +1 -1
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/RECORD +51 -10
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,301 @@
|
|
1
|
+
"""
|
2
|
+
Payment models for the universal payments system.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from django.db import models
|
6
|
+
from django.contrib.auth import get_user_model
|
7
|
+
from django.core.validators import MinValueValidator
|
8
|
+
from django.core.exceptions import ValidationError
|
9
|
+
from django.utils import timezone
|
10
|
+
from .base import TimestampedModel
|
11
|
+
|
12
|
+
User = get_user_model()
|
13
|
+
|
14
|
+
|
15
|
+
|
16
|
+
class UniversalPayment(TimestampedModel):
|
17
|
+
"""Universal payment model for all providers."""
|
18
|
+
|
19
|
+
class PaymentStatus(models.TextChoices):
|
20
|
+
PENDING = "pending", "Pending"
|
21
|
+
CONFIRMING = "confirming", "Confirming"
|
22
|
+
CONFIRMED = "confirmed", "Confirmed"
|
23
|
+
COMPLETED = "completed", "Completed"
|
24
|
+
FAILED = "failed", "Failed"
|
25
|
+
EXPIRED = "expired", "Expired"
|
26
|
+
CANCELLED = "cancelled", "Cancelled"
|
27
|
+
REFUNDED = "refunded", "Refunded"
|
28
|
+
|
29
|
+
class PaymentProvider(models.TextChoices):
|
30
|
+
NOWPAYMENTS = "nowpayments", "NowPayments"
|
31
|
+
STRIPE = "stripe", "Stripe"
|
32
|
+
INTERNAL = "internal", "Internal"
|
33
|
+
|
34
|
+
user = models.ForeignKey(
|
35
|
+
User,
|
36
|
+
on_delete=models.CASCADE,
|
37
|
+
related_name='universal_payments',
|
38
|
+
help_text="User who initiated this payment"
|
39
|
+
)
|
40
|
+
|
41
|
+
# Financial data
|
42
|
+
amount_usd = models.FloatField(
|
43
|
+
validators=[MinValueValidator(1.0)],
|
44
|
+
help_text="Payment amount in USD"
|
45
|
+
)
|
46
|
+
currency_code = models.CharField(
|
47
|
+
max_length=10,
|
48
|
+
help_text="Currency used for payment"
|
49
|
+
)
|
50
|
+
|
51
|
+
# Actual received amount (may differ from requested)
|
52
|
+
actual_amount_usd = models.FloatField(
|
53
|
+
null=True,
|
54
|
+
blank=True,
|
55
|
+
help_text="Actual received amount in USD"
|
56
|
+
)
|
57
|
+
actual_currency_code = models.CharField(
|
58
|
+
max_length=10,
|
59
|
+
null=True,
|
60
|
+
blank=True,
|
61
|
+
help_text="Actual received currency"
|
62
|
+
)
|
63
|
+
|
64
|
+
# Fee information
|
65
|
+
fee_amount_usd = models.FloatField(
|
66
|
+
null=True,
|
67
|
+
blank=True,
|
68
|
+
validators=[MinValueValidator(0.0)],
|
69
|
+
help_text="Fee amount in USD"
|
70
|
+
)
|
71
|
+
|
72
|
+
# Payment details
|
73
|
+
provider = models.CharField(
|
74
|
+
max_length=50,
|
75
|
+
choices=PaymentProvider.choices,
|
76
|
+
help_text="Payment provider"
|
77
|
+
)
|
78
|
+
status = models.CharField(
|
79
|
+
max_length=20,
|
80
|
+
choices=PaymentStatus.choices,
|
81
|
+
default=PaymentStatus.PENDING,
|
82
|
+
help_text="Payment status"
|
83
|
+
)
|
84
|
+
|
85
|
+
# Provider-specific fields
|
86
|
+
provider_payment_id = models.CharField(
|
87
|
+
max_length=255,
|
88
|
+
null=True,
|
89
|
+
blank=True,
|
90
|
+
unique=True,
|
91
|
+
help_text="Provider's payment ID"
|
92
|
+
)
|
93
|
+
internal_payment_id = models.CharField(
|
94
|
+
max_length=100,
|
95
|
+
unique=True,
|
96
|
+
help_text="Internal payment identifier"
|
97
|
+
)
|
98
|
+
|
99
|
+
# Crypto payment specific
|
100
|
+
pay_address = models.CharField(
|
101
|
+
max_length=200,
|
102
|
+
null=True,
|
103
|
+
blank=True,
|
104
|
+
help_text="Cryptocurrency payment address"
|
105
|
+
)
|
106
|
+
pay_amount = models.FloatField(
|
107
|
+
null=True,
|
108
|
+
blank=True,
|
109
|
+
help_text="Amount to pay in cryptocurrency"
|
110
|
+
)
|
111
|
+
network = models.CharField(
|
112
|
+
max_length=50,
|
113
|
+
null=True,
|
114
|
+
blank=True,
|
115
|
+
help_text="Blockchain network (mainnet, testnet, etc.)"
|
116
|
+
)
|
117
|
+
|
118
|
+
# Metadata
|
119
|
+
description = models.TextField(
|
120
|
+
blank=True,
|
121
|
+
help_text="Payment description"
|
122
|
+
)
|
123
|
+
order_id = models.CharField(
|
124
|
+
max_length=255,
|
125
|
+
null=True,
|
126
|
+
blank=True,
|
127
|
+
help_text="Order reference ID"
|
128
|
+
)
|
129
|
+
metadata = models.JSONField(
|
130
|
+
default=dict,
|
131
|
+
help_text="Additional metadata"
|
132
|
+
)
|
133
|
+
|
134
|
+
# Provider webhook data
|
135
|
+
webhook_data = models.JSONField(
|
136
|
+
null=True,
|
137
|
+
blank=True,
|
138
|
+
help_text="Raw webhook data from provider"
|
139
|
+
)
|
140
|
+
|
141
|
+
# Timestamps
|
142
|
+
expires_at = models.DateTimeField(
|
143
|
+
null=True,
|
144
|
+
blank=True,
|
145
|
+
help_text="Payment expiration time"
|
146
|
+
)
|
147
|
+
completed_at = models.DateTimeField(
|
148
|
+
null=True,
|
149
|
+
blank=True,
|
150
|
+
help_text="Payment completion time"
|
151
|
+
)
|
152
|
+
processed_at = models.DateTimeField(
|
153
|
+
null=True,
|
154
|
+
blank=True,
|
155
|
+
help_text="When the payment was processed and funds added to balance"
|
156
|
+
)
|
157
|
+
|
158
|
+
# Import and assign manager
|
159
|
+
from ..managers import UniversalPaymentManager
|
160
|
+
objects = UniversalPaymentManager()
|
161
|
+
|
162
|
+
class Meta:
|
163
|
+
db_table = 'universal_payments'
|
164
|
+
verbose_name = "Universal Payment"
|
165
|
+
verbose_name_plural = "Universal Payments"
|
166
|
+
indexes = [
|
167
|
+
models.Index(fields=['user', 'status']),
|
168
|
+
models.Index(fields=['provider_payment_id']),
|
169
|
+
models.Index(fields=['internal_payment_id']),
|
170
|
+
models.Index(fields=['status']),
|
171
|
+
models.Index(fields=['provider']),
|
172
|
+
models.Index(fields=['currency_code']),
|
173
|
+
models.Index(fields=['created_at']),
|
174
|
+
models.Index(fields=['processed_at']),
|
175
|
+
]
|
176
|
+
ordering = ['-created_at']
|
177
|
+
|
178
|
+
def __str__(self):
|
179
|
+
return f"{self.user.email} - ${self.amount_usd} ({self.currency_code}) - {self.get_status_display()}"
|
180
|
+
|
181
|
+
@property
|
182
|
+
def is_pending(self) -> bool:
|
183
|
+
"""Check if payment is still pending."""
|
184
|
+
return self.status in [
|
185
|
+
self.PaymentStatus.PENDING,
|
186
|
+
self.PaymentStatus.CONFIRMING,
|
187
|
+
self.PaymentStatus.CONFIRMED
|
188
|
+
]
|
189
|
+
|
190
|
+
@property
|
191
|
+
def is_completed(self) -> bool:
|
192
|
+
"""Check if payment is completed."""
|
193
|
+
return self.status == self.PaymentStatus.COMPLETED
|
194
|
+
|
195
|
+
@property
|
196
|
+
def is_failed(self) -> bool:
|
197
|
+
"""Check if payment failed."""
|
198
|
+
return self.status in [self.PaymentStatus.FAILED, self.PaymentStatus.EXPIRED]
|
199
|
+
|
200
|
+
@property
|
201
|
+
def needs_processing(self) -> bool:
|
202
|
+
"""Check if payment needs to be processed (completed but not processed)."""
|
203
|
+
return self.is_completed and not self.processed_at
|
204
|
+
|
205
|
+
@property
|
206
|
+
def is_crypto_payment(self) -> bool:
|
207
|
+
"""Check if this is a cryptocurrency payment."""
|
208
|
+
return self.provider == self.PaymentProvider.NOWPAYMENTS
|
209
|
+
|
210
|
+
def get_payment_url(self) -> str:
|
211
|
+
"""Get payment URL for QR code or direct payment."""
|
212
|
+
if self.pay_address and self.pay_amount:
|
213
|
+
return f"{self.currency_code.lower()}:{self.pay_address}?amount={self.pay_amount}"
|
214
|
+
return ""
|
215
|
+
|
216
|
+
def get_qr_code_url(self, size: int = 200) -> str:
|
217
|
+
"""Get QR code URL for payment."""
|
218
|
+
payment_url = self.get_payment_url()
|
219
|
+
if payment_url:
|
220
|
+
return f"https://api.qrserver.com/v1/create-qr-code/?size={size}x{size}&data={payment_url}"
|
221
|
+
return ""
|
222
|
+
|
223
|
+
def mark_as_processed(self):
|
224
|
+
"""Mark payment as processed."""
|
225
|
+
if not self.processed_at:
|
226
|
+
self.processed_at = timezone.now()
|
227
|
+
self.save(update_fields=['processed_at'])
|
228
|
+
|
229
|
+
def update_from_webhook(self, webhook_data: dict):
|
230
|
+
"""Update payment from provider webhook data."""
|
231
|
+
self.webhook_data = webhook_data
|
232
|
+
|
233
|
+
# Update status if provided
|
234
|
+
if 'payment_status' in webhook_data:
|
235
|
+
self.status = webhook_data['payment_status']
|
236
|
+
|
237
|
+
# Update payment details if provided
|
238
|
+
if 'pay_address' in webhook_data:
|
239
|
+
self.pay_address = webhook_data['pay_address']
|
240
|
+
|
241
|
+
if 'pay_amount' in webhook_data:
|
242
|
+
self.pay_amount = float(str(webhook_data['pay_amount']))
|
243
|
+
|
244
|
+
if 'payment_id' in webhook_data:
|
245
|
+
self.provider_payment_id = webhook_data['payment_id']
|
246
|
+
|
247
|
+
self.save()
|
248
|
+
|
249
|
+
def can_be_refunded(self) -> bool:
|
250
|
+
"""Check if payment can be refunded."""
|
251
|
+
return self.is_completed and self.processed_at
|
252
|
+
|
253
|
+
def get_currency_display_name(self) -> str:
|
254
|
+
"""Get human-readable currency name."""
|
255
|
+
# This could be enhanced to lookup from Currency model
|
256
|
+
currency_names = {
|
257
|
+
'BTC': 'Bitcoin',
|
258
|
+
'ETH': 'Ethereum',
|
259
|
+
'USD': 'US Dollar',
|
260
|
+
'EUR': 'Euro',
|
261
|
+
}
|
262
|
+
return currency_names.get(self.currency_code, self.currency_code)
|
263
|
+
|
264
|
+
def get_status_color(self) -> str:
|
265
|
+
"""Get color for status display."""
|
266
|
+
status_colors = {
|
267
|
+
self.PaymentStatus.PENDING: '#6c757d',
|
268
|
+
self.PaymentStatus.CONFIRMING: '#fd7e14',
|
269
|
+
self.PaymentStatus.CONFIRMED: '#20c997',
|
270
|
+
self.PaymentStatus.COMPLETED: '#198754',
|
271
|
+
self.PaymentStatus.FAILED: '#dc3545',
|
272
|
+
self.PaymentStatus.REFUNDED: '#6f42c1',
|
273
|
+
self.PaymentStatus.EXPIRED: '#dc3545',
|
274
|
+
self.PaymentStatus.CANCELLED: '#6c757d'
|
275
|
+
}
|
276
|
+
return status_colors.get(self.status, '#6c757d')
|
277
|
+
|
278
|
+
def clean(self):
|
279
|
+
"""Validate payment data."""
|
280
|
+
|
281
|
+
# Validate minimum amount
|
282
|
+
if self.amount_usd < 1.0:
|
283
|
+
raise ValidationError("Minimum payment amount is $1.00")
|
284
|
+
|
285
|
+
# Validate crypto address for crypto payments
|
286
|
+
if self.is_crypto_payment and self.status != self.PaymentStatus.PENDING:
|
287
|
+
if not self.pay_address:
|
288
|
+
raise ValidationError("Payment address is required for crypto payments")
|
289
|
+
|
290
|
+
def save(self, *args, **kwargs):
|
291
|
+
"""Override save to run validation."""
|
292
|
+
if self.currency_code:
|
293
|
+
self.currency_code = self.currency_code.upper()
|
294
|
+
|
295
|
+
# Generate internal payment ID if not set
|
296
|
+
if not self.internal_payment_id:
|
297
|
+
import uuid
|
298
|
+
self.internal_payment_id = f"pay_{str(uuid.uuid4())[:8]}"
|
299
|
+
|
300
|
+
self.clean()
|
301
|
+
super().save(*args, **kwargs)
|
@@ -0,0 +1,270 @@
|
|
1
|
+
"""
|
2
|
+
Subscription models for the universal payments system.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from django.db import models
|
6
|
+
from django.contrib.auth import get_user_model
|
7
|
+
from django.core.validators import MinValueValidator
|
8
|
+
from django.utils import timezone
|
9
|
+
from datetime import timedelta
|
10
|
+
from .base import TimestampedModel
|
11
|
+
|
12
|
+
User = get_user_model()
|
13
|
+
|
14
|
+
|
15
|
+
class EndpointGroup(TimestampedModel):
|
16
|
+
"""API endpoint groups for subscription management."""
|
17
|
+
|
18
|
+
name = models.CharField(
|
19
|
+
max_length=100,
|
20
|
+
unique=True,
|
21
|
+
help_text="Endpoint group name"
|
22
|
+
)
|
23
|
+
display_name = models.CharField(
|
24
|
+
max_length=200,
|
25
|
+
help_text="Human-readable name"
|
26
|
+
)
|
27
|
+
description = models.TextField(
|
28
|
+
blank=True,
|
29
|
+
help_text="Group description"
|
30
|
+
)
|
31
|
+
|
32
|
+
# Pricing tiers
|
33
|
+
basic_price = models.FloatField(
|
34
|
+
default=0.0,
|
35
|
+
validators=[MinValueValidator(0.0)],
|
36
|
+
help_text="Basic tier monthly price"
|
37
|
+
)
|
38
|
+
premium_price = models.FloatField(
|
39
|
+
default=0.0,
|
40
|
+
validators=[MinValueValidator(0.0)],
|
41
|
+
help_text="Premium tier monthly price"
|
42
|
+
)
|
43
|
+
enterprise_price = models.FloatField(
|
44
|
+
default=0.0,
|
45
|
+
validators=[MinValueValidator(0.0)],
|
46
|
+
help_text="Enterprise tier monthly price"
|
47
|
+
)
|
48
|
+
|
49
|
+
# Usage limits per tier
|
50
|
+
basic_limit = models.PositiveIntegerField(
|
51
|
+
default=1000,
|
52
|
+
help_text="Basic tier monthly usage limit"
|
53
|
+
)
|
54
|
+
premium_limit = models.PositiveIntegerField(
|
55
|
+
default=10000,
|
56
|
+
help_text="Premium tier monthly usage limit"
|
57
|
+
)
|
58
|
+
enterprise_limit = models.PositiveIntegerField(
|
59
|
+
default=0, # 0 = unlimited
|
60
|
+
help_text="Enterprise tier monthly usage limit (0 = unlimited)"
|
61
|
+
)
|
62
|
+
|
63
|
+
# Settings
|
64
|
+
is_active = models.BooleanField(
|
65
|
+
default=True,
|
66
|
+
help_text="Is this endpoint group active"
|
67
|
+
)
|
68
|
+
require_api_key = models.BooleanField(
|
69
|
+
default=True,
|
70
|
+
help_text="Require API key for access"
|
71
|
+
)
|
72
|
+
|
73
|
+
# Import and assign manager
|
74
|
+
from ..managers import EndpointGroupManager
|
75
|
+
objects = EndpointGroupManager()
|
76
|
+
|
77
|
+
class Meta:
|
78
|
+
db_table = 'endpoint_groups'
|
79
|
+
verbose_name = "Endpoint Group"
|
80
|
+
verbose_name_plural = "Endpoint Groups"
|
81
|
+
indexes = [
|
82
|
+
models.Index(fields=['name']),
|
83
|
+
models.Index(fields=['is_active']),
|
84
|
+
]
|
85
|
+
ordering = ['name']
|
86
|
+
|
87
|
+
def __str__(self):
|
88
|
+
return self.display_name
|
89
|
+
|
90
|
+
def get_price_for_tier(self, tier: str) -> float:
|
91
|
+
"""Get price for specific tier."""
|
92
|
+
tier_prices = {
|
93
|
+
'basic': self.basic_price,
|
94
|
+
'premium': self.premium_price,
|
95
|
+
'enterprise': self.enterprise_price,
|
96
|
+
}
|
97
|
+
return tier_prices.get(tier, 0.0)
|
98
|
+
|
99
|
+
def get_limit_for_tier(self, tier: str) -> int:
|
100
|
+
"""Get usage limit for specific tier."""
|
101
|
+
tier_limits = {
|
102
|
+
'basic': self.basic_limit,
|
103
|
+
'premium': self.premium_limit,
|
104
|
+
'enterprise': self.enterprise_limit,
|
105
|
+
}
|
106
|
+
return tier_limits.get(tier, 0)
|
107
|
+
|
108
|
+
|
109
|
+
class Subscription(TimestampedModel):
|
110
|
+
"""User subscriptions to endpoint groups."""
|
111
|
+
|
112
|
+
class SubscriptionStatus(models.TextChoices):
|
113
|
+
ACTIVE = "active", "Active"
|
114
|
+
INACTIVE = "inactive", "Inactive"
|
115
|
+
EXPIRED = "expired", "Expired"
|
116
|
+
CANCELLED = "cancelled", "Cancelled"
|
117
|
+
SUSPENDED = "suspended", "Suspended"
|
118
|
+
|
119
|
+
class SubscriptionTier(models.TextChoices):
|
120
|
+
BASIC = "basic", "Basic"
|
121
|
+
PREMIUM = "premium", "Premium"
|
122
|
+
ENTERPRISE = "enterprise", "Enterprise"
|
123
|
+
|
124
|
+
user = models.ForeignKey(
|
125
|
+
User,
|
126
|
+
on_delete=models.CASCADE,
|
127
|
+
related_name='subscriptions',
|
128
|
+
help_text="Subscriber"
|
129
|
+
)
|
130
|
+
endpoint_group = models.ForeignKey(
|
131
|
+
EndpointGroup,
|
132
|
+
on_delete=models.CASCADE,
|
133
|
+
related_name='subscriptions',
|
134
|
+
help_text="Endpoint group"
|
135
|
+
)
|
136
|
+
|
137
|
+
# Subscription details
|
138
|
+
tier = models.CharField(
|
139
|
+
max_length=20,
|
140
|
+
choices=SubscriptionTier.choices,
|
141
|
+
default=SubscriptionTier.BASIC,
|
142
|
+
help_text="Subscription tier"
|
143
|
+
)
|
144
|
+
status = models.CharField(
|
145
|
+
max_length=20,
|
146
|
+
choices=SubscriptionStatus.choices,
|
147
|
+
default=SubscriptionStatus.ACTIVE,
|
148
|
+
help_text="Subscription status"
|
149
|
+
)
|
150
|
+
|
151
|
+
# Pricing
|
152
|
+
monthly_price = models.FloatField(
|
153
|
+
validators=[MinValueValidator(0.0)],
|
154
|
+
help_text="Monthly subscription price"
|
155
|
+
)
|
156
|
+
|
157
|
+
# Usage tracking
|
158
|
+
usage_limit = models.PositiveIntegerField(
|
159
|
+
default=1000,
|
160
|
+
help_text="Monthly usage limit (0 = unlimited)"
|
161
|
+
)
|
162
|
+
usage_current = models.PositiveIntegerField(
|
163
|
+
default=0,
|
164
|
+
help_text="Current month usage"
|
165
|
+
)
|
166
|
+
|
167
|
+
# Billing
|
168
|
+
last_billed = models.DateTimeField(
|
169
|
+
null=True,
|
170
|
+
blank=True,
|
171
|
+
help_text="Last billing date"
|
172
|
+
)
|
173
|
+
next_billing = models.DateTimeField(
|
174
|
+
null=True,
|
175
|
+
blank=True,
|
176
|
+
help_text="Next billing date"
|
177
|
+
)
|
178
|
+
|
179
|
+
# Lifecycle
|
180
|
+
expires_at = models.DateTimeField(
|
181
|
+
null=True,
|
182
|
+
blank=True,
|
183
|
+
help_text="Subscription expiration"
|
184
|
+
)
|
185
|
+
cancelled_at = models.DateTimeField(
|
186
|
+
null=True,
|
187
|
+
blank=True,
|
188
|
+
help_text="Cancellation date"
|
189
|
+
)
|
190
|
+
|
191
|
+
# Metadata
|
192
|
+
metadata = models.JSONField(
|
193
|
+
default=dict,
|
194
|
+
help_text="Additional subscription metadata"
|
195
|
+
)
|
196
|
+
|
197
|
+
# Import and assign manager
|
198
|
+
from ..managers import SubscriptionManager
|
199
|
+
objects = SubscriptionManager()
|
200
|
+
|
201
|
+
class Meta:
|
202
|
+
db_table = 'user_subscriptions'
|
203
|
+
verbose_name = "Subscription"
|
204
|
+
verbose_name_plural = "Subscriptions"
|
205
|
+
indexes = [
|
206
|
+
models.Index(fields=['user', 'status']),
|
207
|
+
models.Index(fields=['endpoint_group', 'status']),
|
208
|
+
models.Index(fields=['status', 'expires_at']),
|
209
|
+
models.Index(fields=['next_billing']),
|
210
|
+
models.Index(fields=['created_at']),
|
211
|
+
]
|
212
|
+
unique_together = [['user', 'endpoint_group']] # One subscription per user per group
|
213
|
+
ordering = ['-created_at']
|
214
|
+
|
215
|
+
def __str__(self):
|
216
|
+
return f"{self.user.email} - {self.endpoint_group.name} ({self.tier})"
|
217
|
+
|
218
|
+
def is_active(self) -> bool:
|
219
|
+
"""Check if subscription is currently active."""
|
220
|
+
now = timezone.now()
|
221
|
+
|
222
|
+
return (
|
223
|
+
self.status == self.SubscriptionStatus.ACTIVE and
|
224
|
+
(self.expires_at is None or self.expires_at > now)
|
225
|
+
)
|
226
|
+
|
227
|
+
def is_usage_exceeded(self) -> bool:
|
228
|
+
"""Check if usage limit is exceeded."""
|
229
|
+
return self.usage_limit > 0 and self.usage_current >= self.usage_limit
|
230
|
+
|
231
|
+
def get_usage_percentage(self) -> float:
|
232
|
+
"""Get usage as percentage (0-100)."""
|
233
|
+
if self.usage_limit == 0:
|
234
|
+
return 0.0 # Unlimited
|
235
|
+
|
236
|
+
return min((self.usage_current / self.usage_limit) * 100, 100.0)
|
237
|
+
|
238
|
+
def can_use_api(self) -> bool:
|
239
|
+
"""Check if user can use API (active and not exceeded)."""
|
240
|
+
return self.is_active() and not self.is_usage_exceeded()
|
241
|
+
|
242
|
+
def increment_usage(self, count: int = 1):
|
243
|
+
"""Increment usage counter."""
|
244
|
+
self.usage_current += count
|
245
|
+
self.save(update_fields=['usage_current'])
|
246
|
+
|
247
|
+
def reset_usage(self):
|
248
|
+
"""Reset usage counter (for new billing period)."""
|
249
|
+
self.usage_current = 0
|
250
|
+
self.save(update_fields=['usage_current'])
|
251
|
+
|
252
|
+
def cancel(self):
|
253
|
+
"""Cancel subscription."""
|
254
|
+
self.status = self.SubscriptionStatus.CANCELLED
|
255
|
+
self.cancelled_at = timezone.now()
|
256
|
+
self.save(update_fields=['status', 'cancelled_at'])
|
257
|
+
|
258
|
+
def extend_billing_period(self):
|
259
|
+
"""Extend billing period by one month."""
|
260
|
+
if self.next_billing:
|
261
|
+
self.next_billing += timedelta(days=30)
|
262
|
+
else:
|
263
|
+
self.next_billing = timezone.now() + timedelta(days=30)
|
264
|
+
|
265
|
+
if self.expires_at:
|
266
|
+
self.expires_at += timedelta(days=30)
|
267
|
+
else:
|
268
|
+
self.expires_at = timezone.now() + timedelta(days=30)
|
269
|
+
|
270
|
+
self.save(update_fields=['next_billing', 'expires_at'])
|
@@ -0,0 +1,102 @@
|
|
1
|
+
"""
|
2
|
+
Tariff models for the universal payments system.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from django.db import models
|
6
|
+
from django.core.validators import MinValueValidator
|
7
|
+
from .base import TimestampedModel
|
8
|
+
|
9
|
+
|
10
|
+
class Tariff(TimestampedModel):
|
11
|
+
"""Simple tariff plans for API access."""
|
12
|
+
|
13
|
+
name = models.CharField(
|
14
|
+
max_length=100,
|
15
|
+
unique=True,
|
16
|
+
help_text="Tariff name"
|
17
|
+
)
|
18
|
+
display_name = models.CharField(
|
19
|
+
max_length=200,
|
20
|
+
help_text="Human-readable tariff name"
|
21
|
+
)
|
22
|
+
description = models.TextField(
|
23
|
+
blank=True,
|
24
|
+
help_text="Tariff description"
|
25
|
+
)
|
26
|
+
|
27
|
+
# Pricing
|
28
|
+
monthly_price = models.FloatField(
|
29
|
+
default=0.0,
|
30
|
+
validators=[MinValueValidator(0.0)],
|
31
|
+
help_text="Monthly price in USD"
|
32
|
+
)
|
33
|
+
|
34
|
+
# Limits
|
35
|
+
request_limit = models.PositiveIntegerField(
|
36
|
+
default=1000,
|
37
|
+
help_text="Monthly request limit (0 = unlimited)"
|
38
|
+
)
|
39
|
+
|
40
|
+
# Settings
|
41
|
+
is_active = models.BooleanField(
|
42
|
+
default=True,
|
43
|
+
help_text="Is this tariff active"
|
44
|
+
)
|
45
|
+
|
46
|
+
# Import and assign manager
|
47
|
+
from ..managers import TariffManager
|
48
|
+
objects = TariffManager()
|
49
|
+
|
50
|
+
class Meta:
|
51
|
+
db_table = 'tariffs'
|
52
|
+
verbose_name = "Tariff"
|
53
|
+
verbose_name_plural = "Tariffs"
|
54
|
+
indexes = [
|
55
|
+
models.Index(fields=['is_active']),
|
56
|
+
models.Index(fields=['monthly_price']),
|
57
|
+
]
|
58
|
+
ordering = ['monthly_price']
|
59
|
+
|
60
|
+
def __str__(self):
|
61
|
+
return f"{self.display_name} (${self.monthly_price}/month)"
|
62
|
+
|
63
|
+
@property
|
64
|
+
def is_free(self) -> bool:
|
65
|
+
"""Check if this is a free tariff."""
|
66
|
+
return self.monthly_price == 0
|
67
|
+
|
68
|
+
|
69
|
+
class TariffEndpointGroup(TimestampedModel):
|
70
|
+
"""Simple association between tariffs and endpoint groups."""
|
71
|
+
|
72
|
+
tariff = models.ForeignKey(
|
73
|
+
Tariff,
|
74
|
+
on_delete=models.CASCADE,
|
75
|
+
related_name='endpoint_groups',
|
76
|
+
help_text="Tariff plan"
|
77
|
+
)
|
78
|
+
from .subscriptions import EndpointGroup
|
79
|
+
endpoint_group = models.ForeignKey(
|
80
|
+
EndpointGroup,
|
81
|
+
on_delete=models.CASCADE,
|
82
|
+
related_name='tariffs',
|
83
|
+
help_text="Endpoint group"
|
84
|
+
)
|
85
|
+
|
86
|
+
is_enabled = models.BooleanField(
|
87
|
+
default=True,
|
88
|
+
help_text="Is this endpoint group enabled for this tariff"
|
89
|
+
)
|
90
|
+
|
91
|
+
# Import and assign manager
|
92
|
+
from ..managers import TariffEndpointGroupManager
|
93
|
+
objects = TariffEndpointGroupManager()
|
94
|
+
|
95
|
+
class Meta:
|
96
|
+
db_table = 'tariff_endpoint_groups'
|
97
|
+
verbose_name = "Tariff Endpoint Group"
|
98
|
+
verbose_name_plural = "Tariff Endpoint Groups"
|
99
|
+
unique_together = [['tariff', 'endpoint_group']]
|
100
|
+
|
101
|
+
def __str__(self):
|
102
|
+
return f"{self.tariff.name} - {self.endpoint_group.name}"
|