Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/reimbursements #3860

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ known-first-party = [
"promotion",
"pushnotifications",
"registrations",
"reimbursements",
"sales",
"shortlinks",
"singlepages",
Expand Down
104 changes: 104 additions & 0 deletions website/moneybirdsynchronization/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from payments.payables import payables
from pizzas.models import FoodOrder
from registrations.models import Registration, Renewal
from reimbursements.models import Reimbursement
from sales.models.order import Order


Expand Down Expand Up @@ -346,6 +347,109 @@ class Meta:
unique_together = ("payable_model", "object_id")


class MoneybirdReceipt(models.Model):
reimbursement = models.OneToOneField(Reimbursement, on_delete=models.CASCADE)
object_id = models.CharField(max_length=255)

moneybird_receipt_id = models.CharField(
verbose_name=_("moneybird receipt id"),
max_length=255,
blank=True,
null=True,
)

moneybird_details_attribute_id = models.CharField(
verbose_name=_("moneybird details attribute id"),
max_length=255,
blank=True,
null=True,
) # We need this id, so we can update the rows (otherwise, updates will create new rows without deleting).
# We only support one attribute for now, so this is the easiest way to store it

needs_synchronization = models.BooleanField(
default=True, # The field is set False only when it has been successfully synchronized.
help_text="Indicates that the invoice has to be synchronized (again).",
)

needs_deletion = models.BooleanField(
default=False,
help_text="Indicates that the invoice has to be deleted from moneybird.",
)

def to_moneybird(self):
moneybird = get_moneybird_api_service()

contact_id = settings.MONEYBIRD_UNKNOWN_PAYER_CONTACT_ID

if self.reimbursement.owner is not None:
try:
moneybird_contact = MoneybirdContact.objects.get(
member=self.reimbursement.owner
)
contact_id = moneybird_contact.moneybird_id
except MoneybirdContact.DoesNotExist:
pass

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

project_name = f"Reimbursement [{receipt_date}]"

project_id = None
if project_name is not None:
project, __ = MoneybirdProject.objects.get_or_create(name=project_name)
if project.moneybird_id is None:
response = moneybird.create_project(project.to_moneybird())
project.moneybird_id = response["id"]
project.save()

project_id = project.moneybird_id

source_url = settings.BASE_URL + reverse(
f"admin:{self.reimbursement._meta.app_label}_{self.reimbursement._meta.model_name}_change",
args=(self.object_id,),
)

data = {
"receipt": {
"contact_id": int(contact_id),
"reference": f"Receipt [{self.reimbursement.pk}]",
"source": f"Concrexit ({settings.SITE_DOMAIN})",
"date": receipt_date,
"currency": "EUR",
"prices_are_incl_tax": True,
"details_attributes": [
{
"description": self.reimbursement.description,
"price": str(self.reimbursement.amount),
},
],
}
}

if source_url is not None:
data["external_sales_invoice"]["source_url"] = source_url

if project_id is not None:
data["external_sales_invoice"]["details_attributes"][0]["project_id"] = int(
project_id
)

if self.moneybird_details_attribute_id is not None:
data["external_sales_invoice"]["details_attributes"][0]["id"] = int(
self.moneybird_details_attribute_id
)

return data

def __str__(self):
return f"Moneybird external invoice for {self.payable_object}"

class Meta:
verbose_name = _("moneybird external invoice")
verbose_name_plural = _("moneybird external invoices")
unique_together = ("payable_model", "object_id")


class MoneybirdPayment(models.Model):
payment = models.OneToOneField(
"payments.Payment",
Expand Down
21 changes: 20 additions & 1 deletion website/moneybirdsynchronization/moneybird.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def update_external_sales_invoice(self, invoice_id, invoice_data):
f"external_sales_invoices/{invoice_id}", invoice_data
)

def delete_external_invoice(self, invoice_id):
def delete_external_sales_invoice(self, invoice_id):
self._administration.delete(f"external_sales_invoices/{invoice_id}")

def register_external_invoice_payment(self, invoice_id, payment_data):
Expand All @@ -53,6 +53,25 @@ def update_financial_statement(self, statement_id, statement_data):
def delete_financial_statement(self, statement_id):
return self._administration.delete(f"financial_statements/{statement_id}")

def create_receipt(self, receipt_data):
return self._administration.post("receipts", receipt_data)

def delete_receipt(self, receipt_id):
return self._administration.delete(f"receipts/{receipt_id}")

def update_receipt(self, receipt_id, receipt_data):
return self._administration.patch(f"receipts/{receipt_id}", receipt_data)

def add_receipt_attachment(self, receipt_id, receipt_attachment):
return self._administration.post(
f"receipts/{receipt_id}/attachments", receipt_attachment
)

def delete_receipt_attachment(self, receipt_id, attachment_id):
return self._administration.delete(
f"receipts/{receipt_id}/attachments/{attachment_id}"
)

def link_mutation_to_booking(
self,
mutation_id: int,
Expand Down
83 changes: 79 additions & 4 deletions website/moneybirdsynchronization/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
MoneybirdContact,
MoneybirdExternalInvoice,
MoneybirdPayment,
MoneybirdReceipt,
financial_account_id_for_payment_type,
)
from moneybirdsynchronization.moneybird import get_moneybird_api_service
from payments.models import BankAccount, Payment
from pizzas.models import FoodOrder
from registrations.models import Registration, Renewal
from reimbursements.models import Reimbursement
from sales.models.order import Order

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -185,7 +187,7 @@ def delete_external_invoice(obj):

moneybird = get_moneybird_api_service()
try:
moneybird.delete_external_invoice(external_invoice.moneybird_invoice_id)
moneybird.delete_external_sales_invoice(external_invoice.moneybird_invoice_id)
except Administration.NotFound:
# The invoice has probably been removed manually from moneybird.
# We can assume it no longer exists there, but still, this should not happen
Expand All @@ -199,6 +201,64 @@ def delete_external_invoice(obj):
external_invoice.delete()


def create_or_update_receipt(reimbursement: Reimbursement):
"""Create a receipt on Moneybird for a Reimbursement object."""
if not settings.MONEYBIRD_SYNC_ENABLED:
return

if reimbursement.verdict != Reimbursement.verdicts.APPROVED:
return

moneybird_receipt, _ = MoneybirdReceipt.objects.get_or_create(
reimbursement=reimbursement
)

moneybird = get_moneybird_api_service()

if moneybird_receipt.moneybird_receipt_id:
moneybird.update_receipt(
moneybird_receipt.moneybird_receipt_id, moneybird_receipt.to_moneybird()
)
else:
response = moneybird.create_receipt(moneybird_receipt.to_moneybird())
moneybird_receipt.moneybird_receipt_id = response["id"]
moneybird_receipt.moneybird_details_attribute_id = response["details"][0]["id"]
attachment_response = moneybird.add_receipt_attachment(
moneybird_receipt.moneybird_receipt_id, reimbursement.receipt
)
moneybird_receipt.moneybird_receipt_attachment_id = attachment_response["id"]

moneybird_receipt.needs_synchronization = False
moneybird_receipt.save()

return moneybird_receipt


def delete_receipt(receipt: MoneybirdReceipt):
"""Delete or archive a receipt on Moneybird, and delete our record of it."""
if not settings.MONEYBIRD_SYNC_ENABLED:
return

if receipt.moneybird_receipt_id is None:
receipt.delete()
return

moneybird = get_moneybird_api_service()

try:
if receipt.moneybird_receipt_attachment_id:
moneybird.delete_receipt_attachment(receipt.moneybird_receipt_attachment_id)
moneybird.delete_receipt(receipt.moneybird_id)
receipt.delete()
except Administration.InvalidData:
logger.warning(
"Receipt %s for reimbursement %s could not be deleted.",
receipt.moneybird_receipt_id,
receipt.reimbursement,
)
receipt.delete()


def synchronize_moneybird():
"""Perform all synchronization to moneybird."""
if not settings.MONEYBIRD_SYNC_ENABLED:
Expand All @@ -213,18 +273,20 @@ def synchronize_moneybird():
# already exist on moneybird.
_sync_moneybird_payments()

# Delete invoices that have been marked for deletion.
# Delete invoices and receipts that have been marked for deletion.
_delete_invoices()
_delete_receipts()

# Resynchronize outdated invoices.
_sync_outdated_invoices()

# Push all invoices to moneybird.
# Push all invoices and receipts to moneybird.
_sync_food_orders()
_sync_sales_orders()
_sync_registrations()
_sync_renewals()
_sync_event_registrations()
# _sync_receipts()

logger.info("Finished moneybird synchronization.")

Expand All @@ -238,13 +300,22 @@ def _delete_invoices():
for invoice in invoices:
try:
if invoice.moneybird_invoice_id is not None:
moneybird.delete_external_invoice(invoice.moneybird_invoice_id)
moneybird.delete_external_sales_invoice(invoice.moneybird_invoice_id)
invoice.delete()
except Administration.Error as e:
logger.exception("Moneybird synchronization error: %s", e)
send_sync_error(e, invoice)


def _delete_receipts():
"""Delete the receipts that have been marked for deletion from moneybird."""
receipts = MoneybirdReceipt.objects.filter(needs_deletion=True)
logger.info("Deleting %d receipts.", receipts.count())

for receipt in receipts:
delete_receipt(receipt=receipt)


def _sync_outdated_invoices():
"""Resynchronize all invoices that have been marked as outdated."""
invoices = MoneybirdExternalInvoice.objects.filter(
Expand Down Expand Up @@ -477,6 +548,10 @@ def _sync_event_registrations():
send_sync_error(e, instance)


# def _sync_receipts():
# TODO


def _sync_moneybird_payments():
"""Create financial statements with all payments that haven't been synced yet.

Expand Down
6 changes: 3 additions & 3 deletions website/moneybirdsynchronization/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ def test_delete_invoices(self, mock_api):
moneybird_invoice_id="2",
)

# _delete_invoices calls the delete_external_invoice API directly.
mock_delete_invoice = mock_api.return_value.delete_external_invoice
# _delete_invoices calls the delete_external_sales_invoice API directly.
mock_delete_invoice = mock_api.return_value.delete_external_sales_invoice

with self.subTest("Invoices without needs_deletion are not deleted."):
services._delete_invoices()
Expand Down Expand Up @@ -458,7 +458,7 @@ def test_sync_renewals(self, mock_create_invoice, mock_api):
# Renewal 2 is not paid yet, so no invoice should be made for it.
mock_create_invoice.assert_called_once_with(renewal1)

@mock.patch("moneybirdsynchronization.services.delete_external_invoice")
@mock.patch("moneybirdsynchronization.services.delete_external_sales_invoice")
@mock.patch("moneybirdsynchronization.services.create_or_update_external_invoice")
def test_sync_event_registrations(
self, mock_create_invoice, mock_delete_invoice, mock_api
Expand Down
Empty file.
Loading
Loading