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/__init__.py +23 -0
- oxutils/apps.py +14 -0
- oxutils/audit/__init__.py +0 -0
- oxutils/audit/apps.py +12 -0
- oxutils/audit/export.py +229 -0
- oxutils/audit/masks.py +97 -0
- oxutils/audit/models.py +75 -0
- oxutils/audit/settings.py +19 -0
- oxutils/celery/__init__.py +1 -0
- oxutils/celery/base.py +98 -0
- oxutils/celery/settings.py +1 -0
- oxutils/conf.py +12 -0
- oxutils/enums/__init__.py +1 -0
- oxutils/enums/audit.py +8 -0
- oxutils/enums/invoices.py +11 -0
- oxutils/exceptions.py +117 -0
- oxutils/functions.py +99 -0
- oxutils/jwt/__init__.py +0 -0
- oxutils/jwt/auth.py +55 -0
- oxutils/jwt/client.py +123 -0
- oxutils/jwt/constants.py +1 -0
- oxutils/locale/fr/LC_MESSAGES/django.mo +0 -0
- oxutils/locale/fr/LC_MESSAGES/django.po +368 -0
- oxutils/logger/__init__.py +0 -0
- oxutils/logger/receivers.py +18 -0
- oxutils/logger/settings.py +63 -0
- oxutils/mixins/__init__.py +0 -0
- oxutils/mixins/base.py +21 -0
- oxutils/mixins/schemas.py +13 -0
- oxutils/mixins/services.py +146 -0
- oxutils/models/__init__.py +3 -0
- oxutils/models/base.py +116 -0
- oxutils/models/billing.py +140 -0
- oxutils/models/invoice.py +467 -0
- oxutils/py.typed +0 -0
- oxutils/s3/__init__.py +0 -0
- oxutils/s3/settings.py +34 -0
- oxutils/s3/storages.py +130 -0
- oxutils/settings.py +254 -0
- oxutils/types.py +13 -0
- oxutils-0.1.0.dist-info/METADATA +201 -0
- oxutils-0.1.0.dist-info/RECORD +43 -0
- oxutils-0.1.0.dist-info/WHEEL +4 -0
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
|
+
|