oxutils 0.1.0__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.
oxutils/models/base.py ADDED
@@ -0,0 +1,116 @@
1
+ import uuid
2
+ from django.db import models
3
+ from django.conf import settings
4
+
5
+
6
+ class UUIDPrimaryKeyMixin(models.Model):
7
+ """Mixin that provides a UUID primary key field."""
8
+ id = models.UUIDField(
9
+ primary_key=True,
10
+ default=uuid.uuid4,
11
+ editable=False,
12
+ help_text="Unique identifier for this record"
13
+ )
14
+
15
+ class Meta:
16
+ abstract = True
17
+
18
+
19
+ class TimestampMixin(models.Model):
20
+ """Mixin that provides created_at and updated_at timestamp fields."""
21
+ created_at = models.DateTimeField(
22
+ auto_now_add=True,
23
+ help_text="Date and time when this record was created"
24
+ )
25
+ updated_at = models.DateTimeField(
26
+ auto_now=True,
27
+ help_text="Date and time when this record was last updated"
28
+ )
29
+
30
+ class Meta:
31
+ abstract = True
32
+
33
+
34
+ class UserTrackingMixin(models.Model):
35
+ """Mixin that tracks which user created and last modified a record."""
36
+ created_by = models.ForeignKey(
37
+ settings.AUTH_USER_MODEL,
38
+ on_delete=models.SET_NULL,
39
+ null=True,
40
+ blank=True,
41
+ related_name="%(app_label)s_%(class)s_created",
42
+ help_text="User who created this record"
43
+ )
44
+ updated_by = models.ForeignKey(
45
+ settings.AUTH_USER_MODEL,
46
+ on_delete=models.SET_NULL,
47
+ null=True,
48
+ blank=True,
49
+ related_name="%(app_label)s_%(class)s_updated",
50
+ help_text="User who last updated this record"
51
+ )
52
+
53
+ class Meta:
54
+ abstract = True
55
+
56
+
57
+ class SlugMixin(models.Model):
58
+ """Mixin that provides a slug field."""
59
+ slug = models.SlugField(
60
+ max_length=255,
61
+ unique=True,
62
+ help_text="URL-friendly version of the name"
63
+ )
64
+
65
+ class Meta:
66
+ abstract = True
67
+
68
+
69
+ class NameMixin(models.Model):
70
+ """Mixin that provides name and description fields."""
71
+ name = models.CharField(
72
+ max_length=255,
73
+ help_text="Name of this record"
74
+ )
75
+ description = models.TextField(
76
+ blank=True,
77
+ help_text="Optional description"
78
+ )
79
+
80
+ class Meta:
81
+ abstract = True
82
+
83
+ def __str__(self):
84
+ return self.name
85
+
86
+
87
+ class ActiveMixin(models.Model):
88
+ """Mixin that provides an active/inactive status field."""
89
+ is_active = models.BooleanField(
90
+ default=True,
91
+ help_text="Whether this record is active"
92
+ )
93
+
94
+ class Meta:
95
+ abstract = True
96
+
97
+
98
+ class OrderingMixin(models.Model):
99
+ """Mixin that provides an ordering field."""
100
+ order = models.PositiveIntegerField(
101
+ default=0,
102
+ help_text="Order for sorting records"
103
+ )
104
+
105
+ class Meta:
106
+ abstract = True
107
+ ordering = ['order']
108
+
109
+
110
+ class BaseModelMixin(UUIDPrimaryKeyMixin, TimestampMixin, ActiveMixin):
111
+ """
112
+ Base mixin that combines the most commonly used mixins.
113
+ Provides UUID primary key, timestamps, and active status.
114
+ """
115
+ class Meta:
116
+ abstract = True
@@ -0,0 +1,140 @@
1
+ from django.utils.translation import gettext_lazy as _
2
+ from django.db import models
3
+ from .base import BaseModelMixin
4
+
5
+
6
+
7
+
8
+ class BillingMixin(BaseModelMixin):
9
+ """Billing information for individual users"""
10
+
11
+ PAYMENT_METHOD_CHOICES = [
12
+ ('card', _('Credit card')),
13
+ ('paypal', _('PayPal')),
14
+ ('bank_transfer', _('Bank transfer')),
15
+ ('stripe', _('Stripe')),
16
+ ]
17
+
18
+ CURRENCY_CHOICES = [
19
+ ('USD', _('US Dollar')),
20
+ ('CDF', _('Congolese Franc')),
21
+ ]
22
+
23
+ # Billing address
24
+ billing_name = models.CharField(
25
+ _('billing name'),
26
+ max_length=100,
27
+ blank=True,
28
+ help_text=_('Full name for billing')
29
+ )
30
+
31
+ billing_email = models.EmailField(
32
+ _('billing email'),
33
+ blank=True,
34
+ help_text=_('Email to receive invoices')
35
+ )
36
+
37
+ company_name = models.CharField(
38
+ _('company name'),
39
+ max_length=100,
40
+ blank=True,
41
+ null=True,
42
+ help_text=_('Company name (optional)')
43
+ )
44
+
45
+ tax_number = models.CharField(
46
+ _('VAT number'),
47
+ max_length=50,
48
+ blank=True,
49
+ null=True,
50
+ help_text=_('VAT or tax identification number')
51
+ )
52
+
53
+ # Address
54
+ street_address = models.CharField(
55
+ _('address'),
56
+ max_length=255,
57
+ blank=True
58
+ )
59
+
60
+ city = models.CharField(
61
+ _('city'),
62
+ max_length=100,
63
+ blank=True
64
+ )
65
+
66
+ postal_code = models.CharField(
67
+ _('postal code'),
68
+ max_length=20,
69
+ blank=True
70
+ )
71
+
72
+ country = models.CharField(
73
+ _('country'),
74
+ max_length=2,
75
+ blank=True,
76
+ help_text=_('ISO 3166-1 alpha-2 country code')
77
+ )
78
+
79
+ # Payment preferences
80
+ preferred_currency = models.CharField(
81
+ _('preferred currency'),
82
+ max_length=3,
83
+ choices=CURRENCY_CHOICES,
84
+ default='USD'
85
+ )
86
+
87
+ preferred_payment_method = models.CharField(
88
+ _('preferred payment method'),
89
+ max_length=20,
90
+ choices=PAYMENT_METHOD_CHOICES,
91
+ default='card'
92
+ )
93
+
94
+ # Stripe customer info
95
+ stripe_customer_id = models.CharField(
96
+ _('Stripe customer ID'),
97
+ max_length=100,
98
+ blank=True,
99
+ null=True,
100
+ help_text=_('Stripe customer identifier')
101
+ )
102
+
103
+ # Invoice preferences
104
+ auto_pay = models.BooleanField(
105
+ _('automatic payment'),
106
+ default=False,
107
+ help_text=_('Enable automatic invoice payment')
108
+ )
109
+
110
+ invoice_notes = models.TextField(
111
+ _('billing notes'),
112
+ blank=True,
113
+ max_length=500,
114
+ help_text=_('Custom notes to include on invoices')
115
+ )
116
+
117
+ class Meta:
118
+ abstract = True
119
+ verbose_name = _('Billing information')
120
+ verbose_name_plural = _('Billing information')
121
+
122
+
123
+ @property
124
+ def full_address(self):
125
+ """Return formatted full address"""
126
+ parts = [
127
+ self.street_address,
128
+ self.city,
129
+ self.postal_code,
130
+ self.country
131
+ ]
132
+ return ', '.join(filter(None, parts))
133
+
134
+ def get_billing_name(self):
135
+ """Get billing name"""
136
+ return self.billing_name
137
+
138
+ def get_billing_email(self):
139
+ """Get billing email"""
140
+ return self.billing_email
@@ -0,0 +1,467 @@
1
+ from decimal import Decimal
2
+ from django.db import models
3
+ from django.utils.translation import gettext_lazy as _
4
+ from .base import (
5
+ UUIDPrimaryKeyMixin, TimestampMixin , UserTrackingMixin
6
+ )
7
+ from oxutils.enums import InvoiceStatusEnum
8
+
9
+
10
+
11
+
12
+ class InvoiceMixin(UUIDPrimaryKeyMixin,TimestampMixin, UserTrackingMixin):
13
+ """Model for invoices and billing"""
14
+
15
+ # Invoice details
16
+ invoice_number = models.CharField(
17
+ _('invoice number'),
18
+ max_length=50,
19
+ unique=True,
20
+ help_text=_('Unique invoice number')
21
+ )
22
+
23
+ status = models.CharField(
24
+ _('status'),
25
+ max_length=20,
26
+ choices=[(status.value, status.value) for status in InvoiceStatusEnum],
27
+ default=InvoiceStatusEnum.DRAFT,
28
+ help_text=_('Invoice status')
29
+ )
30
+
31
+ # Amounts
32
+ subtotal = models.DecimalField(
33
+ _('subtotal'),
34
+ max_digits=10,
35
+ decimal_places=2,
36
+ help_text=_('Amount excluding taxes')
37
+ )
38
+
39
+ tax_rate = models.DecimalField(
40
+ _('tax rate'),
41
+ max_digits=5,
42
+ decimal_places=2,
43
+ default=0.00,
44
+ help_text=_('Tax rate as percentage')
45
+ )
46
+
47
+ tax_amount = models.DecimalField(
48
+ _('tax amount'),
49
+ max_digits=10,
50
+ decimal_places=2,
51
+ default=0.00,
52
+ help_text=_('Tax amount')
53
+ )
54
+
55
+ total = models.DecimalField(
56
+ _('total'),
57
+ max_digits=10,
58
+ decimal_places=2,
59
+ help_text=_('Total amount including tax')
60
+ )
61
+
62
+ currency = models.CharField(
63
+ _('currency'),
64
+ max_length=3,
65
+ default='USD',
66
+ help_text=_('ISO currency code (CDF, USD, etc.)')
67
+ )
68
+
69
+ # Dates
70
+ issue_date = models.DateField(
71
+ _('issue date'),
72
+ help_text=_('Invoice issue date')
73
+ )
74
+
75
+ due_date = models.DateField(
76
+ _('due date'),
77
+ help_text=_('Payment due date')
78
+ )
79
+
80
+ paid_date = models.DateTimeField(
81
+ _('payment date'),
82
+ null=True,
83
+ blank=True,
84
+ help_text=_('Payment date and time')
85
+ )
86
+
87
+ # Billing period
88
+ period_start = models.DateField(
89
+ _('period start'),
90
+ help_text=_('Billing period start date')
91
+ )
92
+
93
+ period_end = models.DateField(
94
+ _('period end'),
95
+ help_text=_('Billing period end date')
96
+ )
97
+
98
+ # Additional info
99
+ description = models.TextField(
100
+ _('description'),
101
+ blank=True,
102
+ help_text=_('Detailed invoice description')
103
+ )
104
+
105
+ notes = models.TextField(
106
+ _('notes'),
107
+ blank=True,
108
+ help_text=_('Internal notes about the invoice')
109
+ )
110
+
111
+ # External payment system reference
112
+ payment_reference = models.CharField(
113
+ _('payment reference'),
114
+ max_length=100,
115
+ blank=True,
116
+ help_text=_('External payment system reference (Stripe, etc.)')
117
+ )
118
+
119
+ class Meta:
120
+ abstract = True
121
+ verbose_name = _('Invoice')
122
+ verbose_name_plural = _('Invoices')
123
+ ordering = ['-issue_date']
124
+ indexes = [
125
+ models.Index(fields=['status']),
126
+ models.Index(fields=['issue_date']),
127
+ models.Index(fields=['due_date']),
128
+ models.Index(fields=['invoice_number']),
129
+ ]
130
+
131
+ def __str__(self):
132
+ return f"Invoice {self.invoice_number}"
133
+
134
+ def save(self, *args, **kwargs):
135
+ if self._state.adding:
136
+ self.tax_rate = 16
137
+
138
+ if not self.invoice_number:
139
+ self.invoice_number = self.generate_invoice_number()
140
+
141
+ self.calculate_amounts()
142
+
143
+ self.tax_amount = (self.subtotal * self.tax_rate) / 100
144
+ self.total = self.subtotal + self.tax_amount
145
+
146
+ super().save(*args, **kwargs)
147
+
148
+ def generate_invoice_number(self):
149
+ """Generate unique invoice number"""
150
+ from django.utils import timezone
151
+ year = timezone.now().year
152
+ month = timezone.now().month
153
+
154
+ # Get last invoice number for this month
155
+ last_invoice = self.__class__.objects.filter(
156
+ invoice_number__startswith=f"INV-{year}{month:02d}"
157
+ ).order_by('-invoice_number').first()
158
+
159
+ if last_invoice:
160
+ # Extract sequence number and increment
161
+ try:
162
+ sequence = int(last_invoice.invoice_number.split('-')[-1]) + 1
163
+ except (ValueError, IndexError):
164
+ sequence = 1
165
+ else:
166
+ sequence = 1
167
+
168
+ return f"INV-{year}{month:02d}-{sequence:04d}"
169
+
170
+ def is_overdue(self):
171
+ """Check if invoice is overdue"""
172
+ from django.utils import timezone
173
+ return (
174
+ self.status == InvoiceStatusEnum.PENDING and
175
+ timezone.now().date() > self.due_date
176
+ )
177
+
178
+ def mark_as_paid(self, payment_reference=None, paid_date=None):
179
+ """Mark invoice as paid"""
180
+ from django.utils import timezone
181
+ self.status = InvoiceStatusEnum.PAID
182
+
183
+ if paid_date:
184
+ self.paid_date = paid_date
185
+ else:
186
+ self.paid_date = timezone.now()
187
+
188
+ if payment_reference:
189
+ self.payment_reference = payment_reference
190
+ self.save()
191
+
192
+ def mark_as_overdue(self):
193
+ """Mark invoice as overdue"""
194
+ self.status = InvoiceStatusEnum.OVERDUE
195
+ self.save()
196
+
197
+ def cancel(self, reason=""):
198
+ """Cancel invoice"""
199
+
200
+ if self.status == InvoiceStatusEnum.PAID:
201
+ raise ValueError("Cannot cancel a paid invoice")
202
+
203
+ self.status = InvoiceStatusEnum.CANCELLED
204
+
205
+ if reason:
206
+ self.notes = f"{invoice.notes}\nCancelled: {reason}".strip()
207
+
208
+ self.save()
209
+
210
+ def refund(self, reason= ""):
211
+ """Mark invoice as refunded"""
212
+
213
+ if self.status != InvoiceStatusEnum.PAID:
214
+ raise ValueError("Only paid invoices can be refunded")
215
+
216
+ self.status = InvoiceStatusEnum.REFUNDED
217
+
218
+ if reason:
219
+ self.notes = f"{invoice.notes}\nRefunded: {reason}".strip()
220
+
221
+ self.save()
222
+
223
+ def get_plan_name(self):
224
+ """Get the plan name for this invoice"""
225
+ raise NotImplementedError
226
+
227
+ def is_trial_invoice(self):
228
+ """Check if this invoice is for a trial period"""
229
+ return False
230
+
231
+ def calculate_amounts(self):
232
+ raise NotImplementedError
233
+
234
+
235
+ class InvoiceItemMixin(UUIDPrimaryKeyMixin, TimestampMixin):
236
+ """Model for individual items within a user invoice"""
237
+
238
+ # Item details
239
+ name = models.CharField(
240
+ _('service name'),
241
+ max_length=200,
242
+ help_text=_('Name of the billed service or product')
243
+ )
244
+
245
+ description = models.TextField(
246
+ _('description'),
247
+ blank=True,
248
+ help_text=_('Detailed service description')
249
+ )
250
+
251
+ # Pricing
252
+ quantity = models.DecimalField(
253
+ _('quantity'),
254
+ max_digits=10,
255
+ decimal_places=2,
256
+ default=Decimal('1.00'),
257
+ help_text=_('Service quantity (hours, units, etc.)')
258
+ )
259
+
260
+ unit_price = models.DecimalField(
261
+ _('unit price'),
262
+ max_digits=10,
263
+ decimal_places=2,
264
+ help_text=_('Unit price excluding taxes')
265
+ )
266
+
267
+ total_price = models.DecimalField(
268
+ _('total price'),
269
+ max_digits=10,
270
+ decimal_places=2,
271
+ help_text=_('Total price for this item (quantity × unit price)')
272
+ )
273
+
274
+ # Metadata
275
+ metadata = models.JSONField(
276
+ _('metadata'),
277
+ default=dict,
278
+ blank=True,
279
+ help_text=_('Additional data in JSON format')
280
+ )
281
+
282
+ class Meta:
283
+ abstract = True
284
+ verbose_name = _('Invoice item')
285
+ verbose_name_plural = _('Invoice items')
286
+ ordering = ['id']
287
+ indexes = [
288
+ models.Index(fields=['name']),
289
+ ]
290
+
291
+ def __str__(self):
292
+ return f"{self.name} - {self.invoice.invoice_number}"
293
+
294
+ def save(self, *args, **kwargs):
295
+ # Auto-calculate total price
296
+ self.total_price = self.quantity * self.unit_price
297
+
298
+ super().save(*args, **kwargs)
299
+ self.invoice.update_totals()
300
+
301
+ def delete(self, *args, **kwargs):
302
+ super().delete(*args, **kwargs)
303
+ self.invoice.update_totals()
304
+
305
+
306
+ class RefundRequestMixin(UUIDPrimaryKeyMixin, TimestampMixin, UserTrackingMixin):
307
+ """Abstract model for refund requests"""
308
+
309
+ REFUND_STATUS_CHOICES = [
310
+ ('pending', _('Pending')),
311
+ ('approved', _('Approved')),
312
+ ('rejected', _('Rejected')),
313
+ ('processed', _('Processed')),
314
+ ('cancelled', _('Cancelled')),
315
+ ]
316
+
317
+ REFUND_REASON_CHOICES = [
318
+ ('duplicate_payment', _('Duplicate payment')),
319
+ ('service_not_received', _('Service not received')),
320
+ ('billing_error', _('Billing error')),
321
+ ('cancellation', _('Cancellation')),
322
+ ('technical_issue', _('Technical issue')),
323
+ ('other', _('Other')),
324
+ ]
325
+
326
+ # Refund details
327
+ status = models.CharField(
328
+ _('status'),
329
+ max_length=20,
330
+ choices=REFUND_STATUS_CHOICES,
331
+ default='pending',
332
+ help_text=_('Refund request status')
333
+ )
334
+
335
+ reason = models.CharField(
336
+ _('reason'),
337
+ max_length=30,
338
+ choices=REFUND_REASON_CHOICES,
339
+ help_text=_('Reason for refund request')
340
+ )
341
+
342
+ description = models.TextField(
343
+ _('description'),
344
+ help_text=_('Detailed description of the refund request')
345
+ )
346
+
347
+ # Amount details
348
+ requested_amount = models.DecimalField(
349
+ _('requested amount'),
350
+ max_digits=10,
351
+ decimal_places=2,
352
+ help_text=_('Requested refund amount')
353
+ )
354
+
355
+ approved_amount = models.DecimalField(
356
+ _('approved amount'),
357
+ max_digits=10,
358
+ decimal_places=2,
359
+ null=True,
360
+ blank=True,
361
+ help_text=_('Approved refund amount')
362
+ )
363
+
364
+ currency = models.CharField(
365
+ _('currency'),
366
+ max_length=3,
367
+ default='USD',
368
+ help_text=_('ISO currency code (CDF, USD, etc.)')
369
+ )
370
+
371
+ # Processing details
372
+ processed_date = models.DateTimeField(
373
+ _('processing date'),
374
+ null=True,
375
+ blank=True,
376
+ help_text=_('Refund processing date and time')
377
+ )
378
+
379
+ admin_notes = models.TextField(
380
+ _('admin notes'),
381
+ blank=True,
382
+ help_text=_('Internal administrator notes')
383
+ )
384
+
385
+ # External payment system reference
386
+ refund_reference = models.CharField(
387
+ _('refund reference'),
388
+ max_length=100,
389
+ blank=True,
390
+ help_text=_('External payment system reference (Stripe, etc.)')
391
+ )
392
+
393
+ class Meta:
394
+ abstract = True
395
+ verbose_name = _('Refund request')
396
+ verbose_name_plural = _('Refund requests')
397
+ ordering = ['-created_at']
398
+ indexes = [
399
+ models.Index(fields=['status']),
400
+ models.Index(fields=['created_at']),
401
+ models.Index(fields=['processed_date']),
402
+ ]
403
+
404
+ def __str__(self):
405
+ return f"Refund {self.requested_amount} {self.currency} - {self.get_status_display()}"
406
+
407
+ def approve(self, approved_amount=None, admin_notes=""):
408
+ """Approve the refund request"""
409
+ self.status = 'approved'
410
+ self.approved_amount = approved_amount or self.requested_amount
411
+ if admin_notes:
412
+ self.admin_notes = f"{self.admin_notes}\n{admin_notes}".strip()
413
+ self.save()
414
+
415
+ def reject(self, admin_notes=""):
416
+ """Reject the refund request"""
417
+ self.status = 'rejected'
418
+ if admin_notes:
419
+ self.admin_notes = f"{self.admin_notes}\n{admin_notes}".strip()
420
+ self.save()
421
+
422
+ def process(self, refund_reference="", admin_notes=""):
423
+ """Mark refund as processed"""
424
+ from django.utils import timezone
425
+
426
+ if self.status != 'approved':
427
+ raise ValueError("Only approved requests can be processed")
428
+
429
+ self.status = 'processed'
430
+ self.processed_date = timezone.now()
431
+ self.refund_reference = refund_reference
432
+
433
+ if admin_notes:
434
+ self.admin_notes = f"{self.admin_notes}\n{admin_notes}".strip()
435
+
436
+ self.save()
437
+
438
+ def cancel(self, admin_notes=""):
439
+ """Cancel the refund request"""
440
+ if self.status == 'processed':
441
+ raise ValueError("Cannot cancel an already processed request")
442
+
443
+ self.status = 'cancelled'
444
+ if admin_notes:
445
+ self.admin_notes = f"{self.admin_notes}\n{admin_notes}".strip()
446
+ self.save()
447
+
448
+ def is_pending(self):
449
+ """Check if refund request is pending"""
450
+ return self.status == 'pending'
451
+
452
+ def is_approved(self):
453
+ """Check if refund request is approved"""
454
+ return self.status == 'approved'
455
+
456
+ def is_processed(self):
457
+ """Check if refund request is processed"""
458
+ return self.status == 'processed'
459
+
460
+ def can_be_modified(self):
461
+ """Check if refund request can still be modified"""
462
+ return self.status in ['pending']
463
+
464
+ def get_final_amount(self):
465
+ """Get the final refund amount"""
466
+ return self.approved_amount or self.requested_amount
467
+