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

1import datetime 

2 

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 _ 

10 

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 

20 

21 

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 

30 

31 

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 

44 

45 raise ValueError(f"Unknown payable model {obj}") 

46 

47 

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

59 

60 raise ValueError(f"Unknown payable model {obj}") 

61 

62 

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 

71 

72 

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 

77 

78 

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 

83 

84 

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 ) 

94 

95 moneybird_id = models.CharField( 

96 _("Moneybird ID"), 

97 max_length=255, 

98 blank=True, 

99 null=True, 

100 ) 

101 

102 class Meta: 

103 verbose_name = _("moneybird project") 

104 verbose_name_plural = _("moneybird projects") 

105 

106 def __str__(self): 

107 return f"Moneybird project {self.name}" 

108 

109 def to_moneybird(self): 

110 return { 

111 "project": { 

112 "name": self.name, 

113 } 

114 } 

115 

116 

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 ) 

132 

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 ) 

140 

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 ) 

145 

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 

191 

192 def get_moneybird_info(self): 

193 return { 

194 "id": self.moneybird_id, 

195 "pk": self.member.pk, 

196 } 

197 

198 def __str__(self): 

199 return f"Moneybird contact for {self.member}" 

200 

201 class Meta: 

202 verbose_name = _("moneybird contact") 

203 verbose_name_plural = _("moneybird contacts") 

204 

205 

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

210 

211 moneybird_invoice_id = models.CharField( 

212 verbose_name=_("moneybird invoice id"), 

213 max_length=255, 

214 blank=True, 

215 null=True, 

216 ) 

217 

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 

225 

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 ) 

230 

231 needs_deletion = models.BooleanField( 

232 default=False, 

233 help_text="Indicates that the invoice has to be deleted from moneybird.", 

234 ) 

235 

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 

242 

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 ) 

250 

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 

261 

262 def to_moneybird(self): 

263 moneybird = get_moneybird_api_service() 

264 

265 contact_id = settings.MONEYBIRD_UNKNOWN_PAYER_CONTACT_ID 

266 

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 

275 

276 invoice_date = date_for_payable_model(self.payable_object).strftime("%Y-%m-%d") 

277 

278 period = period_for_payable_model(self.payable_object) 

279 

280 tax_rate_id = tax_rate_for_payable_model(self.payable_object) 

281 

282 project_name = project_name_for_payable_model(self.payable_object) 

283 

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

291 

292 project_id = project.moneybird_id 

293 

294 ledger_id = ledger_id_for_payable_model(self.payable_object) 

295 

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 ) 

300 

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 } 

317 

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 ) 

328 

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 ) 

339 

340 return data 

341 

342 def __str__(self): 

343 return f"Moneybird external invoice for {self.payable_object}" 

344 

345 class Meta: 

346 verbose_name = _("moneybird external invoice") 

347 verbose_name_plural = _("moneybird external invoices") 

348 unique_together = ("payable_model", "object_id") 

349 

350 

351class MoneybirdReceipt(models.Model): 

352 reimbursement = models.OneToOneField( 

353 Reimbursement, 

354 on_delete=models.CASCADE, 

355 related_name="moneybird_receipt", 

356 ) 

357 

358 moneybird_receipt_id = models.CharField( 

359 verbose_name=_("moneybird receipt id"), 

360 max_length=255, 

361 blank=True, 

362 null=True, 

363 ) 

364 

365 moneybird_attachment_is_uploaded = models.BooleanField( 

366 default=False, 

367 ) 

368 

369 def to_moneybird(self): 

370 contact_id = settings.MONEYBIRD_UNKNOWN_PAYER_CONTACT_ID 

371 

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 

380 

381 receipt_date = self.reimbursement.date_incurred.strftime("%Y-%m-%d") 

382 

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 ) 

387 

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 } 

407 

408 return data 

409 

410 def __str__(self): 

411 return f"Moneybird receipt for {self.reimbursement}" 

412 

413 class Meta: 

414 verbose_name = _("moneybird receipt") 

415 verbose_name_plural = _("moneybird receipt") 

416 

417 

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 ) 

425 

426 moneybird_financial_statement_id = models.CharField( 

427 verbose_name=_("moneybird financial statement id"), max_length=255 

428 ) 

429 

430 moneybird_financial_mutation_id = models.CharField( 

431 verbose_name=_("moneybird financial mutation id"), max_length=255 

432 ) 

433 

434 def __str__(self): 

435 return f"Moneybird payment for {self.payment}" 

436 

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 ) 

464 

465 return data 

466 

467 class Meta: 

468 verbose_name = _("moneybird payment") 

469 verbose_name_plural = _("moneybird payments")