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

1from random import randint 

2from unittest import mock 

3 

4from django.test import TestCase, override_settings 

5from django.utils import timezone 

6 

7from freezegun import freeze_time 

8 

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 

28 

29 

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"] 

43 

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() 

52 

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. 

55 

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() 

61 

62 mock_create_or_update_contact = mock_api.return_value.create_contact 

63 mock_update_contact = mock_api.return_value.update_contact 

64 

65 mock_create_or_update_contact.return_value = {"id": "1", "sepa_mandate_id": ""} 

66 mock_update_contact.return_value = {"id": "1", "sepa_mandate_id": ""} 

67 

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 ) 

79 

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() 

84 

85 data = mock_create_or_update_contact.call_args[0][0] 

86 self.assertNotIn("sepa_mandate_id", data["contact"]) 

87 

88 moneybird_contact = self.member.moneybird_contact 

89 self.assertEqual(moneybird_contact.moneybird_sepa_mandate_id, None) 

90 

91 mock_create_or_update_contact.reset_mock() 

92 mock_update_contact.reset_mock() 

93 

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() 

98 

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"]) 

102 

103 moneybird_contact.refresh_from_db() 

104 self.assertEqual(moneybird_contact.moneybird_sepa_mandate_id, None) 

105 

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"} 

114 

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() 

120 

121 data = mock_create_or_update_contact.call_args[0][0] 

122 self.assertEqual(data["contact"]["sepa_mandate_id"], "1-1") 

123 

124 moneybird_contact = self.member.moneybird_contact 

125 self.assertEqual(moneybird_contact.moneybird_sepa_mandate_id, "1-1") 

126 

127 mock_create_or_update_contact.reset_mock() 

128 mock_update_contact.reset_mock() 

129 

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() 

134 

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") 

138 

139 moneybird_contact.refresh_from_db() 

140 self.assertEqual(moneybird_contact.moneybird_sepa_mandate_id, "1-1") 

141 

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() 

148 

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 ) 

169 

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 ) 

191 

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") 

202 

203 services._sync_contacts_with_outdated_mandates() 

204 

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]) 

208 

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 ) 

227 

228 # _delete_invoices calls the delete_external_sales_invoice API directly. 

229 mock_delete_invoice = mock_api.return_value.delete_external_sales_invoice 

230 

231 with self.subTest("Invoices without needs_deletion are not deleted."): 

232 services._delete_invoices() 

233 self.assertEqual(mock_delete_invoice.call_count, 0) 

234 

235 invoice1.needs_deletion = True 

236 invoice2.needs_deletion = True 

237 invoice1.save() 

238 invoice2.save() 

239 

240 mock_delete_invoice.reset_mock() 

241 mock_delete_invoice.side_effect = Administration.InvalidData(400) 

242 

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) 

246 

247 # Deletion failed so the objects are still in the database. 

248 self.assertEqual( 

249 MoneybirdExternalInvoice.objects.filter(needs_deletion=True).count(), 2 

250 ) 

251 

252 mock_delete_invoice.reset_mock() 

253 mock_delete_invoice.side_effect = None 

254 

255 invoice1.moneybird_invoice_id = None 

256 invoice1.needs_synchronization = True 

257 invoice1.save() 

258 

259 with self.subTest("Invoices with needs_deletion are deleted."): 

260 services._delete_invoices() 

261 

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) 

264 

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 ) 

269 

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) 

281 

282 with self.subTest("Invoices with needs_synchronization are updated."): 

283 services._sync_outdated_invoices() 

284 self.assertEqual(mock_update_invoice.call_count, 2) 

285 

286 mock_update_invoice.reset_mock() 

287 mock_update_invoice.side_effect = Administration.InvalidData(400) 

288 

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) 

292 

293 mock_update_invoice.reset_mock() 

294 mock_update_invoice.side_effect = None 

295 

296 invoice1.needs_synchronization = False 

297 invoice1.save() 

298 invoice2.needs_synchronization = False 

299 invoice2.save() 

300 

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) 

304 

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 ) 

311 

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 ) 

325 

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 ) 

335 

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 } 

351 

352 mock_create_statement = mock_api.return_value.create_financial_statement 

353 

354 mock_create_statement.side_effect = side_effect 

355 

356 with freeze_time("2023-10-15"): 

357 services._sync_moneybird_payments() 

358 

359 # Statements should be created only for TPAY and CASH payments. 

360 self.assertEqual(mock_create_statement.call_count, 2) 

361 

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"] 

364 

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)) 

374 

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 ) 

379 

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 ) 

409 

410 services._sync_food_orders() 

411 self.assertEqual(mock_create_invoice.call_count, 2) 

412 

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 ) 

423 

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 ) 

430 

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 ) 

437 

438 # Order 1 is free, and no invoice should be made for it. 

439 create_payment(order2, self.member, Payment.TPAY) 

440 

441 services._sync_sales_orders() 

442 

443 mock_create_invoice.assert_called_once_with(order2) 

444 

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 ) 

453 

454 create_payment(renewal1, self.member, Payment.TPAY) 

455 

456 services._sync_renewals() 

457 

458 # Renewal 2 is not paid yet, so no invoice should be made for it. 

459 mock_create_invoice.assert_called_once_with(renewal1) 

460 

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 ) 

479 

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 ) 

492 

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") 

496 

497 services._sync_event_registrations() 

498 

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) 

503 

504 event2.price = 0.00 

505 event2.save() 

506 

507 mock_create_invoice.reset_mock() 

508 

509 MoneybirdExternalInvoice.objects.create(payable_object=r2) 

510 MoneybirdExternalInvoice.objects.create(payable_object=r3) 

511 

512 services._sync_event_registrations() 

513 

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) 

518 

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() 

533 

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) 

553 

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() 

558 

559 services._sync_contacts() 

560 

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. 

567 

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() 

574 

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() 

589 

590 services._sync_contacts() 

591 

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)