svthalia/concrexit

View on GitHub
website/moneybirdsynchronization/services.py

Summary

Maintainability
C
1 day
Test Coverage
import logging

from django.conf import settings
from django.contrib.admin.utils import model_ngettext
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery
from django.db.models.functions import Cast
from django.utils import timezone

from events.models import EventRegistration
from members.models import Member
from moneybirdsynchronization.administration import Administration
from moneybirdsynchronization.emails import send_sync_error
from moneybirdsynchronization.models import (
    MoneybirdContact,
    MoneybirdExternalInvoice,
    MoneybirdPayment,
    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 sales.models.order import Order

logger = logging.getLogger(__name__)


def create_or_update_contact(member: Member):
    """Push a Django user/member to Moneybird."""
    if not settings.MONEYBIRD_SYNC_ENABLED:
        return None

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

    moneybird = get_moneybird_api_service()

    if moneybird_contact.moneybird_id is None:
        # Push contact to moneybird. This may fail with 422 when moneybird rejects an
        # email address. In that case, we try once more leaving out the email address,
        # as moneybird does not require that we set one at all.
        try:
            response = moneybird.create_contact(moneybird_contact.to_moneybird())
        except Administration.InvalidData:
            logger.info("Retrying to create contact without email...")
            contact = moneybird_contact.to_moneybird()
            del contact["contact"]["send_invoices_to_email"]
            response = moneybird.create_contact(contact)

        moneybird_contact.moneybird_id = response["id"]
    else:
        # Update the contact data (right now we always do this, but we could use the version to check if it's needed).
        try:
            response = moneybird.update_contact(
                moneybird_contact.moneybird_id, moneybird_contact.to_moneybird()
            )
        except Administration.InvalidData:
            logger.info("Retrying to update contact without email...")
            contact = moneybird_contact.to_moneybird()
            del contact["contact"]["send_invoices_to_email"]
            response = moneybird.update_contact(moneybird_contact.moneybird_id, contact)

    moneybird_contact.moneybird_sepa_mandate_id = response["sepa_mandate_id"] or None
    moneybird_contact.needs_synchronization = False
    moneybird_contact.save()
    return moneybird_contact


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

    if contact.moneybird_id is None:
        contact.delete()
        return

    moneybird = get_moneybird_api_service()
    try:
        moneybird.delete_contact(contact.moneybird_id)
        contact.delete()
    except Administration.InvalidData as e:
        if e.status_code == 400 and e.description == "Contact can not be archived":
            # Contact is most likely already archived, so we can delete it.
            logger.warning(
                "Contact %s for member %s could not be archived.",
                contact.moneybird_id,
                contact.member,
            )
            contact.delete()
        else:
            raise


def create_or_update_external_invoice(obj):
    """Create an external sales invoice on Moneybird for a payable object."""
    if not settings.MONEYBIRD_SYNC_ENABLED:
        return None

    external_invoice = MoneybirdExternalInvoice.get_for_object(obj)
    if external_invoice is None:
        external_invoice = MoneybirdExternalInvoice.create_for_object(obj)

    moneybird = get_moneybird_api_service()

    if external_invoice.moneybird_invoice_id:
        moneybird.update_external_sales_invoice(
            external_invoice.moneybird_invoice_id, external_invoice.to_moneybird()
        )
    else:
        response = moneybird.create_external_sales_invoice(
            external_invoice.to_moneybird()
        )
        external_invoice.moneybird_invoice_id = response["id"]
        external_invoice.moneybird_details_attribute_id = response["details"][0]["id"]

    if external_invoice.payable.payment is not None:
        # Mark the invoice as paid if the payable is paid as well
        try:
            moneybird_payment = MoneybirdPayment.objects.get(
                payment=external_invoice.payable.payment
            )
        except MoneybirdPayment.DoesNotExist:
            moneybird_payment = None

        if (
            moneybird_payment is not None
            and moneybird_payment.moneybird_financial_mutation_id is not None
        ):
            mutation_info = moneybird.get_financial_mutation_info(
                external_invoice.payable.payment.moneybird_payment.moneybird_financial_mutation_id
            )
            if not any(
                x["invoice_type"] == "ExternalSalesInvoice"
                and x["invoice_id"] == external_invoice.moneybird_invoice_id
                for x in mutation_info["payments"]
            ):
                # If the payment itself also already exists in a financial mutation
                # and is not yet linked to the booking, link it
                moneybird.link_mutation_to_booking(
                    mutation_id=int(
                        external_invoice.payable.payment.moneybird_payment.moneybird_financial_mutation_id
                    ),
                    booking_id=int(external_invoice.moneybird_invoice_id),
                    price_base=str(external_invoice.payable.payment_amount),
                )
        else:
            # Otherwise, mark it as paid without linking to an actual payment
            # (announcing that in the future, a mutation should become available)
            moneybird.register_external_invoice_payment(
                external_invoice.moneybird_invoice_id,
                {
                    "payment": {
                        "payment_date": external_invoice.payable.payment.created_at.strftime(
                            "%Y-%m-%d %H:%M:%S"
                        ),
                        "price": str(external_invoice.payable.payment_amount),
                        "financial_account_id": financial_account_id_for_payment_type(
                            external_invoice.payable.payment.type
                        ),
                    }
                },
            )

    # Mark the invoice as not outdated anymore only after everything has succeeded.
    external_invoice.needs_synchronization = False
    external_invoice.save()

    return external_invoice


def delete_external_invoice(obj):
    """Delete an external invoice from Moneybird."""
    if not settings.MONEYBIRD_SYNC_ENABLED:
        return

    external_invoice = MoneybirdExternalInvoice.get_for_object(obj)
    if external_invoice is None:
        return

    if external_invoice.moneybird_invoice_id is None:
        external_invoice.delete()
        return

    moneybird = get_moneybird_api_service()
    try:
        moneybird.delete_external_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
        # too often, so we log it.
        logger.warning(
            "Tried to delete non-existing invoice %s with moneybird ID %s",
            external_invoice,
            external_invoice.moneybird_invoice_id,
        )
    finally:
        external_invoice.delete()


def synchronize_moneybird():
    """Perform all synchronization to moneybird."""
    if not settings.MONEYBIRD_SYNC_ENABLED:
        return

    logger.info("Starting moneybird synchronization.")

    _sync_contacts()

    # Push all payments to moneybird. This needs to be done before the invoices,
    # as creating/updating invoices will link the payments to the invoices if they
    # already exist on moneybird.
    _sync_moneybird_payments()

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

    # Resynchronize outdated invoices.
    _sync_outdated_invoices()

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

    logger.info("Finished moneybird synchronization.")


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

    moneybird = get_moneybird_api_service()
    for invoice in invoices:
        try:
            if invoice.moneybird_invoice_id is not None:
                moneybird.delete_external_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 _sync_outdated_invoices():
    """Resynchronize all invoices that have been marked as outdated."""
    invoices = MoneybirdExternalInvoice.objects.filter(
        needs_synchronization=True, needs_deletion=False
    ).order_by("payable_model", "object_id")

    logger.info("Resynchronizing %d invoices.", invoices.count())
    for invoice in invoices:
        try:
            instance = invoice.payable_object
        except ObjectDoesNotExist:
            logger.exception("Payable object for outdated invoice does not exist.")
        if instance is None:
            logger.exception("Payable object for outdated invoice does not exist.")

        try:
            create_or_update_external_invoice(instance)
        except Administration.Error as e:
            logger.exception("Moneybird synchronization error: %s", e)
            send_sync_error(e, instance)


def _sync_contacts():
    logger.info("Synchronizing contacts...")
    # Make moneybird contacts for people that dont have.
    for member in Member.objects.filter(
        moneybird_contact__isnull=True, profile__is_minimized=False
    ):
        try:
            create_or_update_contact(member)
        except Administration.Error as e:
            logger.exception("Moneybird synchronization error: %s", e)
            send_sync_error(e, member)

    # Update moneybird contacts that need synchronization.
    for contact in MoneybirdContact.objects.filter(needs_synchronization=True):
        try:
            create_or_update_contact(contact.member)
        except Administration.Error as e:
            logger.exception("Moneybird synchronization error: %s", e)
            send_sync_error(e, contact.member)

    # Archive moneybrid contacts where mb contact has not been archived but user was minimized.
    for contact in MoneybirdContact.objects.filter(member__profile__is_minimized=True):
        try:
            delete_contact(contact)
        except Administration.Error as e:
            logger.exception("Moneybird synchronization error: %s", e)
            send_sync_error(e, contact)

    _sync_contacts_with_outdated_mandates()


def _sync_contacts_with_outdated_mandates():
    """Update contacts with outdated mandates.

    This is mainly a workaround that allows creating contacts on moneybird for members
    that have a mandate valid from today, without pushing that mandate to Moneybird,
    as Moneybird only allows mandates valid from the past (and not from today).

    These contacts can be updated the next day using this function, wich syncs every
    contact where Moneybird doesn't have the correct mandate yet.
    """
    contacts = (
        MoneybirdContact.objects.annotate(
            sepa_mandate_id=Subquery(
                BankAccount.objects.filter(owner=OuterRef("member"))
                .order_by("-created_at")
                .values("mandate_no")[:1]
            )
        ).exclude(moneybird_sepa_mandate_id=F("sepa_mandate_id"))
        # For some reason the DB does not consider None == None in the exclude above.
        .exclude(sepa_mandate_id=None, moneybird_sepa_mandate_id=None)
    )

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

    for contact in contacts:
        try:
            create_or_update_contact(contact.member)
        except Administration.Error as e:
            logger.exception("Moneybird synchronization error: %s", e)
            send_sync_error(e, contact.member)


def _try_create_or_update_external_invoices(queryset):
    logger.info(
        "Pushing %d %s to Moneybird.", queryset.count(), model_ngettext(queryset)
    )

    for instance in queryset:
        try:
            create_or_update_external_invoice(instance)
        except Administration.Error as e:
            logger.exception("Moneybird synchronization error: %s", e)
            send_sync_error(e, instance)


def _sync_food_orders():
    """Create invoices for new food orders."""
    logger.info("Synchronizing food orders...")
    food_orders = FoodOrder.objects.filter(
        food_event__event__start__date__gte=settings.MONEYBIRD_START_DATE,
    ).exclude(
        Exists(
            MoneybirdExternalInvoice.objects.filter(
                object_id=Cast(OuterRef("pk"), output_field=CharField()),
                payable_model=ContentType.objects.get_for_model(FoodOrder),
            )
        ),
    )

    _try_create_or_update_external_invoices(food_orders)


def _sync_sales_orders():
    """Create invoices for new sales orders."""
    logger.info("Synchronizing sales orders...")
    sales_orders = Order.objects.filter(
        shift__start__date__gte=settings.MONEYBIRD_START_DATE,
        payment__isnull=False,
    ).exclude(
        Exists(
            MoneybirdExternalInvoice.objects.filter(
                object_id=Cast(OuterRef("pk"), output_field=CharField()),
                payable_model=ContentType.objects.get_for_model(Order),
            )
        )
    )

    _try_create_or_update_external_invoices(sales_orders)


def _sync_registrations():
    """Create invoices for new, paid registrations."""
    logger.info("Synchronizing registrations...")
    registrations = Registration.objects.filter(
        created_at__date__gte=settings.MONEYBIRD_START_DATE,
        payment__isnull=False,
    ).exclude(
        Exists(
            MoneybirdExternalInvoice.objects.filter(
                object_id=Cast(OuterRef("pk"), output_field=CharField()),
                payable_model=ContentType.objects.get_for_model(Registration),
            )
        )
    )

    _try_create_or_update_external_invoices(registrations)


def _sync_renewals():
    """Create invoices for new, paid renewals."""
    logger.info("Synchronizing renewals...")
    renewals = Renewal.objects.filter(
        created_at__date__gte=settings.MONEYBIRD_START_DATE,
        payment__isnull=False,
    ).exclude(
        Exists(
            MoneybirdExternalInvoice.objects.filter(
                object_id=Cast(OuterRef("pk"), output_field=CharField()),
                payable_model=ContentType.objects.get_for_model(Renewal),
            )
        )
    )

    _try_create_or_update_external_invoices(renewals)


def _sync_event_registrations():
    """Create invoices for new event registrations, and delete invoices that shouldn't exist.

    Existing invoices are deleted when the event registration is cancelled, not invited, or free.
    In most cases, this will be done already because the event registration has been saved.
    However, some changes to the event or registrations for  the same event might not trigger saving
    the event registration, but still change its queue position or payment amount.
    """
    logger.info("Synchronizing event registrations...")
    event_registrations = (
        EventRegistration.objects.select_properties("queue_position", "payment_amount")
        .filter(
            event__start__date__gte=settings.MONEYBIRD_START_DATE,
            date_cancelled__isnull=True,
            queue_position__isnull=True,
            payment_amount__gt=0,
        )
        .exclude(
            Exists(
                MoneybirdExternalInvoice.objects.filter(
                    object_id=Cast(OuterRef("pk"), output_field=CharField()),
                    payable_model=ContentType.objects.get_for_model(EventRegistration),
                )
            )
        )
    )

    _try_create_or_update_external_invoices(event_registrations)

    to_remove = (
        EventRegistration.objects.select_properties("queue_position", "payment_amount")
        .filter(
            Q(date_cancelled__isnull=False)
            | Q(queue_position__isnull=False)
            | ~Q(payment_amount__gt=0),
            event__start__date__gte=settings.MONEYBIRD_START_DATE,
        )
        .filter(
            Exists(
                MoneybirdExternalInvoice.objects.filter(
                    object_id=Cast(OuterRef("pk"), output_field=CharField()),
                    payable_model=ContentType.objects.get_for_model(EventRegistration),
                )
            )
        )
    )

    logger.info(
        "Removing invoices for %d event registrations from Moneybird.",
        to_remove.count(),
    )

    for instance in to_remove:
        try:
            delete_external_invoice(instance)
        except Administration.Error as e:
            logger.exception("Moneybird synchronization error: %s", e)
            send_sync_error(e, instance)


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

    This creates one statement per payment type for which there are new payments.
    """
    if not settings.MONEYBIRD_SYNC_ENABLED:
        return

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

    for payment_type in [Payment.CASH, Payment.CARD, Payment.TPAY]:
        payments = Payment.objects.filter(
            type=payment_type,
            moneybird_payment__isnull=True,
            created_at__date__gte=settings.MONEYBIRD_START_DATE,
        ).order_by("pk")

        if payments.count() == 0:
            continue

        logger.info(
            "Pushing %d %s payments to Moneybird.",
            payments.count(),
            payment_type,
        )

        financial_account_id = financial_account_id_for_payment_type(payment_type)
        reference = f"{payment_type} payments at {timezone.now():'%Y-%m-%d %H:%M'}"

        try:
            _create_payments_statement(payments, reference, financial_account_id)
        except Administration.Error as e:
            logger.exception("Moneybird synchronization error: %s", e)
            send_sync_error(e, reference)


def _create_payments_statement(payments, reference, financial_account_id):
    moneybird = get_moneybird_api_service()
    moneybird_payments = [MoneybirdPayment(payment=payment) for payment in payments]
    statement = {
        "financial_statement": {
            "financial_account_id": financial_account_id,
            "reference": reference,
            "financial_mutations_attributes": {
                str(i): payment.to_moneybird()
                for i, payment in enumerate(moneybird_payments)
            },
        }
    }

    response = moneybird.create_financial_statement(statement)

    # Store the returned mutation ids that we need to later link the mutations.s
    for i, moneybird_payment in enumerate(moneybird_payments):
        moneybird_payment.moneybird_financial_statement_id = response["id"]
        moneybird_payment.moneybird_financial_mutation_id = response[
            "financial_mutations"
        ][i]["id"]

    MoneybirdPayment.objects.bulk_create(moneybird_payments)


def delete_moneybird_payment(moneybird_payment):
    if not settings.MONEYBIRD_SYNC_ENABLED:
        return

    index_nr = MoneybirdPayment.objects.filter(
        moneybird_financial_statement_id=moneybird_payment.moneybird_financial_statement_id
    ).count()  # Note that this is done post_save, so the payment itself isn't in the database anymore

    moneybird = get_moneybird_api_service()

    if index_nr == 0:
        # Delete the whole statement if it will become empty
        moneybird.delete_financial_statement(
            moneybird_payment.moneybird_financial_statement_id
        )
        return

    # If we're just removing a single payment from a statement, we first need to unlink it
    mutation_info = moneybird.get_financial_mutation_info(
        moneybird_payment.moneybird_financial_mutation_id
    )
    for linked_payment in mutation_info["payments"]:
        moneybird.unlink_mutation_from_booking(
            mutation_id=int(moneybird_payment.moneybird_financial_mutation_id),
            booking_id=int(linked_payment["id"]),
            booking_type="Payment",
        )

    # and then remove it from the statement
    moneybird.update_financial_statement(
        moneybird_payment.moneybird_financial_statement_id,
        {
            "financial_statement": {
                "financial_mutations_attributes": {
                    str(0): {
                        "id": moneybird_payment.moneybird_financial_mutation_id,
                        "_destroy": True,
                    }
                }
            }
        },
    )


def process_thalia_pay_batch(batch):
    if not settings.MONEYBIRD_SYNC_ENABLED:
        return

    moneybird = get_moneybird_api_service()
    moneybird.create_financial_statement(
        {
            "financial_statement": {
                "financial_account_id": settings.MONEYBIRD_TPAY_FINANCIAL_ACCOUNT_ID,
                "reference": f"Settlement of Thalia Pay batch {batch.id}: {batch.description}",
                "financial_mutations_attributes": {
                    "0": {
                        "date": batch.processing_date.strftime("%Y-%m-%d"),
                        "message": f"Settlement of Thalia Pay batch {batch.id}: {batch.description}",
                        "amount": str(-1 * batch.total_amount()),
                    }
                },
            }
        }
    )