diff --git a/invoices/logos/sageteam-logo.png b/invoices/logos/sageteam-logo.png deleted file mode 100644 index 889e8a6..0000000 Binary files a/invoices/logos/sageteam-logo.png and /dev/null differ diff --git a/invoices/signatures/Digital_Sign_Sepehr_Akbarzadeh.png b/invoices/signatures/Digital_Sign_Sepehr_Akbarzadeh.png deleted file mode 100644 index d8125e4..0000000 Binary files a/invoices/signatures/Digital_Sign_Sepehr_Akbarzadeh.png and /dev/null differ diff --git a/sage_invoice/admin/invoice.py b/sage_invoice/admin/invoice.py index 841d4ec..93e3a30 100644 --- a/sage_invoice/admin/invoice.py +++ b/sage_invoice/admin/invoice.py @@ -3,7 +3,7 @@ from import_export.admin import ImportExportModelAdmin from sage_invoice.admin.actions import export_pdf -from sage_invoice.models import Column, Expense, Invoice, Item +from sage_invoice.models import Column, Expense, Invoice, Item, CustomerProfile from sage_invoice.resource import InvoiceResource @@ -31,18 +31,28 @@ class ExpenseInline(admin.TabularInline): ) +class CustomerProfileInline(admin.StackedInline): + model = CustomerProfile + + @admin.register(Invoice) class InvoiceAdmin(ImportExportModelAdmin, admin.ModelAdmin): resource_class = InvoiceResource admin_priority = 1 - list_display = ("title", "invoice_date", "customer_name", "status") - search_fields = ("customer_name", "status", "customer_email") + list_display = ("title", "invoice_date", "status") + search_fields = ("status", "customer_email") save_on_top = True list_filter = ("status", "invoice_date", "category") ordering = ("-invoice_date",) autocomplete_fields = ("category",) readonly_fields = ("slug",) actions = [export_pdf] + inlines = [ + CustomerProfileInline, + ItemInline, + ColumnInline, + ExpenseInline, + ] class Media: js = ("assets/js/invoice_admin.js",) @@ -58,8 +68,6 @@ def get_fieldsets(self, request, obj=None): "invoice_date", "tracking_code", "due_date", - "customer_name", - "contacts", "category", "receipt", ), @@ -99,18 +107,18 @@ def get_fieldsets(self, request, obj=None): return fieldsets - inlines = [ItemInline, ColumnInline, ExpenseInline] - def get_inline_instances(self, request, obj=None): inlines = [] if obj and obj.pk: inlines = [ + CustomerProfileInline(self.model, self.admin_site), ItemInline(self.model, self.admin_site), ColumnInline(self.model, self.admin_site), ExpenseInline(self.model, self.admin_site), ] else: inlines = [ + CustomerProfileInline(self.model, self.admin_site), ItemInline(self.model, self.admin_site), ExpenseInline(self.model, self.admin_site), ] diff --git a/sage_invoice/helpers/choice.py b/sage_invoice/helpers/choice.py index e9bb0a9..654d991 100644 --- a/sage_invoice/helpers/choice.py +++ b/sage_invoice/helpers/choice.py @@ -2,6 +2,28 @@ class InvoiceStatus(models.TextChoices): + """ + This class defines the possible statuses for an invoice in the system. + + - **DRAFT**: The invoice is saved but has not yet been sent to the customer. + This is the initial state when an invoice is being created and hasn't been + finalized or issued. + + - **OVERDUE**: The invoice was sent, but the payment due date has passed, and + payment has not been received. It indicates that the customer is late in making + the payment. + + - **PAID**: The invoice has been fully paid by the customer. No further action is + required from either party. + + - **UNPAID**: The invoice has been sent to the customer (via email or manual download). + It has not been paid yet, but it is now awaiting customer action. The invoice + is still pending and has not yet been paid. This status includes invoices that + are still within the payment period and those that might require follow-up + (before they become overdue). + """ + DRAFT = ("draft", "DRAFT") + OVERDUE = ("overdue", "OVERDUE") PAID = ("paid", "PAID") UNPAID = ("unpaid", "UNPAID") diff --git a/sage_invoice/migrations/0001_initial.py b/sage_invoice/migrations/0001_initial.py index 8f938e3..c0006f6 100644 --- a/sage_invoice/migrations/0001_initial.py +++ b/sage_invoice/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1 on 2024-10-08 14:44 +# Generated by Django 5.1.2 on 2024-10-18 00:01 import django.db.models.deletion import django_jsonform.models.fields @@ -48,12 +48,9 @@ class Migration(migrations.Migration): ), ( "description", - models.CharField( - blank=True, - db_comment="Description of the Category", - help_text="Description of the Category.", - max_length=255, - null=True, + models.TextField( + db_comment="Stores a detailed description of the instance.", + help_text="Enter a detailed description of the item. This can include its purpose, characteristics, and any other relevant information.", verbose_name="Description", ), ), @@ -105,15 +102,6 @@ class Migration(migrations.Migration): verbose_name="Invoice Date", ), ), - ( - "customer_name", - models.CharField( - db_comment="Customer name created", - help_text="The name of the customer.", - max_length=255, - verbose_name="Customer Name", - ), - ), ( "tracking_code", models.CharField( @@ -123,16 +111,15 @@ class Migration(migrations.Migration): verbose_name="Tracking Code", ), ), - ( - "contacts", - django_jsonform.models.fields.JSONField( - blank=True, null=True, verbose_name="Customer Contacts" - ), - ), ( "status", models.CharField( - choices=[("paid", "PAID"), ("unpaid", "UNPAID")], + choices=[ + ("draft", "DRAFT"), + ("overdue", "OVERDUE"), + ("paid", "PAID"), + ("unpaid", "UNPAID"), + ], db_comment="Current status of the invoice (Paid, Unpaid)", help_text="The current status of the invoice (Paid, Unpaid).", max_length=50, @@ -241,10 +228,13 @@ class Migration(migrations.Migration): "template_choice", models.CharField( choices=[ - ("1", "Invoice1 Template"), - ("2", "Invoice2 Template"), - ("3", "Invoice3 Template"), - ("4", "Invoice4 Template"), + ("quotation_1", "quotation_1"), + ("quotation_2", "quotation_2"), + ("quotation_3", "quotation_3"), + ("quotation_4", "quotation_4"), + ("receipt1", "receipt1"), + ("receipt2", "receipt2"), + ("receipt3", "receipt3"), ], db_comment="Template choice for the invoice", help_text="The template you want for your invoice", @@ -260,7 +250,7 @@ class Migration(migrations.Migration): help_text="The category associated with this invoice.", null=True, on_delete=django.db.models.deletion.CASCADE, - related_name="category", + related_name="invoices", to="sage_invoice.category", verbose_name="Category", ), @@ -378,7 +368,7 @@ class Migration(migrations.Migration): db_comment="Reference to the associated invoice", help_text="The invoice associated with this total.", on_delete=django.db.models.deletion.CASCADE, - related_name="total", + related_name="expense", to="sage_invoice.invoice", verbose_name="Invoice", ), @@ -390,6 +380,82 @@ class Migration(migrations.Migration): "db_table": "sage_expense", }, ), + migrations.CreateModel( + name="CustomerProfile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + db_comment="Customer name created", + help_text="The name of the customer.", + max_length=255, + verbose_name="Customer Name", + ), + ), + ( + "company_name", + models.CharField( + blank=True, + db_comment="Company name associated with the customer, if applicable.", + help_text="The company name of the customer (optional).", + max_length=255, + null=True, + verbose_name="Company Name", + ), + ), + ( + "billing_address", + django_jsonform.models.fields.JSONField( + blank=True, + db_comment="Stores billing address details such as street, city, state, postal code, and country.", + help_text="The full billing address of the customer.", + null=True, + verbose_name="Billing Address", + ), + ), + ( + "shipping_address", + django_jsonform.models.fields.JSONField( + blank=True, + db_comment="Stores shipping address details if different from the billing address.", + help_text="The shipping address of the customer (optional).", + null=True, + verbose_name="Shipping Address", + ), + ), + ( + "contacts", + django_jsonform.models.fields.JSONField( + blank=True, null=True, verbose_name="Customer Contacts" + ), + ), + ( + "invoice", + models.OneToOneField( + db_comment="Link to the customer profile associated with this invoice", + help_text="The customer profile linked to this invoice.", + on_delete=django.db.models.deletion.CASCADE, + related_name="customer", + to="sage_invoice.invoice", + verbose_name="Customer Profile", + ), + ), + ], + options={ + "verbose_name": "Customer Profile", + "verbose_name_plural": "Customer Profiles", + "db_table": "sage_invoice_customer", + }, + ), migrations.CreateModel( name="Item", fields=[ @@ -414,9 +480,10 @@ class Migration(migrations.Migration): ( "quantity", models.PositiveIntegerField( + blank=True, db_comment="The quantity of the invoice item", - default=1, help_text="The quantity of the item.", + null=True, verbose_name="Quantity", ), ), diff --git a/sage_invoice/migrations/0002_alter_invoice_template_choice.py b/sage_invoice/migrations/0002_alter_invoice_template_choice.py deleted file mode 100644 index 609c0e0..0000000 --- a/sage_invoice/migrations/0002_alter_invoice_template_choice.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.1.2 on 2024-10-17 09:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("sage_invoice", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="invoice", - name="template_choice", - field=models.CharField( - choices=[ - ("quotation_1", "quotation_1"), - ("quotation_2", "quotation_2"), - ("quotation_3", "quotation_3"), - ("quotation_4", "quotation_4"), - ("receipt1", "receipt1"), - ("receipt2", "receipt2"), - ("receipt3", "receipt3"), - ], - db_comment="Template choice for the invoice", - help_text="The template you want for your invoice", - max_length=20, - verbose_name="Template choice", - ), - ), - ] diff --git a/sage_invoice/migrations/0003_alter_expense_invoice_alter_invoice_category_and_more.py b/sage_invoice/migrations/0003_alter_expense_invoice_alter_invoice_category_and_more.py deleted file mode 100644 index 4fb793a..0000000 --- a/sage_invoice/migrations/0003_alter_expense_invoice_alter_invoice_category_and_more.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 5.1.2 on 2024-10-17 17:19 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("sage_invoice", "0002_alter_invoice_template_choice"), - ] - - operations = [ - migrations.AlterField( - model_name="expense", - name="invoice", - field=models.OneToOneField( - db_comment="Reference to the associated invoice", - help_text="The invoice associated with this total.", - on_delete=django.db.models.deletion.CASCADE, - related_name="expense", - to="sage_invoice.invoice", - verbose_name="Invoice", - ), - ), - migrations.AlterField( - model_name="invoice", - name="category", - field=models.ForeignKey( - blank=True, - db_comment="Category associated with this invoice", - help_text="The category associated with this invoice.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="invoices", - to="sage_invoice.category", - verbose_name="Category", - ), - ), - migrations.AlterField( - model_name="item", - name="quantity", - field=models.PositiveIntegerField( - blank=True, - db_comment="The quantity of the invoice item", - help_text="The quantity of the item.", - null=True, - verbose_name="Quantity", - ), - ), - ] diff --git a/sage_invoice/models/__init__.py b/sage_invoice/models/__init__.py index cdeb9a4..415f58b 100644 --- a/sage_invoice/models/__init__.py +++ b/sage_invoice/models/__init__.py @@ -2,6 +2,7 @@ from .column import Column from .expense import Expense from .invoice import Invoice +from .customer import CustomerProfile from .item import Item -__all__ = ["Invoice", "Column", "Item", "Expense", "Category"] +__all__ = ["Invoice", "Column", "Item", "Expense", "Category", "CustomerProfile"] diff --git a/sage_invoice/models/customer.py b/sage_invoice/models/customer.py new file mode 100644 index 0000000..9981b7e --- /dev/null +++ b/sage_invoice/models/customer.py @@ -0,0 +1,119 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_jsonform.models.fields import JSONField + +class CustomerProfile(models.Model): + """ + This model stores complete customer information, including contact details, + billing address, and additional fields required for invoicing. + """ + invoice = models.OneToOneField( + "Invoice", + on_delete=models.CASCADE, + related_name="customer", + verbose_name=_("Customer Profile"), + help_text=_("The customer profile linked to this invoice."), + db_comment="Link to the customer profile associated with this invoice", + ) + + name = models.CharField( + max_length=255, + verbose_name=_("Customer Name"), + help_text=_("The name of the customer."), + db_comment="Customer name created", + ) + + company_name = models.CharField( + max_length=255, + verbose_name=_("Company Name"), + blank=True, + null=True, + help_text=_("The company name of the customer (optional)."), + db_comment="Company name associated with the customer, if applicable.", + ) + + billing_address = JSONField( + verbose_name=_("Billing Address"), + blank=True, + null=True, + schema={ + "type": "object", + "title": "Billing Address", + "properties": { + "street": {"type": "string", "title": "Street", "placeholder": "123 Main St"}, + "city": {"type": "string", "title": "City", "placeholder": "New York"}, + "state": {"type": "string", "title": "State", "placeholder": "NY"}, + "postal_code": {"type": "string", "pattern": "^[0-9]+$", "title": "Postal Code", "placeholder": "10001"}, + "country": {"type": "string", "title": "Country", "placeholder": "USA"}, + }, + }, + help_text=_("The full billing address of the customer."), + db_comment="Stores billing address details such as street, city, state, postal code, and country.", + ) + + # Optional shipping address (if different from billing) + shipping_address = JSONField( + verbose_name=_("Shipping Address"), + blank=True, + null=True, + schema={ + "type": "object", + "title": "Shipping Address", + "properties": { + "street": {"type": "string", "title": "Street", "placeholder": "456 Shipping Ave"}, + "city": {"type": "string", "title": "City", "placeholder": "Los Angeles"}, + "state": {"type": "string", "title": "State", "placeholder": "CA"}, + "postal_code": {"type": "string", "pattern": "^[0-9]+$", "title": "Postal Code", "placeholder": "90001"}, + "country": {"type": "string", "title": "Country", "placeholder": "USA"}, + }, + }, + help_text=_("The shipping address of the customer (optional)."), + db_comment="Stores shipping address details if different from the billing address.", + ) + + contact = JSONField( + verbose_name="Customer Contacts", + blank=True, + null=True, + schema={ + "type": "object", + "properties": { + "Contact Info": { + "oneOf": [ + { + "type": "object", + "title": "Phone", + "properties": { + "phone": { + "type": "string", + "pattern": "^[0-9]+$", + "title": "Phone", + "placeholder": "1234567890", + } + }, + }, + { + "type": "object", + "title": "Email", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email", + "placeholder": "you@example.com", + } + }, + }, + ] + } + }, + }, + ) + + def __str__(self): + return f"{self.customer_name} ({self.company_name})" if self.company_name else self.customer_name + + class Meta: + verbose_name = _("Customer Profile") + verbose_name_plural = _("Customer Profiles") + db_table = "sage_invoice_customer" diff --git a/sage_invoice/models/invoice.py b/sage_invoice/models/invoice.py index ed58dd6..086b983 100644 --- a/sage_invoice/models/invoice.py +++ b/sage_invoice/models/invoice.py @@ -15,12 +15,6 @@ class Invoice(TitleSlugMixin): help_text=_("The date when the invoice was created."), db_comment="Invoice date created", ) - customer_name = models.CharField( - max_length=255, - verbose_name=_("Customer Name"), - help_text=_("The name of the customer."), - db_comment="Customer name created", - ) tracking_code = models.CharField( max_length=255, verbose_name=_("Tracking Code"), @@ -29,47 +23,10 @@ class Invoice(TitleSlugMixin): ), db_comment="Tracking code created", ) - contacts = JSONField( - verbose_name="Customer Contacts", - blank=True, - null=True, - schema={ - "type": "object", - "properties": { - "Contact Info": { - "oneOf": [ - { - "type": "object", - "title": "Phone", - "properties": { - "phone": { - "type": "string", - "pattern": "^[0-9]+$", - "title": "Phone", - "placeholder": "1234567890", - } - }, - }, - { - "type": "object", - "title": "Email", - "properties": { - "email": { - "type": "string", - "format": "email", - "title": "Email", - "placeholder": "you@example.com", - } - }, - }, - ] - } - }, - }, - ) + status = models.CharField( max_length=50, - choices=InvoiceStatus, + choices=InvoiceStatus.choices, verbose_name=_("Status"), help_text=_("The current status of the invoice (Paid, Unpaid)."), db_comment="Current status of the invoice (Paid, Unpaid)", @@ -164,9 +121,15 @@ class Invoice(TitleSlugMixin): ) def clean(self): - if self.due_date < self.invoice_date: - raise ValidationError(_("Due Date must be later than Invoice Date.")) - if len(self.tracking_code) <= 10: + # Ensure both `due_date` and `invoice_date` are set before comparing them + if self.due_date and self.invoice_date: + if self.due_date < self.invoice_date: + raise ValidationError(_("Due Date must be later than Invoice Date.")) + else: + raise ValidationError(_("Both Due Date and Invoice Date must be provided.")) + + # Ensure tracking code length is valid + if self.tracking_code and len(self.tracking_code) <= 10: self.tracking_code = generate_tracking_code( self.tracking_code, self.invoice_date )