Coverage for website/moneybirdsynchronization/models.py: 45.85%
213 statements
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1import datetime
3from django.conf import settings
4from django.contrib.contenttypes.fields import GenericForeignKey
5from django.contrib.contenttypes.models import ContentType
6from django.db import models
7from django.urls import reverse
8from django.utils import timezone
9from django.utils.translation import gettext_lazy as _
11from events.models import EventRegistration
12from members.models import Member
13from moneybirdsynchronization.moneybird import get_moneybird_api_service
14from payments.models import BankAccount, Payment
15from payments.payables import payables
16from pizzas.models import FoodOrder
17from registrations.models import Registration, Renewal
18from reimbursements.models import Reimbursement
19from sales.models.order import Order
22def financial_account_id_for_payment_type(payment_type) -> int | None:
23 if payment_type == Payment.CARD: 23 ↛ 24line 23 didn't jump to line 24 because the condition on line 23 was never true
24 return settings.MONEYBIRD_CARD_FINANCIAL_ACCOUNT_ID
25 if payment_type == Payment.CASH:
26 return settings.MONEYBIRD_CASH_FINANCIAL_ACCOUNT_ID
27 if payment_type == Payment.TPAY: 27 ↛ 29line 27 didn't jump to line 29 because the condition on line 27 was always true
28 return settings.MONEYBIRD_TPAY_FINANCIAL_ACCOUNT_ID
29 return None
32def project_name_for_payable_model(obj) -> str | None:
33 if isinstance(obj, EventRegistration):
34 start_date = obj.event.start.strftime("%Y-%m-%d")
35 return f"{obj.event.title} [{start_date}]"
36 if isinstance(obj, FoodOrder):
37 start_date = obj.food_event.event.start.strftime("%Y-%m-%d")
38 return f"{obj.food_event.event.title} [{start_date}]"
39 if isinstance(obj, Order):
40 start_date = obj.shift.start.strftime("%Y-%m-%d")
41 return f"{obj.shift} [{start_date}]"
42 if isinstance(obj, Registration | Renewal):
43 return None
45 raise ValueError(f"Unknown payable model {obj}")
48def date_for_payable_model(obj) -> datetime.datetime | datetime.date:
49 if isinstance(obj, EventRegistration):
50 return obj.date.date()
51 if isinstance(obj, FoodOrder):
52 return obj.food_event.event.start
53 if isinstance(obj, Order):
54 return obj.shift.start
55 if isinstance(obj, Registration | Renewal):
56 # Registrations and Renewals are only pushed to Moneybird
57 # once they've been paid,, so a obj.payment always exists.
58 return obj.payment.created_at.date()
60 raise ValueError(f"Unknown payable model {obj}")
63def period_for_payable_model(obj) -> str | None:
64 if isinstance(obj, Registration | Renewal):
65 if obj.payment is not None:
66 date = obj.payment.created_at.date()
67 # Use the payment date, when the new membership normally starts,
68 # and don't bother with the 'until' date.
69 return f"{date.strftime('%Y%m%d')}..{date.strftime('%Y%m%d')}"
70 return None
73def tax_rate_for_payable_model(obj) -> int | None:
74 if isinstance(obj, Registration | Renewal | FoodOrder):
75 return settings.MONEYBIRD_ZERO_TAX_RATE_ID
76 return None
79def ledger_id_for_payable_model(obj) -> int | None:
80 if isinstance(obj, Registration | Renewal):
81 return settings.MONEYBIRD_CONTRIBUTION_LEDGER_ID
82 return None
85class MoneybirdProject(models.Model):
86 name = models.CharField(
87 _("Name"),
88 max_length=255,
89 blank=False,
90 null=False,
91 unique=True,
92 db_index=True,
93 )
95 moneybird_id = models.CharField(
96 _("Moneybird ID"),
97 max_length=255,
98 blank=True,
99 null=True,
100 )
102 class Meta:
103 verbose_name = _("moneybird project")
104 verbose_name_plural = _("moneybird projects")
106 def __str__(self):
107 return f"Moneybird project {self.name}"
109 def to_moneybird(self):
110 return {
111 "project": {
112 "name": self.name,
113 }
114 }
117class MoneybirdContact(models.Model):
118 member = models.OneToOneField(
119 Member,
120 on_delete=models.CASCADE,
121 verbose_name=_("member"),
122 related_name="moneybird_contact",
123 null=True,
124 blank=True,
125 )
126 moneybird_id = models.CharField(
127 _("Moneybird ID"),
128 max_length=255,
129 blank=True,
130 null=True,
131 )
133 moneybird_sepa_mandate_id = models.CharField(
134 _("Moneybird SEPA mandate ID"),
135 max_length=255,
136 blank=True,
137 null=True,
138 unique=True,
139 )
141 needs_synchronization = models.BooleanField(
142 default=True, # The field is set False only when it has been successfully synchronized.
143 help_text="Indicates that the contact has to be synchronized (again).",
144 )
146 def to_moneybird(self):
147 if self.member.profile is None: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true
148 return None
149 data = {
150 "contact": {
151 "firstname": self.member.first_name,
152 "lastname": self.member.last_name,
153 "address1": self.member.profile.address_street,
154 "address2": self.member.profile.address_street2,
155 "zipcode": self.member.profile.address_postal_code,
156 "city": self.member.profile.address_city,
157 "country": self.member.profile.address_country,
158 "send_invoices_to_email": self.member.email,
159 }
160 }
161 bank_account = BankAccount.objects.filter(owner=self.member).last()
162 if bank_account: 162 ↛ 178line 162 didn't jump to line 178 because the condition on line 162 was always true
163 data["contact"]["sepa_iban"] = bank_account.iban
164 data["contact"]["sepa_bic"] = bank_account.bic or ""
165 data["contact"]["sepa_iban_account_name"] = (
166 f"{bank_account.initials} {bank_account.last_name}"
167 )
168 if bank_account.valid and bank_account.valid_from < timezone.now().date():
169 data["contact"]["sepa_active"] = True
170 data["contact"]["sepa_mandate_id"] = bank_account.mandate_no
171 data["contact"]["sepa_mandate_date"] = bank_account.valid_from.strftime(
172 "%Y-%m-%d"
173 )
174 data["contact"]["sepa_sequence_type"] = "RCUR"
175 else:
176 data["contact"]["sepa_active"] = False
177 else:
178 data["contact"]["sepa_iban_account_name"] = ""
179 data["contact"]["sepa_iban"] = ""
180 data["contact"]["sepa_bic"] = ""
181 data["contact"]["sepa_active"] = False
182 if self.moneybird_id is not None:
183 data["id"] = self.moneybird_id
184 if settings.MONEYBIRD_MEMBER_PK_CUSTOM_FIELD_ID: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 data["contact"]["custom_fields_attributes"] = {}
186 data["contact"]["custom_fields_attributes"]["0"] = {
187 "id": settings.MONEYBIRD_MEMBER_PK_CUSTOM_FIELD_ID,
188 "value": self.member.pk,
189 }
190 return data
192 def get_moneybird_info(self):
193 return {
194 "id": self.moneybird_id,
195 "pk": self.member.pk,
196 }
198 def __str__(self):
199 return f"Moneybird contact for {self.member}"
201 class Meta:
202 verbose_name = _("moneybird contact")
203 verbose_name_plural = _("moneybird contacts")
206class MoneybirdExternalInvoice(models.Model):
207 payable_model = models.ForeignKey(ContentType, on_delete=models.CASCADE)
208 object_id = models.CharField(max_length=255)
209 payable_object = GenericForeignKey("payable_model", "object_id")
211 moneybird_invoice_id = models.CharField(
212 verbose_name=_("moneybird invoice id"),
213 max_length=255,
214 blank=True,
215 null=True,
216 )
218 moneybird_details_attribute_id = models.CharField(
219 verbose_name=_("moneybird details attribute id"),
220 max_length=255,
221 blank=True,
222 null=True,
223 ) # We need this id, so we can update the rows (otherwise, updates will create new rows without deleting).
224 # We only support one attribute for now, so this is the easiest way to store it
226 needs_synchronization = models.BooleanField(
227 default=True, # The field is set False only when it has been successfully synchronized.
228 help_text="Indicates that the invoice has to be synchronized (again).",
229 )
231 needs_deletion = models.BooleanField(
232 default=False,
233 help_text="Indicates that the invoice has to be deleted from moneybird.",
234 )
236 @property
237 def payable(self):
238 payable = payables.get_payable(self.payable_object)
239 if payable is None:
240 raise ValueError(f"Could not find payable for {self.payable_object}")
241 return payable
243 @classmethod
244 def create_for_object(cls, obj):
245 content_type = ContentType.objects.get_for_model(obj)
246 return cls.objects.create(
247 payable_model=content_type,
248 object_id=obj.pk,
249 )
251 @classmethod
252 def get_for_object(cls, obj):
253 content_type = ContentType.objects.get_for_model(obj)
254 try:
255 return cls.objects.get(
256 payable_model=content_type,
257 object_id=obj.pk,
258 )
259 except cls.DoesNotExist:
260 return None
262 def to_moneybird(self):
263 moneybird = get_moneybird_api_service()
265 contact_id = settings.MONEYBIRD_UNKNOWN_PAYER_CONTACT_ID
267 if self.payable.payment_payer is not None:
268 try:
269 moneybird_contact = MoneybirdContact.objects.get(
270 member=self.payable.payment_payer
271 )
272 contact_id = moneybird_contact.moneybird_id
273 except MoneybirdContact.DoesNotExist:
274 pass
276 invoice_date = date_for_payable_model(self.payable_object).strftime("%Y-%m-%d")
278 period = period_for_payable_model(self.payable_object)
280 tax_rate_id = tax_rate_for_payable_model(self.payable_object)
282 project_name = project_name_for_payable_model(self.payable_object)
284 project_id = None
285 if project_name is not None:
286 project, __ = MoneybirdProject.objects.get_or_create(name=project_name)
287 if project.moneybird_id is None:
288 response = moneybird.create_project(project.to_moneybird())
289 project.moneybird_id = response["id"]
290 project.save()
292 project_id = project.moneybird_id
294 ledger_id = ledger_id_for_payable_model(self.payable_object)
296 source_url = settings.BASE_URL + reverse(
297 f"admin:{self.payable_object._meta.app_label}_{self.payable_object._meta.model_name}_change",
298 args=(self.object_id,),
299 )
301 data = {
302 "external_sales_invoice": {
303 "contact_id": int(contact_id),
304 "reference": f"{self.payable.payment_topic} [{self.payable.model.pk}]",
305 "source": f"Concrexit ({settings.SITE_DOMAIN})",
306 "date": invoice_date,
307 "currency": "EUR",
308 "prices_are_incl_tax": True,
309 "details_attributes": [
310 {
311 "description": self.payable.payment_notes,
312 "price": str(self.payable.payment_amount),
313 },
314 ],
315 }
316 }
318 if source_url is not None:
319 data["external_sales_invoice"]["source_url"] = source_url
320 if project_id is not None:
321 data["external_sales_invoice"]["details_attributes"][0]["project_id"] = int(
322 project_id
323 )
324 if ledger_id is not None:
325 data["external_sales_invoice"]["details_attributes"][0]["ledger_id"] = int(
326 ledger_id
327 )
329 if self.moneybird_details_attribute_id is not None:
330 data["external_sales_invoice"]["details_attributes"][0]["id"] = int(
331 self.moneybird_details_attribute_id
332 )
333 if period is not None:
334 data["external_sales_invoice"]["details_attributes"][0]["period"] = period
335 if tax_rate_id is not None:
336 data["external_sales_invoice"]["details_attributes"][0]["tax_rate_id"] = (
337 int(tax_rate_id)
338 )
340 return data
342 def __str__(self):
343 return f"Moneybird external invoice for {self.payable_object}"
345 class Meta:
346 verbose_name = _("moneybird external invoice")
347 verbose_name_plural = _("moneybird external invoices")
348 unique_together = ("payable_model", "object_id")
351class MoneybirdReceipt(models.Model):
352 reimbursement = models.OneToOneField(
353 Reimbursement,
354 on_delete=models.CASCADE,
355 related_name="moneybird_receipt",
356 )
358 moneybird_receipt_id = models.CharField(
359 verbose_name=_("moneybird receipt id"),
360 max_length=255,
361 blank=True,
362 null=True,
363 )
365 moneybird_attachment_is_uploaded = models.BooleanField(
366 default=False,
367 )
369 def to_moneybird(self):
370 contact_id = settings.MONEYBIRD_UNKNOWN_PAYER_CONTACT_ID
372 if self.reimbursement.owner is not None:
373 try:
374 moneybird_contact = MoneybirdContact.objects.get(
375 member=self.reimbursement.owner
376 )
377 contact_id = moneybird_contact.moneybird_id
378 except MoneybirdContact.DoesNotExist:
379 pass
381 receipt_date = self.reimbursement.date_incurred.strftime("%Y-%m-%d")
383 source_url = settings.BASE_URL + reverse(
384 f"admin:{self.reimbursement._meta.app_label}_{self.reimbursement._meta.model_name}_change",
385 args=(self.reimbursement.pk,),
386 )
388 data = {
389 "receipt": {
390 "contact_id": int(contact_id),
391 "reference": f"Receipt [{self.reimbursement.pk}]",
392 "source": f"Concrexit ({settings.SITE_DOMAIN})",
393 "date": receipt_date,
394 "currency": "EUR",
395 "prices_are_incl_tax": True,
396 "details_attributes": [
397 {
398 "description": self.reimbursement.description
399 + f"\n\nConcrexit: {source_url}",
400 "price": str(self.reimbursement.amount),
401 "ledger_account_id": settings.MONEYBIRD_UNCATEGORIZEDEXPENSES_LEDGER_ID,
402 "tax_rate_id": settings.MONEYBIRD_REIMBURSEMENT_DEFAULT_TAX_RATE_ID,
403 },
404 ],
405 }
406 }
408 return data
410 def __str__(self):
411 return f"Moneybird receipt for {self.reimbursement}"
413 class Meta:
414 verbose_name = _("moneybird receipt")
415 verbose_name_plural = _("moneybird receipt")
418class MoneybirdPayment(models.Model):
419 payment = models.OneToOneField(
420 "payments.Payment",
421 on_delete=models.CASCADE,
422 verbose_name=_("payment"),
423 related_name="moneybird_payment",
424 )
426 moneybird_financial_statement_id = models.CharField(
427 verbose_name=_("moneybird financial statement id"), max_length=255
428 )
430 moneybird_financial_mutation_id = models.CharField(
431 verbose_name=_("moneybird financial mutation id"), max_length=255
432 )
434 def __str__(self):
435 return f"Moneybird payment for {self.payment}"
437 def to_moneybird(self):
438 data = {
439 "date": self.payment.created_at.strftime("%Y-%m-%d"),
440 "message": f"{self.payment.pk}; {self.payment.type} by {self.payment.paid_by or '?'}; {self.payment.notes}; processed by {self.payment.processed_by or '?'} at {self.payment.created_at:%Y-%m-%d %H:%M:%S}.",
441 "sepa_fields": {
442 "trtp": f"Concrexit - {self.payment.get_type_display()}",
443 "name": self.payment.paid_by.get_full_name()
444 if self.payment.paid_by
445 else "",
446 "remi": self.payment.notes,
447 "eref": f"{self.payment.pk} {self.payment.created_at.astimezone():%Y-%m-%d %H:%M:%S}",
448 "pref": self.payment.topic,
449 "marf": f"Processed by {self.payment.processed_by.get_full_name()}"
450 if self.payment.processed_by
451 else "",
452 },
453 "amount": str(self.payment.amount),
454 "contra_account_name": self.payment.paid_by.get_full_name()
455 if self.payment.paid_by
456 else "",
457 "batch_reference": str(self.payment.pk),
458 }
459 if self.moneybird_financial_mutation_id: 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true
460 data["financial_mutation_id"] = int(self.moneybird_financial_mutation_id)
461 data["financial_account_id"] = financial_account_id_for_payment_type(
462 self.payment.type
463 )
465 return data
467 class Meta:
468 verbose_name = _("moneybird payment")
469 verbose_name_plural = _("moneybird payments")