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.
Files changed (102) hide show
  1. wbaccounting/__init__.py +1 -0
  2. wbaccounting/admin/__init__.py +5 -0
  3. wbaccounting/admin/booking_entry.py +53 -0
  4. wbaccounting/admin/entry_accounting_information.py +10 -0
  5. wbaccounting/admin/invoice.py +26 -0
  6. wbaccounting/admin/invoice_type.py +8 -0
  7. wbaccounting/admin/transactions.py +16 -0
  8. wbaccounting/apps.py +5 -0
  9. wbaccounting/dynamic_preferences_registry.py +107 -0
  10. wbaccounting/factories/__init__.py +10 -0
  11. wbaccounting/factories/booking_entry.py +21 -0
  12. wbaccounting/factories/entry_accounting_information.py +46 -0
  13. wbaccounting/factories/invoice.py +43 -0
  14. wbaccounting/factories/transactions.py +32 -0
  15. wbaccounting/files/__init__.py +0 -0
  16. wbaccounting/files/invoice_document_file.py +134 -0
  17. wbaccounting/files/utils.py +331 -0
  18. wbaccounting/generators/__init__.py +6 -0
  19. wbaccounting/generators/base.py +120 -0
  20. wbaccounting/io/handlers/__init__.py +0 -0
  21. wbaccounting/io/handlers/transactions.py +32 -0
  22. wbaccounting/io/parsers/__init__.py +0 -0
  23. wbaccounting/io/parsers/societe_generale_lux.py +49 -0
  24. wbaccounting/io/parsers/societe_generale_lux_prenotification.py +60 -0
  25. wbaccounting/migrations/0001_initial_squashed_squashed_0005_alter_bookingentry_counterparty_and_more.py +284 -0
  26. wbaccounting/migrations/0006_alter_invoice_status.py +30 -0
  27. wbaccounting/migrations/0007_alter_invoice_options.py +23 -0
  28. wbaccounting/migrations/0008_alter_invoice_options.py +20 -0
  29. wbaccounting/migrations/0009_invoicetype_alter_bookingentry_options_and_more.py +366 -0
  30. wbaccounting/migrations/0010_alter_bookingentry_options.py +20 -0
  31. wbaccounting/migrations/0011_transaction.py +103 -0
  32. wbaccounting/migrations/0012_entryaccountinginformation_external_invoice_users.py +25 -0
  33. wbaccounting/migrations/__init__.py +0 -0
  34. wbaccounting/models/__init__.py +6 -0
  35. wbaccounting/models/booking_entry.py +167 -0
  36. wbaccounting/models/entry_accounting_information.py +157 -0
  37. wbaccounting/models/invoice.py +467 -0
  38. wbaccounting/models/invoice_type.py +30 -0
  39. wbaccounting/models/model_tasks.py +71 -0
  40. wbaccounting/models/transactions.py +112 -0
  41. wbaccounting/permissions.py +6 -0
  42. wbaccounting/processors/__init__.py +0 -0
  43. wbaccounting/processors/dummy_processor.py +5 -0
  44. wbaccounting/serializers/__init__.py +12 -0
  45. wbaccounting/serializers/booking_entry.py +78 -0
  46. wbaccounting/serializers/consolidated_invoice.py +109 -0
  47. wbaccounting/serializers/entry_accounting_information.py +149 -0
  48. wbaccounting/serializers/invoice.py +95 -0
  49. wbaccounting/serializers/invoice_type.py +16 -0
  50. wbaccounting/serializers/transactions.py +50 -0
  51. wbaccounting/tests/__init__.py +0 -0
  52. wbaccounting/tests/conftest.py +65 -0
  53. wbaccounting/tests/test_displays/__init__.py +0 -0
  54. wbaccounting/tests/test_displays/test_booking_entries.py +1 -0
  55. wbaccounting/tests/test_models/__init__.py +0 -0
  56. wbaccounting/tests/test_models/test_booking_entries.py +119 -0
  57. wbaccounting/tests/test_models/test_entry_accounting_information.py +81 -0
  58. wbaccounting/tests/test_models/test_invoice_types.py +21 -0
  59. wbaccounting/tests/test_models/test_invoices.py +73 -0
  60. wbaccounting/tests/test_models/test_transactions.py +40 -0
  61. wbaccounting/tests/test_processors.py +28 -0
  62. wbaccounting/tests/test_serializers/__init__.py +0 -0
  63. wbaccounting/tests/test_serializers/test_booking_entries.py +69 -0
  64. wbaccounting/tests/test_serializers/test_entry_accounting_information.py +64 -0
  65. wbaccounting/tests/test_serializers/test_invoice_types.py +35 -0
  66. wbaccounting/tests/test_serializers/test_transactions.py +72 -0
  67. wbaccounting/urls.py +68 -0
  68. wbaccounting/viewsets/__init__.py +12 -0
  69. wbaccounting/viewsets/booking_entry.py +61 -0
  70. wbaccounting/viewsets/buttons/__init__.py +3 -0
  71. wbaccounting/viewsets/buttons/booking_entry.py +15 -0
  72. wbaccounting/viewsets/buttons/entry_accounting_information.py +100 -0
  73. wbaccounting/viewsets/buttons/invoice.py +65 -0
  74. wbaccounting/viewsets/cashflows.py +124 -0
  75. wbaccounting/viewsets/display/__init__.py +8 -0
  76. wbaccounting/viewsets/display/booking_entry.py +58 -0
  77. wbaccounting/viewsets/display/cashflows.py +58 -0
  78. wbaccounting/viewsets/display/entry_accounting_information.py +91 -0
  79. wbaccounting/viewsets/display/invoice.py +218 -0
  80. wbaccounting/viewsets/display/invoice_type.py +19 -0
  81. wbaccounting/viewsets/display/transactions.py +35 -0
  82. wbaccounting/viewsets/endpoints/__init__.py +1 -0
  83. wbaccounting/viewsets/endpoints/invoice.py +6 -0
  84. wbaccounting/viewsets/entry_accounting_information.py +143 -0
  85. wbaccounting/viewsets/invoice.py +277 -0
  86. wbaccounting/viewsets/invoice_type.py +25 -0
  87. wbaccounting/viewsets/menu/__init__.py +6 -0
  88. wbaccounting/viewsets/menu/booking_entry.py +15 -0
  89. wbaccounting/viewsets/menu/cashflows.py +10 -0
  90. wbaccounting/viewsets/menu/entry_accounting_information.py +11 -0
  91. wbaccounting/viewsets/menu/invoice.py +15 -0
  92. wbaccounting/viewsets/menu/invoice_type.py +15 -0
  93. wbaccounting/viewsets/menu/transactions.py +15 -0
  94. wbaccounting/viewsets/titles/__init__.py +4 -0
  95. wbaccounting/viewsets/titles/booking_entry.py +12 -0
  96. wbaccounting/viewsets/titles/entry_accounting_information.py +12 -0
  97. wbaccounting/viewsets/titles/invoice.py +23 -0
  98. wbaccounting/viewsets/titles/invoice_type.py +12 -0
  99. wbaccounting/viewsets/transactions.py +34 -0
  100. wbaccounting-2.2.1.dist-info/METADATA +8 -0
  101. wbaccounting-2.2.1.dist-info/RECORD +102 -0
  102. 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"