Coverage for website/moneybirdsynchronization/tests/test_services.py: 100.00%
272 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
1from random import randint
2from unittest import mock
4from django.test import TestCase, override_settings
5from django.utils import timezone
7from freezegun import freeze_time
9from events.models.event import Event
10from events.models.event_registration import EventRegistration
11from members.models import Member
12from moneybirdsynchronization import services
13from moneybirdsynchronization.administration import Administration
14from moneybirdsynchronization.models import (
15 MoneybirdContact,
16 MoneybirdExternalInvoice,
17 MoneybirdPayment,
18)
19from payments.models import BankAccount, Payment
20from payments.services import create_payment
21from pizzas.models import FoodEvent, FoodOrder
22from pizzas.models import Product as FoodProduct
23from registrations.models import Renewal
24from sales.models.order import Order, OrderItem
25from sales.models.product import Product as SalesProduct
26from sales.models.product import ProductList
27from sales.models.shift import Shift
30# Each test method has a mock_api argument that is a MagicMock instance, replacing the
31# MoneybirdAPIService *class*. To check calls or set behaviour of a MoneybirdAPIService
32# *instance*, use `mock_api.return_value.<MoneybirdAPIService method>`.
33@mock.patch("moneybirdsynchronization.moneybird.MoneybirdAPIService", autospec=True)
34@override_settings( # Settings needed to enable synchronization.
35 MONEYBIRD_START_DATE="2023-09-01",
36 MONEYBIRD_ADMINISTRATION_ID="123",
37 MONEYBIRD_API_KEY="foo",
38 MONEYBIRD_SYNC_ENABLED=True,
39 SUSPEND_SIGNALS=True,
40)
41class ServicesTest(TestCase):
42 fixtures = ["members.json", "bank_accounts.json", "products.json"]
44 @classmethod
45 def setUpTestData(cls):
46 cls.member = Member.objects.get(pk=1)
47 cls.member2 = Member.objects.get(pk=2)
48 cls.member3 = Member.objects.get(pk=3)
49 cls.member4 = Member.objects.get(pk=4)
50 cls.member5 = Member.objects.get(pk=5)
51 cls.bank_account = cls.member.bank_accounts.first()
53 def test_create_or_update_contact_with_mandate(self, mock_api):
54 """Creating/updating a contact with a mandate excludes the mandate if it starts today.
56 This is a limitation imposed by the Moneybird API.
57 See: https://github.com/svthalia/concrexit/issues/3295.
58 """
59 # Drop the fixtures, we create our own bank account here.
60 BankAccount.objects.all().delete()
62 mock_create_or_update_contact = mock_api.return_value.create_contact
63 mock_update_contact = mock_api.return_value.update_contact
65 mock_create_or_update_contact.return_value = {"id": "1", "sepa_mandate_id": ""}
66 mock_update_contact.return_value = {"id": "1", "sepa_mandate_id": ""}
68 # Bank account is valid from 2016-07-07.
69 with freeze_time("2016-07-07"):
70 bank_account = BankAccount.objects.create(
71 owner=self.member,
72 iban="NL12ABNA1234567890",
73 initials="J.",
74 last_name="Doe",
75 valid_from=timezone.now().date(),
76 signature="base64,png",
77 mandate_no="1-1",
78 )
80 with self.subTest("Creating a contact does not push today's SEPA mandate."):
81 services.create_or_update_contact(self.member)
82 mock_create_or_update_contact.assert_called_once()
83 mock_update_contact.assert_not_called()
85 data = mock_create_or_update_contact.call_args[0][0]
86 self.assertNotIn("sepa_mandate_id", data["contact"])
88 moneybird_contact = self.member.moneybird_contact
89 self.assertEqual(moneybird_contact.moneybird_sepa_mandate_id, None)
91 mock_create_or_update_contact.reset_mock()
92 mock_update_contact.reset_mock()
94 with self.subTest("Updating a contact does not push today's SEPA mandate."):
95 services.create_or_update_contact(self.member)
96 mock_create_or_update_contact.assert_not_called()
97 mock_update_contact.assert_called_once()
99 data = mock_update_contact.call_args[0][1]
100 self.assertEqual(mock_update_contact.call_args[0][0], "1")
101 self.assertNotIn("sepa_mandate_id", data["contact"])
103 moneybird_contact.refresh_from_db()
104 self.assertEqual(moneybird_contact.moneybird_sepa_mandate_id, None)
106 moneybird_contact.delete()
107 mock_create_or_update_contact.reset_mock()
108 mock_update_contact.reset_mock()
109 mock_create_or_update_contact.return_value = {
110 "id": "1",
111 "sepa_mandate_id": "1-1",
112 }
113 mock_update_contact.return_value = {"id": "1", "sepa_mandate_id": "1-1"}
115 with freeze_time("2016-07-08"):
116 with self.subTest("Creating a contact pushes past SEPA mandate."):
117 services.create_or_update_contact(self.member)
118 mock_create_or_update_contact.assert_called_once()
119 mock_update_contact.assert_not_called()
121 data = mock_create_or_update_contact.call_args[0][0]
122 self.assertEqual(data["contact"]["sepa_mandate_id"], "1-1")
124 moneybird_contact = self.member.moneybird_contact
125 self.assertEqual(moneybird_contact.moneybird_sepa_mandate_id, "1-1")
127 mock_create_or_update_contact.reset_mock()
128 mock_update_contact.reset_mock()
130 with self.subTest("Updating a contact pushes past SEPA mandate."):
131 services.create_or_update_contact(self.member)
132 mock_create_or_update_contact.assert_not_called()
133 mock_update_contact.assert_called_once()
135 data = mock_update_contact.call_args[0][1]
136 self.assertEqual(mock_update_contact.call_args[0][0], "1")
137 self.assertEqual(data["contact"]["sepa_mandate_id"], "1-1")
139 moneybird_contact.refresh_from_db()
140 self.assertEqual(moneybird_contact.moneybird_sepa_mandate_id, "1-1")
142 @mock.patch("moneybirdsynchronization.services.create_or_update_contact")
143 def test_sync_contacts_with_outdated_mandates(
144 self, mock_create_or_update_contact, mock_api
145 ):
146 # Drop the fixtures, we create our own bank account here.
147 BankAccount.objects.all().delete()
149 # Valid and already pushed.
150 ba1 = BankAccount.objects.create(
151 owner=self.member,
152 iban="NL12ABNA1234567890",
153 initials="J.",
154 last_name="Doe",
155 valid_from=timezone.now().date() - timezone.timedelta(days=1),
156 signature="base64,png",
157 mandate_no="1-1",
158 )
159 # Valid and new.
160 ba2 = BankAccount.objects.create(
161 owner=self.member2,
162 iban="NL12ABNA1234567891",
163 initials="J.",
164 last_name="Doe",
165 valid_from=timezone.now().date() - timezone.timedelta(days=1),
166 signature="base64,png",
167 mandate_no="2-1",
168 )
170 # Outdated and already pushed.
171 BankAccount.objects.create(
172 owner=self.member3,
173 iban="NL12ABNA1234567892",
174 initials="J.",
175 last_name="Doe",
176 valid_from=timezone.now().date() - timezone.timedelta(days=10),
177 valid_until=timezone.now().date() - timezone.timedelta(days=2),
178 signature="base64,png",
179 mandate_no="3-1",
180 )
181 # Valid and an outdated mandate already pushed.
182 BankAccount.objects.create(
183 owner=self.member3,
184 iban="NL12ABNA1234567892",
185 initials="J.",
186 last_name="Doe",
187 valid_from=timezone.now().date() - timezone.timedelta(days=1),
188 signature="base64,png",
189 mandate_no="3-2",
190 )
192 MoneybirdContact.objects.create(
193 member=self.member, moneybird_id="1", moneybird_sepa_mandate_id="1-1"
194 )
195 MoneybirdContact.objects.create(
196 member=self.member2, moneybird_id="2", moneybird_sepa_mandate_id=None
197 )
198 MoneybirdContact.objects.create(
199 member=self.member3, moneybird_id="3", moneybird_sepa_mandate_id="3-1"
200 )
201 MoneybirdContact.objects.create(member=self.member4, moneybird_id="4")
203 services._sync_contacts_with_outdated_mandates()
205 self.assertEqual(mock_create_or_update_contact.call_count, 2)
206 members = [x[0][0].pk for x in mock_create_or_update_contact.call_args_list]
207 self.assertCountEqual(members, [self.member2.pk, self.member3.pk])
209 def test_delete_invoices(self, mock_api):
210 """Invoices marked for deletion are deleted."""
211 renewal1 = Renewal.objects.create(
212 member=self.member, length=Renewal.MEMBERSHIP_YEAR
213 )
214 renewal2 = Renewal.objects.create(
215 member=self.member2, length=Renewal.MEMBERSHIP_YEAR
216 )
217 invoice1 = MoneybirdExternalInvoice.objects.create(
218 payable_object=renewal1,
219 needs_synchronization=False,
220 moneybird_invoice_id="1",
221 )
222 invoice2 = MoneybirdExternalInvoice.objects.create(
223 payable_object=renewal2,
224 needs_synchronization=False,
225 moneybird_invoice_id="2",
226 )
228 # _delete_invoices calls the delete_external_sales_invoice API directly.
229 mock_delete_invoice = mock_api.return_value.delete_external_sales_invoice
231 with self.subTest("Invoices without needs_deletion are not deleted."):
232 services._delete_invoices()
233 self.assertEqual(mock_delete_invoice.call_count, 0)
235 invoice1.needs_deletion = True
236 invoice2.needs_deletion = True
237 invoice1.save()
238 invoice2.save()
240 mock_delete_invoice.reset_mock()
241 mock_delete_invoice.side_effect = Administration.InvalidData(400)
243 with self.subTest("All invoices are tried even after an exception"):
244 services._delete_invoices()
245 self.assertEqual(mock_delete_invoice.call_count, 2)
247 # Deletion failed so the objects are still in the database.
248 self.assertEqual(
249 MoneybirdExternalInvoice.objects.filter(needs_deletion=True).count(), 2
250 )
252 mock_delete_invoice.reset_mock()
253 mock_delete_invoice.side_effect = None
255 invoice1.moneybird_invoice_id = None
256 invoice1.needs_synchronization = True
257 invoice1.save()
259 with self.subTest("Invoices with needs_deletion are deleted."):
260 services._delete_invoices()
262 # Only one invoice has a moneybird_invoice_id, so only one call is made.
263 mock_delete_invoice.assert_called_once_with(invoice2.moneybird_invoice_id)
265 # The other has its object removed from the database without an API call.
266 self.assertEqual(
267 MoneybirdExternalInvoice.objects.filter(needs_deletion=True).count(), 0
268 )
270 @mock.patch("moneybirdsynchronization.services.create_or_update_external_invoice")
271 def test_sync_outdated_invoices(self, mock_update_invoice, mock_api):
272 """Invoices marked with needs_synchronization are updated."""
273 renewal1 = Renewal.objects.create(
274 member=self.member, length=Renewal.MEMBERSHIP_YEAR
275 )
276 renewal2 = Renewal.objects.create(
277 member=self.member2, length=Renewal.MEMBERSHIP_YEAR
278 )
279 invoice1 = MoneybirdExternalInvoice.objects.create(payable_object=renewal1)
280 invoice2 = MoneybirdExternalInvoice.objects.create(payable_object=renewal2)
282 with self.subTest("Invoices with needs_synchronization are updated."):
283 services._sync_outdated_invoices()
284 self.assertEqual(mock_update_invoice.call_count, 2)
286 mock_update_invoice.reset_mock()
287 mock_update_invoice.side_effect = Administration.InvalidData(400)
289 with self.subTest("All invoices are tried even after an exception"):
290 services._sync_outdated_invoices()
291 self.assertEqual(mock_update_invoice.call_count, 2)
293 mock_update_invoice.reset_mock()
294 mock_update_invoice.side_effect = None
296 invoice1.needs_synchronization = False
297 invoice1.save()
298 invoice2.needs_synchronization = False
299 invoice2.save()
301 with self.subTest("Invoices without needs_synchronization are not updated."):
302 services._sync_outdated_invoices()
303 self.assertEqual(mock_update_invoice.call_count, 0)
305 def test_sync_moneybird_payments(self, mock_api):
306 """MoneybirdPayments are created for any new (non-wire) payments."""
307 # Payments from before settings.MONEYBIRD_START_DATE are ignored.
308 p1 = Payment.objects.create(
309 type=Payment.CASH, amount=5, created_at="2000-01-01"
310 )
312 p2 = Payment.objects.create(
313 type=Payment.CASH, amount=10, created_at="2023-10-15"
314 )
315 p3 = Payment.objects.create(
316 type=Payment.CASH, amount=15, created_at="2023-10-15"
317 )
318 p4 = Payment.objects.create(
319 type=Payment.TPAY,
320 amount=20,
321 created_at="2023-10-15",
322 paid_by=self.member,
323 processed_by=self.member,
324 )
326 # Payments that are already synchronized are ignored.
327 p5 = Payment.objects.create(
328 type=Payment.CARD, amount=25, created_at="2023-10-15"
329 )
330 MoneybirdPayment.objects.create(
331 payment=p5,
332 moneybird_financial_statement_id="0",
333 moneybird_financial_mutation_id="0",
334 )
336 def side_effect(data):
337 """Return a new financial statement with plausible mutations for each call."""
338 return {
339 "id": str(randint(1_000_000_000, 9_000_000_000)),
340 "financial_mutations": [
341 {
342 "id": str(randint(1_000_000_000, 9_000_000_000)),
343 "amount": f"{float(mut['amount']):0.2f}",
344 "batch_reference": mut["batch_reference"],
345 }
346 for mut in data["financial_statement"][
347 "financial_mutations_attributes"
348 ].values()
349 ],
350 }
352 mock_create_statement = mock_api.return_value.create_financial_statement
354 mock_create_statement.side_effect = side_effect
356 with freeze_time("2023-10-15"):
357 services._sync_moneybird_payments()
359 # Statements should be created only for TPAY and CASH payments.
360 self.assertEqual(mock_create_statement.call_count, 2)
362 data1 = mock_create_statement.call_args_list[0][0][0]["financial_statement"]
363 data2 = mock_create_statement.call_args_list[1][0][0]["financial_statement"]
365 # Check that the
366 self.assertEqual(len(data1["financial_mutations_attributes"]), 2)
367 self.assertEqual(len(data2["financial_mutations_attributes"]), 1)
368 mut1 = data1["financial_mutations_attributes"]["0"]
369 mut2 = data1["financial_mutations_attributes"]["1"]
370 mut3 = data2["financial_mutations_attributes"]["0"]
371 self.assertIn(mut1["batch_reference"], (str(p2.id), str(p3.id)))
372 self.assertIn(mut2["batch_reference"], (str(p2.id), str(p3.id)))
373 self.assertEqual(mut3["batch_reference"], str(p4.id))
375 # MoneybirdPayments should now exist for all payments except p1.
376 self.assertEqual(
377 MoneybirdPayment.objects.filter(payment__in=[p1, p2, p3, p4, p5]).count(), 4
378 )
380 @mock.patch("moneybirdsynchronization.services.create_or_update_external_invoice")
381 def test_sync_food_orders(self, mock_create_invoice, mock_api):
382 """Invoices are made for food orders."""
383 event = Event.objects.create(
384 title="testevent",
385 description="desc",
386 start=timezone.now(),
387 end=(timezone.now() + timezone.timedelta(hours=1)),
388 location="test location",
389 map_location="test map location",
390 price=0.00,
391 fine=0.00,
392 )
393 food_event = FoodEvent.objects.create(
394 event=event,
395 start=timezone.now(),
396 end=(timezone.now() + timezone.timedelta(hours=1)),
397 )
398 product = FoodProduct.objects.create(name="foo", description="bar", price=1.00)
399 order1 = FoodOrder.objects.create(
400 member=Member.objects.get(pk=1),
401 food_event=food_event,
402 product=product,
403 )
404 order2 = FoodOrder.objects.create(
405 name="John Doe",
406 food_event=food_event,
407 product=product,
408 )
410 services._sync_food_orders()
411 self.assertEqual(mock_create_invoice.call_count, 2)
413 @mock.patch("moneybirdsynchronization.services.create_or_update_external_invoice")
414 def test_sync_sales_orders(self, mock_create_invoice, mock_api):
415 """Invoices are created for paid sales orders."""
416 beer = SalesProduct.objects.get(name="beer")
417 soda = SalesProduct.objects.get(name="soda")
418 shift = Shift.objects.create(
419 start=timezone.now(),
420 end=timezone.now() + timezone.timedelta(hours=1),
421 product_list=ProductList.objects.get(name="normal"),
422 )
424 order1 = Order.objects.create(shift=shift, payer=self.member)
425 OrderItem.objects.create(
426 order=order1,
427 product=shift.product_list.product_items.get(product=soda),
428 amount=2,
429 )
431 order2 = Order.objects.create(shift=shift, payer=self.member)
432 OrderItem.objects.create(
433 order=order2,
434 product=shift.product_list.product_items.get(product=beer),
435 amount=1,
436 )
438 # Order 1 is free, and no invoice should be made for it.
439 create_payment(order2, self.member, Payment.TPAY)
441 services._sync_sales_orders()
443 mock_create_invoice.assert_called_once_with(order2)
445 @mock.patch("moneybirdsynchronization.services.create_or_update_external_invoice")
446 def test_sync_renewals(self, mock_create_invoice, mock_api):
447 renewal1 = Renewal.objects.create(
448 member=self.member, length=Renewal.MEMBERSHIP_YEAR
449 )
450 renewal2 = Renewal.objects.create(
451 member=self.member2, length=Renewal.MEMBERSHIP_YEAR
452 )
454 create_payment(renewal1, self.member, Payment.TPAY)
456 services._sync_renewals()
458 # Renewal 2 is not paid yet, so no invoice should be made for it.
459 mock_create_invoice.assert_called_once_with(renewal1)
461 @mock.patch("moneybirdsynchronization.services.delete_external_invoice")
462 @mock.patch("moneybirdsynchronization.services.create_or_update_external_invoice")
463 def test_sync_event_registrations(
464 self, mock_create_invoice, mock_delete_invoice, mock_api
465 ):
466 """Invoices are created for event registrations."""
467 event1 = Event.objects.create(
468 title="testevent 1",
469 description="desc",
470 start=timezone.now(),
471 end=timezone.now() + timezone.timedelta(hours=1),
472 registration_start=timezone.now(),
473 registration_end=timezone.now() + timezone.timedelta(hours=1),
474 location="test location",
475 map_location="test map location",
476 price=0.00,
477 fine=0.00,
478 )
480 event2 = Event.objects.create(
481 title="testevent 2",
482 description="desc",
483 start=timezone.now(),
484 end=(timezone.now() + timezone.timedelta(hours=1)),
485 registration_start=timezone.now(),
486 registration_end=timezone.now() + timezone.timedelta(hours=1),
487 location="test location",
488 map_location="test map location",
489 price=10.00,
490 fine=20.00,
491 )
493 r1 = EventRegistration.objects.create(event=event1, member=self.member)
494 r2 = EventRegistration.objects.create(event=event2, member=self.member)
495 r3 = EventRegistration.objects.create(event=event2, name="John Doe")
497 services._sync_event_registrations()
499 self.assertEqual(mock_delete_invoice.call_count, 0)
500 self.assertEqual(mock_create_invoice.call_count, 2)
501 mock_create_invoice.assert_any_call(r2)
502 mock_create_invoice.assert_any_call(r3)
504 event2.price = 0.00
505 event2.save()
507 mock_create_invoice.reset_mock()
509 MoneybirdExternalInvoice.objects.create(payable_object=r2)
510 MoneybirdExternalInvoice.objects.create(payable_object=r3)
512 services._sync_event_registrations()
514 self.assertEqual(mock_create_invoice.call_count, 0)
515 self.assertEqual(mock_delete_invoice.call_count, 2)
516 mock_delete_invoice.assert_any_call(r2)
517 mock_delete_invoice.assert_any_call(r3)
519 @mock.patch("moneybirdsynchronization.services.delete_contact")
520 @mock.patch("moneybirdsynchronization.services.create_or_update_contact")
521 @mock.patch(
522 "moneybirdsynchronization.services._sync_contacts_with_outdated_mandates"
523 ) # Prevent sync_contacts from actaully calling the function, since it is tested separately.
524 def test_sync_contacts(
525 self,
526 mock_sync_contacts_with_outdated_mandates,
527 mock_create_or_update_contact,
528 mock_delete_contact,
529 mock_api,
530 ):
531 # test moneyboard contact is made for users without moneybird contact (and not minimized), in this case only for self.member
532 services._sync_contacts()
534 self.assertEqual(mock_create_or_update_contact.call_count, 5)
535 self.assertEqual(mock_delete_contact.call_count, 0)
536 mock_create_or_update_contact.assert_any_call(self.member)
537 mock_create_or_update_contact.assert_any_call(self.member2)
538 mock_create_or_update_contact.assert_any_call(self.member3)
539 mock_create_or_update_contact.assert_any_call(self.member4)
540 mock_create_or_update_contact.assert_any_call(self.member5)
541 mock_create_or_update_contact.reset_mock()
542 # create contacs so that mock_create_or_update_contact is not called again
543 MoneybirdContact.objects.create(
544 member=self.member2, needs_synchronization=False
545 )
546 MoneybirdContact.objects.create(
547 member=self.member3, needs_synchronization=False
548 )
549 MoneybirdContact.objects.create(
550 member=self.member4, needs_synchronization=False
551 )
552 MoneybirdContact.objects.create(member=self.member, needs_synchronization=False)
554 with override_settings(SUSPEND_SIGNALS=False):
555 # MoneybirdContact is marked for resynchronization if address changes.
556 self.member.profile.address_line1 = "foo"
557 self.member.profile.save()
559 services._sync_contacts()
561 self.assertEqual(mock_create_or_update_contact.call_count, 2)
562 self.assertEqual(mock_delete_contact.call_count, 0)
563 mock_create_or_update_contact.assert_any_call(self.member) # Address changed.
564 mock_create_or_update_contact.assert_any_call(
565 self.member5
566 ) # No contact was made.
568 mock_create_or_update_contact.reset_mock()
569 MoneybirdContact.objects.create(
570 member=self.member5, needs_synchronization=False
571 )
572 self.member.moneybird_contact.needs_synchronization = False
573 self.member.moneybird_contact.save()
575 # Moneybird contact is deleted (archived) if a profile is minimized.
576 profile = self.member.profile
577 profile.student_number = None
578 profile.phone_number = None
579 profile.address_street = None
580 profile.address_street2 = None
581 profile.address_postal_code = None
582 profile.address_city = None
583 profile.address_country = None
584 profile.birthday = None
585 profile.emergency_contact_phone_number = None
586 profile.emergency_contact = None
587 profile.is_minimized = True
588 profile.save()
590 services._sync_contacts()
592 self.assertEqual(mock_create_or_update_contact.call_count, 0)
593 self.assertEqual(mock_delete_contact.call_count, 1)
594 mock_delete_contact.assert_any_call(self.member.moneybird_contact)