wbaccounting 2.2.1__py2.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.
- wbaccounting/__init__.py +1 -0
- wbaccounting/admin/__init__.py +5 -0
- wbaccounting/admin/booking_entry.py +53 -0
- wbaccounting/admin/entry_accounting_information.py +10 -0
- wbaccounting/admin/invoice.py +26 -0
- wbaccounting/admin/invoice_type.py +8 -0
- wbaccounting/admin/transactions.py +16 -0
- wbaccounting/apps.py +5 -0
- wbaccounting/dynamic_preferences_registry.py +107 -0
- wbaccounting/factories/__init__.py +10 -0
- wbaccounting/factories/booking_entry.py +21 -0
- wbaccounting/factories/entry_accounting_information.py +46 -0
- wbaccounting/factories/invoice.py +43 -0
- wbaccounting/factories/transactions.py +32 -0
- wbaccounting/files/__init__.py +0 -0
- wbaccounting/files/invoice_document_file.py +134 -0
- wbaccounting/files/utils.py +331 -0
- wbaccounting/generators/__init__.py +6 -0
- wbaccounting/generators/base.py +120 -0
- wbaccounting/io/handlers/__init__.py +0 -0
- wbaccounting/io/handlers/transactions.py +32 -0
- wbaccounting/io/parsers/__init__.py +0 -0
- wbaccounting/io/parsers/societe_generale_lux.py +49 -0
- wbaccounting/io/parsers/societe_generale_lux_prenotification.py +60 -0
- wbaccounting/migrations/0001_initial_squashed_squashed_0005_alter_bookingentry_counterparty_and_more.py +284 -0
- wbaccounting/migrations/0006_alter_invoice_status.py +30 -0
- wbaccounting/migrations/0007_alter_invoice_options.py +23 -0
- wbaccounting/migrations/0008_alter_invoice_options.py +20 -0
- wbaccounting/migrations/0009_invoicetype_alter_bookingentry_options_and_more.py +366 -0
- wbaccounting/migrations/0010_alter_bookingentry_options.py +20 -0
- wbaccounting/migrations/0011_transaction.py +103 -0
- wbaccounting/migrations/0012_entryaccountinginformation_external_invoice_users.py +25 -0
- wbaccounting/migrations/__init__.py +0 -0
- wbaccounting/models/__init__.py +6 -0
- wbaccounting/models/booking_entry.py +167 -0
- wbaccounting/models/entry_accounting_information.py +157 -0
- wbaccounting/models/invoice.py +467 -0
- wbaccounting/models/invoice_type.py +30 -0
- wbaccounting/models/model_tasks.py +71 -0
- wbaccounting/models/transactions.py +112 -0
- wbaccounting/permissions.py +6 -0
- wbaccounting/processors/__init__.py +0 -0
- wbaccounting/processors/dummy_processor.py +5 -0
- wbaccounting/serializers/__init__.py +12 -0
- wbaccounting/serializers/booking_entry.py +78 -0
- wbaccounting/serializers/consolidated_invoice.py +109 -0
- wbaccounting/serializers/entry_accounting_information.py +149 -0
- wbaccounting/serializers/invoice.py +95 -0
- wbaccounting/serializers/invoice_type.py +16 -0
- wbaccounting/serializers/transactions.py +50 -0
- wbaccounting/tests/__init__.py +0 -0
- wbaccounting/tests/conftest.py +65 -0
- wbaccounting/tests/test_displays/__init__.py +0 -0
- wbaccounting/tests/test_displays/test_booking_entries.py +1 -0
- wbaccounting/tests/test_models/__init__.py +0 -0
- wbaccounting/tests/test_models/test_booking_entries.py +119 -0
- wbaccounting/tests/test_models/test_entry_accounting_information.py +81 -0
- wbaccounting/tests/test_models/test_invoice_types.py +21 -0
- wbaccounting/tests/test_models/test_invoices.py +73 -0
- wbaccounting/tests/test_models/test_transactions.py +40 -0
- wbaccounting/tests/test_processors.py +28 -0
- wbaccounting/tests/test_serializers/__init__.py +0 -0
- wbaccounting/tests/test_serializers/test_booking_entries.py +69 -0
- wbaccounting/tests/test_serializers/test_entry_accounting_information.py +64 -0
- wbaccounting/tests/test_serializers/test_invoice_types.py +35 -0
- wbaccounting/tests/test_serializers/test_transactions.py +72 -0
- wbaccounting/urls.py +68 -0
- wbaccounting/viewsets/__init__.py +12 -0
- wbaccounting/viewsets/booking_entry.py +61 -0
- wbaccounting/viewsets/buttons/__init__.py +3 -0
- wbaccounting/viewsets/buttons/booking_entry.py +15 -0
- wbaccounting/viewsets/buttons/entry_accounting_information.py +100 -0
- wbaccounting/viewsets/buttons/invoice.py +65 -0
- wbaccounting/viewsets/cashflows.py +124 -0
- wbaccounting/viewsets/display/__init__.py +8 -0
- wbaccounting/viewsets/display/booking_entry.py +58 -0
- wbaccounting/viewsets/display/cashflows.py +58 -0
- wbaccounting/viewsets/display/entry_accounting_information.py +91 -0
- wbaccounting/viewsets/display/invoice.py +218 -0
- wbaccounting/viewsets/display/invoice_type.py +19 -0
- wbaccounting/viewsets/display/transactions.py +35 -0
- wbaccounting/viewsets/endpoints/__init__.py +1 -0
- wbaccounting/viewsets/endpoints/invoice.py +6 -0
- wbaccounting/viewsets/entry_accounting_information.py +143 -0
- wbaccounting/viewsets/invoice.py +277 -0
- wbaccounting/viewsets/invoice_type.py +25 -0
- wbaccounting/viewsets/menu/__init__.py +6 -0
- wbaccounting/viewsets/menu/booking_entry.py +15 -0
- wbaccounting/viewsets/menu/cashflows.py +10 -0
- wbaccounting/viewsets/menu/entry_accounting_information.py +11 -0
- wbaccounting/viewsets/menu/invoice.py +15 -0
- wbaccounting/viewsets/menu/invoice_type.py +15 -0
- wbaccounting/viewsets/menu/transactions.py +15 -0
- wbaccounting/viewsets/titles/__init__.py +4 -0
- wbaccounting/viewsets/titles/booking_entry.py +12 -0
- wbaccounting/viewsets/titles/entry_accounting_information.py +12 -0
- wbaccounting/viewsets/titles/invoice.py +23 -0
- wbaccounting/viewsets/titles/invoice_type.py +12 -0
- wbaccounting/viewsets/transactions.py +34 -0
- wbaccounting-2.2.1.dist-info/METADATA +8 -0
- wbaccounting-2.2.1.dist-info/RECORD +102 -0
- wbaccounting-2.2.1.dist-info/WHEEL +5 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
from django.db import models
|
|
5
|
+
from django.db.models import Q, QuerySet
|
|
6
|
+
from django.db.models.signals import post_save
|
|
7
|
+
from django.dispatch import receiver
|
|
8
|
+
from django.utils.module_loading import import_string
|
|
9
|
+
from dynamic_preferences.registries import global_preferences_registry as gpr
|
|
10
|
+
from wbaccounting.models.model_tasks import generate_booking_entries_as_task
|
|
11
|
+
from wbcore.contrib.authentication.models import User
|
|
12
|
+
from wbcore.contrib.currency.models import Currency
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_email_body() -> str:
|
|
16
|
+
with suppress(Exception):
|
|
17
|
+
return gpr.manager()["wbaccounting__invoice_email_body"]
|
|
18
|
+
|
|
19
|
+
return ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_currency() -> Currency | None:
|
|
23
|
+
with suppress(Exception):
|
|
24
|
+
return Currency.objects.get(key=gpr.manager()["wbaccounting__default_entry_account_information_currency_key"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EntryAccountingInformationDefaultQuerySet(QuerySet):
|
|
28
|
+
def filter_for_user(self, user: User) -> QuerySet:
|
|
29
|
+
"""
|
|
30
|
+
Filters entry accounting information based on if the current user is allowed to see it.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
user (User): The user for whom entry accounting information need to be filtered.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
QuerySet: A filtered queryset.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
if user.is_superuser or user.has_perm("wbaccounting.administrate_invoice"):
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
if not user.has_perm("wbaccounting.view_entryaccountinginformation"):
|
|
43
|
+
return self.none()
|
|
44
|
+
|
|
45
|
+
return self.filter(Q(counterparty_is_private=False) | Q(exempt_users=user))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EntryAccountingInformation(models.Model):
|
|
49
|
+
# Link to Entry
|
|
50
|
+
entry = models.OneToOneField(
|
|
51
|
+
"directory.Entry",
|
|
52
|
+
on_delete=models.CASCADE,
|
|
53
|
+
related_name="entry_accounting_information",
|
|
54
|
+
verbose_name="Linked Counterparty",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Tax Information
|
|
58
|
+
tax_id = models.CharField(max_length=512, blank=True, null=True, verbose_name="Tax ID")
|
|
59
|
+
vat = models.FloatField(blank=True, null=True, verbose_name="VAT")
|
|
60
|
+
|
|
61
|
+
# Invoice Information
|
|
62
|
+
default_currency = models.ForeignKey(
|
|
63
|
+
"currency.Currency",
|
|
64
|
+
related_name="entry_accounting_informations",
|
|
65
|
+
default=default_currency,
|
|
66
|
+
on_delete=models.PROTECT,
|
|
67
|
+
verbose_name="Default Currency",
|
|
68
|
+
blank=True,
|
|
69
|
+
null=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
email_to = models.ManyToManyField(
|
|
73
|
+
"directory.EmailContact", related_name="entry_accounting_informations_to", blank=True, verbose_name="To"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
email_cc = models.ManyToManyField(
|
|
77
|
+
"directory.EmailContact", related_name="entry_accounting_informations_cc", blank=True, verbose_name="CC"
|
|
78
|
+
)
|
|
79
|
+
email_bcc = models.ManyToManyField(
|
|
80
|
+
"directory.EmailContact", related_name="entry_accounting_informations_bcc", blank=True, verbose_name="BCC"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
email_subject = models.CharField(default="{{invoice.title}}", max_length=1024, verbose_name="Subject")
|
|
84
|
+
email_body = models.TextField(default=default_email_body, verbose_name="Body")
|
|
85
|
+
send_mail = models.BooleanField(default=True, verbose_name="Send Mail")
|
|
86
|
+
|
|
87
|
+
counterparty_is_private = models.BooleanField(
|
|
88
|
+
default=False,
|
|
89
|
+
verbose_name="Counterparty Is Private",
|
|
90
|
+
help_text="Hides all of the counterparty's invoices from non-eligible users",
|
|
91
|
+
)
|
|
92
|
+
exempt_users = models.ManyToManyField(
|
|
93
|
+
to="authentication.User",
|
|
94
|
+
verbose_name="Exempt Users",
|
|
95
|
+
help_text="Exclusion list of users who are able to see private invoices for the counterparty",
|
|
96
|
+
related_name="private_accounting_information",
|
|
97
|
+
blank=True,
|
|
98
|
+
)
|
|
99
|
+
booking_entry_generator = models.CharField(max_length=256, null=True, blank=True)
|
|
100
|
+
default_invoice_type = models.ForeignKey(
|
|
101
|
+
to="wbaccounting.InvoiceType",
|
|
102
|
+
related_name="booking_entries",
|
|
103
|
+
null=True,
|
|
104
|
+
blank=True,
|
|
105
|
+
on_delete=models.SET_NULL,
|
|
106
|
+
verbose_name="Default Invoice Type",
|
|
107
|
+
help_text="When invoicinging outstanding booking entries, this invoice type will be assigned to the corresponding invoice",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
external_invoice_users = models.ManyToManyField(
|
|
111
|
+
to="authentication.User",
|
|
112
|
+
verbose_name="External User",
|
|
113
|
+
help_text="External users who are able to see the invoices generated for this counterparty",
|
|
114
|
+
related_name="external_accounting_information",
|
|
115
|
+
blank=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def get_booking_entry_generator(self):
|
|
119
|
+
with suppress(ImportError):
|
|
120
|
+
return import_string(self.booking_entry_generator or "")
|
|
121
|
+
|
|
122
|
+
objects = EntryAccountingInformationDefaultQuerySet.as_manager()
|
|
123
|
+
|
|
124
|
+
class Meta:
|
|
125
|
+
verbose_name = "Counterparty"
|
|
126
|
+
verbose_name_plural = "Counterparties"
|
|
127
|
+
|
|
128
|
+
def __str__(self):
|
|
129
|
+
return f"Counterparty: {self.entry.computed_str}"
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def get_representation_endpoint(cls):
|
|
133
|
+
return "wbaccounting:entryaccountinginformationrepresentation-list"
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def get_endpoint_basename(cls):
|
|
137
|
+
return "wbaccounting:entryaccountinginformation"
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def get_representation_value_key(cls):
|
|
141
|
+
return "id"
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def get_representation_label_key(cls):
|
|
145
|
+
return "{{entry_repr}}"
|
|
146
|
+
|
|
147
|
+
def generate_booking_entries(self, from_date: date, to_date: date):
|
|
148
|
+
generate_booking_entries_as_task.delay( # type: ignore
|
|
149
|
+
self.booking_entry_generator or "", from_date, to_date, self.entry.id
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@receiver(post_save, sender=EntryAccountingInformation)
|
|
154
|
+
def post_save_entry(sender, instance, **kwargs):
|
|
155
|
+
"""If the EAI does not have any email_to, then we add the entries primary email address to it (if it exists)"""
|
|
156
|
+
if not instance.email_to.exists() and (email := instance.entry.primary_email_contact()):
|
|
157
|
+
instance.email_to.add(email)
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from django.contrib.auth import get_user_model
|
|
7
|
+
from django.core.files import File
|
|
8
|
+
from django.db import models, transaction
|
|
9
|
+
from django.db.models import Max, Q, QuerySet, Sum
|
|
10
|
+
from django.db.models.signals import pre_save
|
|
11
|
+
from django.dispatch import receiver
|
|
12
|
+
from django.template import Context, Template
|
|
13
|
+
from django_fsm import FSMField, transition
|
|
14
|
+
from dynamic_preferences.registries import global_preferences_registry
|
|
15
|
+
from slugify import slugify
|
|
16
|
+
from wbaccounting.dynamic_preferences_registry import format_invoice_number
|
|
17
|
+
from wbaccounting.files.invoice_document_file import generate_file
|
|
18
|
+
from wbaccounting.models.booking_entry import BookingEntry, BookingEntryDefaultQuerySet
|
|
19
|
+
from wbaccounting.models.model_tasks import (
|
|
20
|
+
refresh_complete_invoice_as_task,
|
|
21
|
+
refresh_invoice_document_as_task,
|
|
22
|
+
)
|
|
23
|
+
from wbcore.contrib.authentication.models import User
|
|
24
|
+
from wbcore.contrib.directory.models import Company, Entry
|
|
25
|
+
from wbcore.contrib.documents.models import Document, DocumentType
|
|
26
|
+
from wbcore.contrib.icons import WBIcon
|
|
27
|
+
from wbcore.contrib.notifications.dispatch import send_notification
|
|
28
|
+
from wbcore.enums import RequestType
|
|
29
|
+
from wbcore.metadata.configs.buttons import ActionButton, ButtonDefaultColor
|
|
30
|
+
from wbcore.models import WBModel
|
|
31
|
+
from wbcore.utils.importlib import import_from_dotted_path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvoiceDefaultQuerySet(QuerySet):
|
|
35
|
+
def create_for_booking_entry(self, booking_entry: BookingEntry, **kwargs) -> "Invoice":
|
|
36
|
+
"""
|
|
37
|
+
Creates an Invoice instance associated with a specific BookingEntry and updates the BookingEntry
|
|
38
|
+
to link to this newly created Invoice.
|
|
39
|
+
|
|
40
|
+
This method takes a BookingEntry object as input, creates a new Invoice associated with the BookingEntry's
|
|
41
|
+
counterparty, and then links the BookingEntry to this new Invoice by updating its 'invoice' field.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
booking_entry (BookingEntry): The BookingEntry instance for which to create the invoice.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Invoice: The newly created Invoice instance associated with the given BookingEntry's counterparty.
|
|
48
|
+
"""
|
|
49
|
+
invoice = super().create(
|
|
50
|
+
counterparty=booking_entry.counterparty,
|
|
51
|
+
invoice_type=booking_entry.counterparty.entry_accounting_information.default_invoice_type,
|
|
52
|
+
**kwargs,
|
|
53
|
+
)
|
|
54
|
+
booking_entry.invoice = invoice
|
|
55
|
+
booking_entry.save()
|
|
56
|
+
return invoice
|
|
57
|
+
|
|
58
|
+
def create_for_counterparty(self, counterparty: Entry, **kwargs) -> "Invoice|None":
|
|
59
|
+
"""
|
|
60
|
+
Creates an Invoice instance for a given counterparty and associates unresolved BookingEntry objects with the
|
|
61
|
+
newly created invoice.
|
|
62
|
+
|
|
63
|
+
This method automatically finds all BookingEntry objects related to the counterparty that do not have an
|
|
64
|
+
associated invoice and are not resolved, and updates them to link to the newly created Invoice.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
counterparty (Entry): The counterparty for which to create the invoice.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Invoice | None: The newly created Invoice instance associated with the given counterparty or None if no
|
|
71
|
+
Booking Entries can be invoiced.
|
|
72
|
+
"""
|
|
73
|
+
booking_entries = BookingEntry.objects.filter(
|
|
74
|
+
counterparty=counterparty,
|
|
75
|
+
invoice__isnull=True,
|
|
76
|
+
payment_date__isnull=True,
|
|
77
|
+
)
|
|
78
|
+
if not booking_entries.exists():
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
title = kwargs.pop(
|
|
82
|
+
"title",
|
|
83
|
+
f"Invoice {counterparty.computed_str} {booking_entries.latest('reference_date').reference_date or ''}",
|
|
84
|
+
)
|
|
85
|
+
invoice_currency = kwargs.pop("invoice_currency", counterparty.entry_accounting_information.default_currency)
|
|
86
|
+
invoice_type = kwargs.pop("type", counterparty.entry_accounting_information.default_invoice_type)
|
|
87
|
+
backlinks = counterparty.entry_accounting_information.get_booking_entry_generator().merge_backlinks(
|
|
88
|
+
booking_entries
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
invoice = super().create(
|
|
92
|
+
counterparty=counterparty,
|
|
93
|
+
title=title,
|
|
94
|
+
invoice_currency=invoice_currency,
|
|
95
|
+
backlinks=backlinks,
|
|
96
|
+
invoice_type=invoice_type,
|
|
97
|
+
**kwargs,
|
|
98
|
+
)
|
|
99
|
+
booking_entries.update(invoice=invoice)
|
|
100
|
+
transaction.on_commit(lambda: refresh_complete_invoice_as_task.delay(invoice.pk)) # type: ignore
|
|
101
|
+
return invoice
|
|
102
|
+
|
|
103
|
+
def filter_for_user(self, user: User) -> QuerySet:
|
|
104
|
+
"""
|
|
105
|
+
Filters invoices based on if the current user can see the counterparty.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
user (User): The user for whom invoices need to be filtered.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
QuerySet: A filtered queryset.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
if user.is_superuser or user.has_perm("wbaccounting.administrate_invoice"):
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
if not user.has_perm("wbaccounting.view_invoice"):
|
|
118
|
+
return self.none()
|
|
119
|
+
|
|
120
|
+
return self.filter(
|
|
121
|
+
Q(counterparty__entry_accounting_information__counterparty_is_private=False)
|
|
122
|
+
| Q(counterparty__entry_accounting_information__exempt_users=user)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Invoice(WBModel):
|
|
127
|
+
class Status(models.TextChoices):
|
|
128
|
+
DRAFT = "DRAFT", "Draft"
|
|
129
|
+
SUBMITTED = "SUBMITTED", "Submitted"
|
|
130
|
+
APPROVED = "APPROVED", "Approved"
|
|
131
|
+
CANCELLED = "CANCELLED", "Cancelled"
|
|
132
|
+
SENT = "SENT", "Sent"
|
|
133
|
+
PAID = "PAID", "Paid"
|
|
134
|
+
|
|
135
|
+
class Meta:
|
|
136
|
+
verbose_name = "Invoice"
|
|
137
|
+
verbose_name_plural = "Invoices"
|
|
138
|
+
permissions = (
|
|
139
|
+
(
|
|
140
|
+
"can_generate_invoice",
|
|
141
|
+
"Can Generate Invoice",
|
|
142
|
+
),
|
|
143
|
+
("administrate_invoice", "Can administer Invoice"),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
notification_types = [
|
|
147
|
+
(
|
|
148
|
+
"wbaccounting.invoice.notify_approval",
|
|
149
|
+
"Invoice Approval Notification",
|
|
150
|
+
"Sends a notification when something happens in a relevant article.",
|
|
151
|
+
True,
|
|
152
|
+
True,
|
|
153
|
+
False,
|
|
154
|
+
),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
def __str__(self):
|
|
158
|
+
return self.title
|
|
159
|
+
|
|
160
|
+
objects = InvoiceDefaultQuerySet.as_manager()
|
|
161
|
+
|
|
162
|
+
status = FSMField(default=Status.DRAFT, choices=Status.choices, verbose_name="Status")
|
|
163
|
+
resolved = models.BooleanField(default=False, verbose_name="Resolved")
|
|
164
|
+
|
|
165
|
+
title = models.CharField(max_length=255, verbose_name="Title")
|
|
166
|
+
|
|
167
|
+
invoice_date = models.DateField(verbose_name="Invoice Date")
|
|
168
|
+
reference_date = models.DateField(verbose_name="Reference Date", null=True, blank=True)
|
|
169
|
+
payment_date = models.DateField(verbose_name="Payment Date", null=True, blank=True)
|
|
170
|
+
|
|
171
|
+
invoice_currency = models.ForeignKey(
|
|
172
|
+
"currency.Currency", related_name="invoices", on_delete=models.PROTECT, verbose_name="Curency"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
counterparty = models.ForeignKey(
|
|
176
|
+
"directory.Entry", related_name="accounting_invoices", on_delete=models.PROTECT, verbose_name="Counterparty"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
text_above = models.TextField(null=True, blank=True)
|
|
180
|
+
text_below = models.TextField(null=True, blank=True)
|
|
181
|
+
|
|
182
|
+
invoice_type = models.ForeignKey(
|
|
183
|
+
to="InvoiceType", null=True, verbose_name="Type", related_name="invoices", on_delete=models.PROTECT
|
|
184
|
+
)
|
|
185
|
+
gross_value = models.DecimalField(
|
|
186
|
+
max_digits=16, decimal_places=4, null=True, blank=True, verbose_name="Gross Value"
|
|
187
|
+
)
|
|
188
|
+
net_value = models.DecimalField(max_digits=16, decimal_places=4, null=True, blank=True, verbose_name="Net Value")
|
|
189
|
+
|
|
190
|
+
backlinks = models.JSONField(null=True, blank=True)
|
|
191
|
+
|
|
192
|
+
if TYPE_CHECKING:
|
|
193
|
+
booking_entries: BookingEntryDefaultQuerySet
|
|
194
|
+
|
|
195
|
+
def save(self, *args, **kwargs):
|
|
196
|
+
# If the invoice does not have a primary key yet, then it can't be set as a booking entries invoice
|
|
197
|
+
if self.id:
|
|
198
|
+
self.gross_value = self.booking_entries.all().aggregate(s=Sum("invoice_gross_value")).get("s", Decimal(0))
|
|
199
|
+
self.net_value = self.booking_entries.all().aggregate(s=Sum("invoice_net_value")).get("s", Decimal(0))
|
|
200
|
+
self.reference_date = self.booking_entries.all().aggregate(m=Max("reference_date")).get("m", None)
|
|
201
|
+
|
|
202
|
+
transaction.on_commit(lambda: refresh_invoice_document_as_task.delay(self.pk)) # type: ignore
|
|
203
|
+
super().save(*args, **kwargs)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def is_counterparty_invoice(self):
|
|
207
|
+
return (self.net_value or 0) < 0
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def invoice_system_key(self) -> str:
|
|
211
|
+
return f"invoice-{self.pk}"
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def invoice_document(self):
|
|
215
|
+
return Document.get_for_object(self).filter(system_created=True, system_key=self.invoice_system_key).first()
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def invoice_company(self):
|
|
219
|
+
global_preferences = global_preferences_registry.manager()
|
|
220
|
+
if invoice_company_id := global_preferences["wbaccounting__invoice_company"]:
|
|
221
|
+
return Company.objects.get(id=invoice_company_id)
|
|
222
|
+
|
|
223
|
+
@transition(
|
|
224
|
+
field=status,
|
|
225
|
+
source=[Status.DRAFT], # type: ignore
|
|
226
|
+
target=Status.SUBMITTED,
|
|
227
|
+
custom={
|
|
228
|
+
"_transition_button": ActionButton(
|
|
229
|
+
method=RequestType.PATCH,
|
|
230
|
+
color=ButtonDefaultColor.WARNING,
|
|
231
|
+
identifiers=("wbaccounting:invoice",),
|
|
232
|
+
icon=WBIcon.SEND.icon,
|
|
233
|
+
key="submit",
|
|
234
|
+
label="Submit",
|
|
235
|
+
action_label="Submitting",
|
|
236
|
+
description_fields="<p>{{title}}</p><p>After Submitting, this invoice cannot be changed anymore.</p>",
|
|
237
|
+
)
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
def submit(self, by=None, description=None, **kwargs):
|
|
241
|
+
for user in get_user_model().objects.filter(
|
|
242
|
+
Q(user_permissions__codename="administrate_invoice")
|
|
243
|
+
| Q(groups__permissions__codename="administrate_invoice")
|
|
244
|
+
):
|
|
245
|
+
send_notification(
|
|
246
|
+
code="wbaccounting.invoice.notify_approval",
|
|
247
|
+
title="An invoice needs to be approved",
|
|
248
|
+
body=f"An Invoice was submitted for approval ({self.title})",
|
|
249
|
+
user=user,
|
|
250
|
+
reverse_name="wbaccounting:invoice-detail",
|
|
251
|
+
reverse_args=[self.pk],
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
@transition(
|
|
255
|
+
field=status,
|
|
256
|
+
source=[Status.SUBMITTED, Status.DRAFT], # type: ignore
|
|
257
|
+
target=Status.CANCELLED,
|
|
258
|
+
custom={
|
|
259
|
+
"_transition_button": ActionButton(
|
|
260
|
+
method=RequestType.PATCH,
|
|
261
|
+
color=ButtonDefaultColor.WARNING,
|
|
262
|
+
identifiers=("wbaccounting:invoice",),
|
|
263
|
+
icon=WBIcon.REJECT.icon,
|
|
264
|
+
key="cancel",
|
|
265
|
+
label="Cancel",
|
|
266
|
+
action_label="Cancellation",
|
|
267
|
+
description_fields="<p>{{title}}</p><p>After cancelling, this invoice cannot be used anymore.</p>",
|
|
268
|
+
)
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
def cancel(self, by=None, description=None, **kwargs):
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
@transition(
|
|
275
|
+
field=status,
|
|
276
|
+
source=[Status.SUBMITTED], # type: ignore
|
|
277
|
+
target=Status.APPROVED,
|
|
278
|
+
permission=lambda instance, user: user.has_perm("wbaccounting.administrate_invoice"),
|
|
279
|
+
custom={
|
|
280
|
+
"_transition_button": ActionButton(
|
|
281
|
+
method=RequestType.PATCH,
|
|
282
|
+
color=ButtonDefaultColor.WARNING,
|
|
283
|
+
identifiers=("wbaccounting:invoice",),
|
|
284
|
+
icon=WBIcon.APPROVE.icon,
|
|
285
|
+
key="approve",
|
|
286
|
+
label="Approve",
|
|
287
|
+
action_label="Approval",
|
|
288
|
+
description_fields="<p>Are you sure you want to approve this invoice?</p>",
|
|
289
|
+
)
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
def approve(self, by=None, description=None, **kwargs):
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
@transition(
|
|
296
|
+
field=status,
|
|
297
|
+
source=[Status.SUBMITTED], # type: ignore
|
|
298
|
+
target=Status.DRAFT,
|
|
299
|
+
permission=lambda instance, user: user.has_perm("wbaccounting.administrate_invoice"),
|
|
300
|
+
custom={
|
|
301
|
+
"_transition_button": ActionButton(
|
|
302
|
+
method=RequestType.PATCH,
|
|
303
|
+
color=ButtonDefaultColor.WARNING,
|
|
304
|
+
identifiers=("wbaccounting:invoice",),
|
|
305
|
+
icon=WBIcon.DENY.icon,
|
|
306
|
+
key="deny",
|
|
307
|
+
label="Deny",
|
|
308
|
+
action_label="Denial",
|
|
309
|
+
description_fields="<p>Are you sure you want to deny this invoice?</p>",
|
|
310
|
+
)
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
def deny(self, by=None, description=None, **kwargs):
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
@transition(
|
|
317
|
+
field=status,
|
|
318
|
+
source=[Status.APPROVED], # type: ignore
|
|
319
|
+
target=Status.SENT,
|
|
320
|
+
custom={
|
|
321
|
+
"_transition_button": ActionButton(
|
|
322
|
+
method=RequestType.PATCH,
|
|
323
|
+
color=ButtonDefaultColor.SUCCESS,
|
|
324
|
+
identifiers=("wbaccounting:invoice",),
|
|
325
|
+
icon=WBIcon.GENERATE_NEXT.icon,
|
|
326
|
+
key="send",
|
|
327
|
+
label="Send",
|
|
328
|
+
action_label="Sending",
|
|
329
|
+
description_fields="<p>{{title}}</p>",
|
|
330
|
+
)
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
def send(self, by=None, description=None, **kwargs):
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
def can_send(self):
|
|
337
|
+
errors = dict()
|
|
338
|
+
if not self.invoice_document:
|
|
339
|
+
errors["status"] = ["An invoice needs to be generated first."]
|
|
340
|
+
return errors
|
|
341
|
+
|
|
342
|
+
@transition(
|
|
343
|
+
field=status,
|
|
344
|
+
source=[Status.SENT], # type: ignore
|
|
345
|
+
target=Status.PAID,
|
|
346
|
+
custom={
|
|
347
|
+
"_transition_button": ActionButton(
|
|
348
|
+
method=RequestType.PATCH,
|
|
349
|
+
color=ButtonDefaultColor.SUCCESS,
|
|
350
|
+
identifiers=("wbaccounting:invoice",),
|
|
351
|
+
icon=WBIcon.DEAL_MONEY.icon,
|
|
352
|
+
key="pay",
|
|
353
|
+
label="Pay",
|
|
354
|
+
action_label="Payment",
|
|
355
|
+
description_fields="<p>{{title}}</p>",
|
|
356
|
+
)
|
|
357
|
+
},
|
|
358
|
+
)
|
|
359
|
+
def pay(self, by=None, description=None, **kwargs):
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
def send_invoice_to_recipients(self):
|
|
363
|
+
if invoice_document := self.invoice_document:
|
|
364
|
+
entry_accounting_information = self.counterparty.entry_accounting_information
|
|
365
|
+
if entry_accounting_information.send_mail:
|
|
366
|
+
global_preferences = global_preferences_registry.manager()
|
|
367
|
+
from_email = global_preferences["wbaccounting__default_from_email_address"]
|
|
368
|
+
context = {"invoice": self.get_context(), "entry": self.counterparty}
|
|
369
|
+
|
|
370
|
+
rendered_subject = Template(entry_accounting_information.email_subject).render(Context(context))
|
|
371
|
+
rendered_body = Template(entry_accounting_information.email_body).render(Context(context))
|
|
372
|
+
|
|
373
|
+
to_emails = list(entry_accounting_information.email_to.values_list("address", flat=True))
|
|
374
|
+
cc_emails = list(entry_accounting_information.email_cc.values_list("address", flat=True))
|
|
375
|
+
bcc_emails = list(entry_accounting_information.email_bcc.values_list("address", flat=True))
|
|
376
|
+
|
|
377
|
+
invoice_document.send_email(
|
|
378
|
+
to_emails,
|
|
379
|
+
as_link=False,
|
|
380
|
+
subject=rendered_subject,
|
|
381
|
+
from_email=from_email,
|
|
382
|
+
body=rendered_body,
|
|
383
|
+
cc_emails=cc_emails,
|
|
384
|
+
bcc_emails=bcc_emails,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Other methods
|
|
388
|
+
def refresh_invoice_document(self, override_status=False):
|
|
389
|
+
if self.status == self.Status.DRAFT or override_status:
|
|
390
|
+
invoice = generate_file(self)
|
|
391
|
+
file_name = f"{slugify(self.title)}.pdf"
|
|
392
|
+
document_file = BytesIO(invoice)
|
|
393
|
+
document_type, created = DocumentType.objects.get_or_create(name="invoice")
|
|
394
|
+
document, created = Document.objects.update_or_create(
|
|
395
|
+
document_type=document_type,
|
|
396
|
+
system_created=True,
|
|
397
|
+
system_key=self.invoice_system_key,
|
|
398
|
+
defaults={
|
|
399
|
+
"name": f"Invoice: {self.title}",
|
|
400
|
+
"description": f"Invoice for {self.counterparty}.",
|
|
401
|
+
"file": File(document_file, file_name),
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
document.link(self)
|
|
405
|
+
|
|
406
|
+
def get_permissions_for_user_and_document(self, user, document, created=None) -> list[tuple[str, bool]]:
|
|
407
|
+
# if the document is the invoice document, we want to assign certain permissions
|
|
408
|
+
if document.system_created and self.invoice_system_key == document.system_key:
|
|
409
|
+
if user.is_superuser:
|
|
410
|
+
return []
|
|
411
|
+
|
|
412
|
+
entry_info = self.counterparty.entry_accounting_information
|
|
413
|
+
has_view_permission = user.has_perm("wbaccounting.view_invoice")
|
|
414
|
+
|
|
415
|
+
if entry_info.external_invoice_users.filter(id=user.id).exists():
|
|
416
|
+
return [("documents.view_document", False)]
|
|
417
|
+
|
|
418
|
+
is_access_allowed = has_view_permission and (
|
|
419
|
+
not entry_info.counterparty_is_private or user in entry_info.exempt_users.all()
|
|
420
|
+
)
|
|
421
|
+
has_admin_permission = user.has_perm("wbaccounting.administrate_invoice")
|
|
422
|
+
|
|
423
|
+
if is_access_allowed or has_admin_permission:
|
|
424
|
+
return [("documents.view_document", False)]
|
|
425
|
+
return []
|
|
426
|
+
|
|
427
|
+
def get_context(self) -> dict:
|
|
428
|
+
return {
|
|
429
|
+
"title": self.title,
|
|
430
|
+
"counterparty": self.counterparty.computed_str,
|
|
431
|
+
"total_net_value": format_invoice_number(self.net_value),
|
|
432
|
+
"total_gross_value": format_invoice_number(self.gross_value),
|
|
433
|
+
"text_above": self.text_above,
|
|
434
|
+
"text_below": self.text_below,
|
|
435
|
+
"invoice_date": self.invoice_date.strftime("%d.%m.%Y"),
|
|
436
|
+
"currency": self.invoice_currency,
|
|
437
|
+
"invoice": self,
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
@classmethod
|
|
441
|
+
def get_endpoint_basename(cls):
|
|
442
|
+
return "wbaccounting:invoice"
|
|
443
|
+
|
|
444
|
+
@classmethod
|
|
445
|
+
def get_representation_endpoint(cls):
|
|
446
|
+
return "wbaccounting:invoicerepresentation-list"
|
|
447
|
+
|
|
448
|
+
@classmethod
|
|
449
|
+
def get_representation_value_key(cls):
|
|
450
|
+
return "id"
|
|
451
|
+
|
|
452
|
+
@classmethod
|
|
453
|
+
def get_representation_label_key(cls):
|
|
454
|
+
return "{{title}} ({{invoice_date}})"
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@receiver(pre_save, sender=Invoice)
|
|
458
|
+
def post_save_handle_processor(sender, instance, **kwargs):
|
|
459
|
+
if (
|
|
460
|
+
instance.status == Invoice.Status.APPROVED
|
|
461
|
+
and instance.invoice_type
|
|
462
|
+
and (processor_path := instance.invoice_type.processor)
|
|
463
|
+
):
|
|
464
|
+
with suppress(ModuleNotFoundError):
|
|
465
|
+
processor = import_from_dotted_path(processor_path)
|
|
466
|
+
processor(instance)
|
|
467
|
+
instance.status = instance.Status.SENT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from wbcore.models import WBModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class InvoiceType(WBModel):
|
|
6
|
+
name = models.CharField(max_length=100, unique=True, verbose_name="Name")
|
|
7
|
+
processor = models.CharField(max_length=128, null=True, blank=True, verbose_name="Processor")
|
|
8
|
+
|
|
9
|
+
def __str__(self) -> str:
|
|
10
|
+
return self.name
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def get_endpoint_basename(cls) -> str:
|
|
14
|
+
return "wbaccounting:invoicetype"
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def get_representation_value_key(cls) -> str:
|
|
18
|
+
return "id"
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def get_representation_endpoint(cls) -> str:
|
|
22
|
+
return "wbaccounting:invoicetyperepresentation-list"
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def get_representation_label_key(cls) -> str:
|
|
26
|
+
return "{{name}}"
|
|
27
|
+
|
|
28
|
+
class Meta: # type: ignore
|
|
29
|
+
verbose_name = "Invoice Type"
|
|
30
|
+
verbose_name_plural = "Invoice Types"
|