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

1import logging 

2 

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 

10 

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 

28 

29logger = logging.getLogger(__name__) 

30 

31 

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 

36 

37 moneybird_contact, _ = MoneybirdContact.objects.get_or_create(member=member) 

38 

39 moneybird = get_moneybird_api_service() 

40 

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) 

52 

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) 

65 

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 

70 

71 

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 

76 

77 if contact.moneybird_id is None: 

78 contact.delete() 

79 return 

80 

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 

96 

97 

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 

102 

103 external_invoice = MoneybirdExternalInvoice.get_for_object(obj) 

104 if external_invoice is None: 

105 external_invoice = MoneybirdExternalInvoice.create_for_object(obj) 

106 

107 moneybird = get_moneybird_api_service() 

108 

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

119 

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 

128 

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 ) 

167 

168 # Mark the invoice as not outdated anymore only after everything has succeeded. 

169 external_invoice.needs_synchronization = False 

170 external_invoice.save() 

171 

172 return external_invoice 

173 

174 

175def delete_external_invoice(obj): 

176 """Delete an external invoice from Moneybird.""" 

177 if not settings.MONEYBIRD_SYNC_ENABLED: 

178 return 

179 

180 external_invoice = MoneybirdExternalInvoice.get_for_object(obj) 

181 if external_invoice is None: 

182 return 

183 

184 if external_invoice.moneybird_invoice_id is None: 

185 external_invoice.delete() 

186 return 

187 

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

202 

203 

204def create_receipt(reimbursement: Reimbursement): 

205 """Create a receipt on Moneybird for a Reimbursement object.""" 

206 if not settings.MONEYBIRD_SYNC_ENABLED: 

207 return 

208 

209 if reimbursement.verdict != Reimbursement.Verdict.APPROVED: 

210 return 

211 

212 moneybird_receipt, _ = MoneybirdReceipt.objects.get_or_create( 

213 reimbursement=reimbursement 

214 ) 

215 

216 moneybird = get_moneybird_api_service() 

217 

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

222 

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

230 

231 return moneybird_receipt 

232 

233 

234def synchronize_moneybird(): 

235 """Perform all synchronization to moneybird.""" 

236 if not settings.MONEYBIRD_SYNC_ENABLED: 

237 return 

238 

239 logger.info("Starting moneybird synchronization.") 

240 

241 _sync_contacts() 

242 

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

247 

248 # Delete invoices and receipts that have been marked for deletion. 

249 _delete_invoices() 

250 

251 # Resynchronize outdated invoices. 

252 _sync_outdated_invoices() 

253 

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

261 

262 logger.info("Finished moneybird synchronization.") 

263 

264 

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

269 

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) 

279 

280 

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

286 

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

295 

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) 

301 

302 

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) 

314 

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) 

322 

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) 

330 

331 _sync_contacts_with_outdated_mandates() 

332 

333 

334def _sync_contacts_with_outdated_mandates(): 

335 """Update contacts with outdated mandates. 

336 

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

340 

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 ) 

356 

357 logger.info( 

358 "Pushing %d contacts with outdated mandates to Moneybird.", contacts.count() 

359 ) 

360 

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) 

367 

368 

369def _try_create_or_update_external_invoices(queryset): 

370 logger.info( 

371 "Pushing %d %s to Moneybird.", queryset.count(), model_ngettext(queryset) 

372 ) 

373 

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) 

380 

381 

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 ) 

395 

396 _try_create_or_update_external_invoices(food_orders) 

397 

398 

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 ) 

413 

414 _try_create_or_update_external_invoices(sales_orders) 

415 

416 

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 ) 

431 

432 _try_create_or_update_external_invoices(registrations) 

433 

434 

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 ) 

449 

450 _try_create_or_update_external_invoices(renewals) 

451 

452 

453def _sync_event_registrations(): 

454 """Create invoices for new event registrations, and delete invoices that shouldn't exist. 

455 

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 ) 

479 

480 _try_create_or_update_external_invoices(event_registrations) 

481 

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 ) 

499 

500 logger.info( 

501 "Removing invoices for %d event registrations from Moneybird.", 

502 to_remove.count(), 

503 ) 

504 

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) 

511 

512 

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 ) 

522 

523 logger.info( 

524 "Pushing %d reimbursement receipts to Moneybird.", reimbursements.count() 

525 ) 

526 

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) 

533 

534 

535def _sync_moneybird_payments(): 

536 """Create financial statements with all payments that haven't been synced yet. 

537 

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 

542 

543 logger.info("Synchronizing payments...") 

544 

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

551 

552 if payments.count() == 0: 

553 continue 

554 

555 logger.info( 

556 "Pushing %d %s payments to Moneybird.", 

557 payments.count(), 

558 payment_type, 

559 ) 

560 

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

563 

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) 

569 

570 

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 } 

584 

585 response = moneybird.create_financial_statement(statement) 

586 

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

593 

594 MoneybirdPayment.objects.bulk_create(moneybird_payments) 

595 

596 

597def delete_moneybird_payment(moneybird_payment): 

598 if not settings.MONEYBIRD_SYNC_ENABLED: 

599 return 

600 

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 

604 

605 moneybird = get_moneybird_api_service() 

606 

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 

613 

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 ) 

624 

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 ) 

639 

640 

641def process_thalia_pay_batch(batch): 

642 if not settings.MONEYBIRD_SYNC_ENABLED: 

643 return 

644 

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 )