Coverage for website/moneybirdsynchronization/services.py: 45.87%
275 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 logging
3from django.conf import settings
4from django.contrib.admin.utils import model_ngettext
5from django.contrib.contenttypes.models import ContentType
6from django.core.exceptions import ObjectDoesNotExist
7from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery
8from django.db.models.functions import Cast
9from django.utils import timezone
11from events.models import EventRegistration
12from members.models import Member
13from moneybirdsynchronization.administration import Administration
14from moneybirdsynchronization.emails import send_sync_error
15from moneybirdsynchronization.models import (
16 MoneybirdContact,
17 MoneybirdExternalInvoice,
18 MoneybirdPayment,
19 MoneybirdReceipt,
20 financial_account_id_for_payment_type,
21)
22from moneybirdsynchronization.moneybird import get_moneybird_api_service
23from payments.models import BankAccount, Payment
24from pizzas.models import FoodOrder
25from registrations.models import Registration, Renewal
26from reimbursements.models import Reimbursement
27from sales.models.order import Order
29logger = logging.getLogger(__name__)
32def create_or_update_contact(member: Member):
33 """Push a Django user/member to Moneybird."""
34 if not settings.MONEYBIRD_SYNC_ENABLED: 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true
35 return None
37 moneybird_contact, _ = MoneybirdContact.objects.get_or_create(member=member)
39 moneybird = get_moneybird_api_service()
41 if moneybird_contact.moneybird_id is None:
42 # Push contact to moneybird. This may fail with 422 when moneybird rejects an
43 # email address. In that case, we try once more leaving out the email address,
44 # as moneybird does not require that we set one at all.
45 try:
46 response = moneybird.create_contact(moneybird_contact.to_moneybird())
47 except Administration.InvalidData:
48 logger.info("Retrying to create contact without email...")
49 contact = moneybird_contact.to_moneybird()
50 del contact["contact"]["send_invoices_to_email"]
51 response = moneybird.create_contact(contact)
53 moneybird_contact.moneybird_id = response["id"]
54 else:
55 # Update the contact data (right now we always do this, but we could use the version to check if it's needed).
56 try:
57 response = moneybird.update_contact(
58 moneybird_contact.moneybird_id, moneybird_contact.to_moneybird()
59 )
60 except Administration.InvalidData:
61 logger.info("Retrying to update contact without email...")
62 contact = moneybird_contact.to_moneybird()
63 del contact["contact"]["send_invoices_to_email"]
64 response = moneybird.update_contact(moneybird_contact.moneybird_id, contact)
66 moneybird_contact.moneybird_sepa_mandate_id = response["sepa_mandate_id"] or None
67 moneybird_contact.needs_synchronization = False
68 moneybird_contact.save()
69 return moneybird_contact
72def delete_contact(contact: MoneybirdContact):
73 """Delete or archive a contact on Moneybird, and delete our record of it."""
74 if not settings.MONEYBIRD_SYNC_ENABLED:
75 return
77 if contact.moneybird_id is None:
78 contact.delete()
79 return
81 moneybird = get_moneybird_api_service()
82 try:
83 moneybird.delete_contact(contact.moneybird_id)
84 contact.delete()
85 except Administration.InvalidData as e:
86 if e.status_code == 400 and e.description == "Contact can not be archived":
87 # Contact is most likely already archived, so we can delete it.
88 logger.warning(
89 "Contact %s for member %s could not be archived.",
90 contact.moneybird_id,
91 contact.member,
92 )
93 contact.delete()
94 else:
95 raise
98def create_or_update_external_invoice(obj):
99 """Create an external sales invoice on Moneybird for a payable object."""
100 if not settings.MONEYBIRD_SYNC_ENABLED:
101 return None
103 external_invoice = MoneybirdExternalInvoice.get_for_object(obj)
104 if external_invoice is None:
105 external_invoice = MoneybirdExternalInvoice.create_for_object(obj)
107 moneybird = get_moneybird_api_service()
109 if external_invoice.moneybird_invoice_id:
110 moneybird.update_external_sales_invoice(
111 external_invoice.moneybird_invoice_id, external_invoice.to_moneybird()
112 )
113 else:
114 response = moneybird.create_external_sales_invoice(
115 external_invoice.to_moneybird()
116 )
117 external_invoice.moneybird_invoice_id = response["id"]
118 external_invoice.moneybird_details_attribute_id = response["details"][0]["id"]
120 if external_invoice.payable.payment is not None:
121 # Mark the invoice as paid if the payable is paid as well
122 try:
123 moneybird_payment = MoneybirdPayment.objects.get(
124 payment=external_invoice.payable.payment
125 )
126 except MoneybirdPayment.DoesNotExist:
127 moneybird_payment = None
129 if (
130 moneybird_payment is not None
131 and moneybird_payment.moneybird_financial_mutation_id is not None
132 ):
133 mutation_info = moneybird.get_financial_mutation_info(
134 external_invoice.payable.payment.moneybird_payment.moneybird_financial_mutation_id
135 )
136 if not any(
137 x["invoice_type"] == "ExternalSalesInvoice"
138 and x["invoice_id"] == external_invoice.moneybird_invoice_id
139 for x in mutation_info["payments"]
140 ):
141 # If the payment itself also already exists in a financial mutation
142 # and is not yet linked to the booking, link it
143 moneybird.link_mutation_to_booking(
144 mutation_id=int(
145 external_invoice.payable.payment.moneybird_payment.moneybird_financial_mutation_id
146 ),
147 booking_id=int(external_invoice.moneybird_invoice_id),
148 price_base=str(external_invoice.payable.payment_amount),
149 )
150 else:
151 # Otherwise, mark it as paid without linking to an actual payment
152 # (announcing that in the future, a mutation should become available)
153 moneybird.register_external_invoice_payment(
154 external_invoice.moneybird_invoice_id,
155 {
156 "payment": {
157 "payment_date": external_invoice.payable.payment.created_at.strftime(
158 "%Y-%m-%d %H:%M:%S"
159 ),
160 "price": str(external_invoice.payable.payment_amount),
161 "financial_account_id": financial_account_id_for_payment_type(
162 external_invoice.payable.payment.type
163 ),
164 }
165 },
166 )
168 # Mark the invoice as not outdated anymore only after everything has succeeded.
169 external_invoice.needs_synchronization = False
170 external_invoice.save()
172 return external_invoice
175def delete_external_invoice(obj):
176 """Delete an external invoice from Moneybird."""
177 if not settings.MONEYBIRD_SYNC_ENABLED:
178 return
180 external_invoice = MoneybirdExternalInvoice.get_for_object(obj)
181 if external_invoice is None:
182 return
184 if external_invoice.moneybird_invoice_id is None:
185 external_invoice.delete()
186 return
188 moneybird = get_moneybird_api_service()
189 try:
190 moneybird.delete_external_sales_invoice(external_invoice.moneybird_invoice_id)
191 except Administration.NotFound:
192 # The invoice has probably been removed manually from moneybird.
193 # We can assume it no longer exists there, but still, this should not happen
194 # too often, so we log it.
195 logger.warning(
196 "Tried to delete non-existing invoice %s with moneybird ID %s",
197 external_invoice,
198 external_invoice.moneybird_invoice_id,
199 )
200 finally:
201 external_invoice.delete()
204def create_receipt(reimbursement: Reimbursement):
205 """Create a receipt on Moneybird for a Reimbursement object."""
206 if not settings.MONEYBIRD_SYNC_ENABLED:
207 return
209 if reimbursement.verdict != Reimbursement.Verdict.APPROVED:
210 return
212 moneybird_receipt, _ = MoneybirdReceipt.objects.get_or_create(
213 reimbursement=reimbursement
214 )
216 moneybird = get_moneybird_api_service()
218 if moneybird_receipt.moneybird_receipt_id is None:
219 response = moneybird.create_receipt(moneybird_receipt.to_moneybird())
220 moneybird_receipt.moneybird_receipt_id = response["id"]
221 moneybird_receipt.save()
223 if not moneybird_receipt.moneybird_attachment_is_uploaded:
224 moneybird.add_receipt_attachment(
225 moneybird_receipt.moneybird_receipt_id,
226 reimbursement.receipt,
227 )
228 moneybird_receipt.moneybird_attachment_is_uploaded = True
229 moneybird_receipt.save()
231 return moneybird_receipt
234def synchronize_moneybird():
235 """Perform all synchronization to moneybird."""
236 if not settings.MONEYBIRD_SYNC_ENABLED:
237 return
239 logger.info("Starting moneybird synchronization.")
241 _sync_contacts()
243 # Push all payments to moneybird. This needs to be done before the invoices,
244 # as creating/updating invoices will link the payments to the invoices if they
245 # already exist on moneybird.
246 _sync_moneybird_payments()
248 # Delete invoices and receipts that have been marked for deletion.
249 _delete_invoices()
251 # Resynchronize outdated invoices.
252 _sync_outdated_invoices()
254 # Push all invoices and receipts to moneybird.
255 _sync_food_orders()
256 _sync_sales_orders()
257 _sync_registrations()
258 _sync_renewals()
259 _sync_event_registrations()
260 _sync_receipts()
262 logger.info("Finished moneybird synchronization.")
265def _delete_invoices():
266 """Delete the invoices that have been marked for deletion from moneybird."""
267 invoices = MoneybirdExternalInvoice.objects.filter(needs_deletion=True)
268 logger.info("Deleting %d invoices.", invoices.count())
270 moneybird = get_moneybird_api_service()
271 for invoice in invoices:
272 try:
273 if invoice.moneybird_invoice_id is not None:
274 moneybird.delete_external_sales_invoice(invoice.moneybird_invoice_id)
275 invoice.delete()
276 except Administration.Error as e:
277 logger.exception("Moneybird synchronization error: %s", e)
278 send_sync_error(e, invoice)
281def _sync_outdated_invoices():
282 """Resynchronize all invoices that have been marked as outdated."""
283 invoices = MoneybirdExternalInvoice.objects.filter(
284 needs_synchronization=True, needs_deletion=False
285 ).order_by("payable_model", "object_id")
287 logger.info("Resynchronizing %d invoices.", invoices.count())
288 for invoice in invoices:
289 try:
290 instance = invoice.payable_object
291 except ObjectDoesNotExist:
292 logger.exception("Payable object for outdated invoice does not exist.")
293 if instance is None: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true
294 logger.exception("Payable object for outdated invoice does not exist.")
296 try:
297 create_or_update_external_invoice(instance)
298 except Administration.Error as e:
299 logger.exception("Moneybird synchronization error: %s", e)
300 send_sync_error(e, instance)
303def _sync_contacts():
304 logger.info("Synchronizing contacts...")
305 # Make moneybird contacts for people that dont have.
306 for member in Member.objects.filter(
307 moneybird_contact__isnull=True, profile__is_minimized=False
308 ):
309 try:
310 create_or_update_contact(member)
311 except Administration.Error as e:
312 logger.exception("Moneybird synchronization error: %s", e)
313 send_sync_error(e, member)
315 # Update moneybird contacts that need synchronization.
316 for contact in MoneybirdContact.objects.filter(needs_synchronization=True):
317 try:
318 create_or_update_contact(contact.member)
319 except Administration.Error as e:
320 logger.exception("Moneybird synchronization error: %s", e)
321 send_sync_error(e, contact.member)
323 # Archive moneybrid contacts where mb contact has not been archived but user was minimized.
324 for contact in MoneybirdContact.objects.filter(member__profile__is_minimized=True):
325 try:
326 delete_contact(contact)
327 except Administration.Error as e:
328 logger.exception("Moneybird synchronization error: %s", e)
329 send_sync_error(e, contact)
331 _sync_contacts_with_outdated_mandates()
334def _sync_contacts_with_outdated_mandates():
335 """Update contacts with outdated mandates.
337 This is mainly a workaround that allows creating contacts on moneybird for members
338 that have a mandate valid from today, without pushing that mandate to Moneybird,
339 as Moneybird only allows mandates valid from the past (and not from today).
341 These contacts can be updated the next day using this function, wich syncs every
342 contact where Moneybird doesn't have the correct mandate yet.
343 """
344 contacts = (
345 MoneybirdContact.objects.annotate(
346 sepa_mandate_id=Subquery(
347 BankAccount.objects.filter(owner=OuterRef("member"))
348 .order_by("-created_at")
349 .values("mandate_no")[:1]
350 )
351 )
352 .exclude(moneybird_sepa_mandate_id=F("sepa_mandate_id"))
353 # For some reason the DB does not consider None == None in the exclude above.
354 .exclude(sepa_mandate_id=None, moneybird_sepa_mandate_id=None)
355 )
357 logger.info(
358 "Pushing %d contacts with outdated mandates to Moneybird.", contacts.count()
359 )
361 for contact in contacts:
362 try:
363 create_or_update_contact(contact.member)
364 except Administration.Error as e:
365 logger.exception("Moneybird synchronization error: %s", e)
366 send_sync_error(e, contact.member)
369def _try_create_or_update_external_invoices(queryset):
370 logger.info(
371 "Pushing %d %s to Moneybird.", queryset.count(), model_ngettext(queryset)
372 )
374 for instance in queryset:
375 try:
376 create_or_update_external_invoice(instance)
377 except Administration.Error as e:
378 logger.exception("Moneybird synchronization error: %s", e)
379 send_sync_error(e, instance)
382def _sync_food_orders():
383 """Create invoices for new food orders."""
384 logger.info("Synchronizing food orders...")
385 food_orders = FoodOrder.objects.filter(
386 food_event__event__start__date__gte=settings.MONEYBIRD_START_DATE,
387 ).exclude(
388 Exists(
389 MoneybirdExternalInvoice.objects.filter(
390 object_id=Cast(OuterRef("pk"), output_field=CharField()),
391 payable_model=ContentType.objects.get_for_model(FoodOrder),
392 )
393 ),
394 )
396 _try_create_or_update_external_invoices(food_orders)
399def _sync_sales_orders():
400 """Create invoices for new sales orders."""
401 logger.info("Synchronizing sales orders...")
402 sales_orders = Order.objects.filter(
403 shift__start__date__gte=settings.MONEYBIRD_START_DATE,
404 payment__isnull=False,
405 ).exclude(
406 Exists(
407 MoneybirdExternalInvoice.objects.filter(
408 object_id=Cast(OuterRef("pk"), output_field=CharField()),
409 payable_model=ContentType.objects.get_for_model(Order),
410 )
411 )
412 )
414 _try_create_or_update_external_invoices(sales_orders)
417def _sync_registrations():
418 """Create invoices for new, paid registrations."""
419 logger.info("Synchronizing registrations...")
420 registrations = Registration.objects.filter(
421 created_at__date__gte=settings.MONEYBIRD_START_DATE,
422 payment__isnull=False,
423 ).exclude(
424 Exists(
425 MoneybirdExternalInvoice.objects.filter(
426 object_id=Cast(OuterRef("pk"), output_field=CharField()),
427 payable_model=ContentType.objects.get_for_model(Registration),
428 )
429 )
430 )
432 _try_create_or_update_external_invoices(registrations)
435def _sync_renewals():
436 """Create invoices for new, paid renewals."""
437 logger.info("Synchronizing renewals...")
438 renewals = Renewal.objects.filter(
439 created_at__date__gte=settings.MONEYBIRD_START_DATE,
440 payment__isnull=False,
441 ).exclude(
442 Exists(
443 MoneybirdExternalInvoice.objects.filter(
444 object_id=Cast(OuterRef("pk"), output_field=CharField()),
445 payable_model=ContentType.objects.get_for_model(Renewal),
446 )
447 )
448 )
450 _try_create_or_update_external_invoices(renewals)
453def _sync_event_registrations():
454 """Create invoices for new event registrations, and delete invoices that shouldn't exist.
456 Existing invoices are deleted when the event registration is cancelled, not invited, or free.
457 In most cases, this will be done already because the event registration has been saved.
458 However, some changes to the event or registrations for the same event might not trigger saving
459 the event registration, but still change its queue position or payment amount.
460 """
461 logger.info("Synchronizing event registrations...")
462 event_registrations = (
463 EventRegistration.objects.select_properties("queue_position", "payment_amount")
464 .filter(
465 event__start__date__gte=settings.MONEYBIRD_START_DATE,
466 date_cancelled__isnull=True,
467 queue_position__isnull=True,
468 payment_amount__gt=0,
469 )
470 .exclude(
471 Exists(
472 MoneybirdExternalInvoice.objects.filter(
473 object_id=Cast(OuterRef("pk"), output_field=CharField()),
474 payable_model=ContentType.objects.get_for_model(EventRegistration),
475 )
476 )
477 )
478 )
480 _try_create_or_update_external_invoices(event_registrations)
482 to_remove = (
483 EventRegistration.objects.select_properties("queue_position", "payment_amount")
484 .filter(
485 Q(date_cancelled__isnull=False)
486 | Q(queue_position__isnull=False)
487 | ~Q(payment_amount__gt=0),
488 event__start__date__gte=settings.MONEYBIRD_START_DATE,
489 )
490 .filter(
491 Exists(
492 MoneybirdExternalInvoice.objects.filter(
493 object_id=Cast(OuterRef("pk"), output_field=CharField()),
494 payable_model=ContentType.objects.get_for_model(EventRegistration),
495 )
496 )
497 )
498 )
500 logger.info(
501 "Removing invoices for %d event registrations from Moneybird.",
502 to_remove.count(),
503 )
505 for instance in to_remove:
506 try:
507 delete_external_invoice(instance)
508 except Administration.Error as e:
509 logger.exception("Moneybird synchronization error: %s", e)
510 send_sync_error(e, instance)
513def _sync_receipts():
514 # Reimbursements whose MoneybirdReceipt does not exist or has not been fully pushed yet.
515 reimbursements = Reimbursement.objects.filter(
516 verdict=Reimbursement.Verdict.APPROVED,
517 ).exclude(
518 moneybird_receipt__isnull=False,
519 moneybird_receipt__moneybird_receipt_id__isnull=False,
520 moneybird_receipt__moneybird_attachment_is_uploaded=False,
521 )
523 logger.info(
524 "Pushing %d reimbursement receipts to Moneybird.", reimbursements.count()
525 )
527 for reimbursement in reimbursements:
528 try:
529 create_receipt(reimbursement)
530 except Administration.Error as e:
531 logger.exception("Moneybird synchronization error: %s", e)
532 send_sync_error(e, reimbursement)
535def _sync_moneybird_payments():
536 """Create financial statements with all payments that haven't been synced yet.
538 This creates one statement per payment type for which there are new payments.
539 """
540 if not settings.MONEYBIRD_SYNC_ENABLED: 540 ↛ 541line 540 didn't jump to line 541 because the condition on line 540 was never true
541 return
543 logger.info("Synchronizing payments...")
545 for payment_type in [Payment.CASH, Payment.CARD, Payment.TPAY]:
546 payments = Payment.objects.filter(
547 type=payment_type,
548 moneybird_payment__isnull=True,
549 created_at__date__gte=settings.MONEYBIRD_START_DATE,
550 ).order_by("pk")
552 if payments.count() == 0:
553 continue
555 logger.info(
556 "Pushing %d %s payments to Moneybird.",
557 payments.count(),
558 payment_type,
559 )
561 financial_account_id = financial_account_id_for_payment_type(payment_type)
562 reference = f"{payment_type} payments at {timezone.now():'%Y-%m-%d %H:%M'}"
564 try:
565 _create_payments_statement(payments, reference, financial_account_id)
566 except Administration.Error as e:
567 logger.exception("Moneybird synchronization error: %s", e)
568 send_sync_error(e, reference)
571def _create_payments_statement(payments, reference, financial_account_id):
572 moneybird = get_moneybird_api_service()
573 moneybird_payments = [MoneybirdPayment(payment=payment) for payment in payments]
574 statement = {
575 "financial_statement": {
576 "financial_account_id": financial_account_id,
577 "reference": reference,
578 "financial_mutations_attributes": {
579 str(i): payment.to_moneybird()
580 for i, payment in enumerate(moneybird_payments)
581 },
582 }
583 }
585 response = moneybird.create_financial_statement(statement)
587 # Store the returned mutation ids that we need to later link the mutations.s
588 for i, moneybird_payment in enumerate(moneybird_payments):
589 moneybird_payment.moneybird_financial_statement_id = response["id"]
590 moneybird_payment.moneybird_financial_mutation_id = response[
591 "financial_mutations"
592 ][i]["id"]
594 MoneybirdPayment.objects.bulk_create(moneybird_payments)
597def delete_moneybird_payment(moneybird_payment):
598 if not settings.MONEYBIRD_SYNC_ENABLED:
599 return
601 index_nr = MoneybirdPayment.objects.filter(
602 moneybird_financial_statement_id=moneybird_payment.moneybird_financial_statement_id
603 ).count() # Note that this is done post_save, so the payment itself isn't in the database anymore
605 moneybird = get_moneybird_api_service()
607 if index_nr == 0:
608 # Delete the whole statement if it will become empty
609 moneybird.delete_financial_statement(
610 moneybird_payment.moneybird_financial_statement_id
611 )
612 return
614 # If we're just removing a single payment from a statement, we first need to unlink it
615 mutation_info = moneybird.get_financial_mutation_info(
616 moneybird_payment.moneybird_financial_mutation_id
617 )
618 for linked_payment in mutation_info["payments"]:
619 moneybird.unlink_mutation_from_booking(
620 mutation_id=int(moneybird_payment.moneybird_financial_mutation_id),
621 booking_id=int(linked_payment["id"]),
622 booking_type="Payment",
623 )
625 # and then remove it from the statement
626 moneybird.update_financial_statement(
627 moneybird_payment.moneybird_financial_statement_id,
628 {
629 "financial_statement": {
630 "financial_mutations_attributes": {
631 str(0): {
632 "id": moneybird_payment.moneybird_financial_mutation_id,
633 "_destroy": True,
634 }
635 }
636 }
637 },
638 )
641def process_thalia_pay_batch(batch):
642 if not settings.MONEYBIRD_SYNC_ENABLED:
643 return
645 moneybird = get_moneybird_api_service()
646 moneybird.create_financial_statement(
647 {
648 "financial_statement": {
649 "financial_account_id": settings.MONEYBIRD_TPAY_FINANCIAL_ACCOUNT_ID,
650 "reference": f"Settlement of Thalia Pay batch {batch.id}: {batch.description}",
651 "financial_mutations_attributes": {
652 "0": {
653 "date": batch.processing_date.strftime("%Y-%m-%d"),
654 "message": f"Settlement of Thalia Pay batch {batch.id}: {batch.description}",
655 "amount": str(-1 * batch.total_amount()),
656 }
657 },
658 }
659 }
660 )