svthalia/concrexit

View on GitHub
website/moneybirdsynchronization/models.py

Summary

Maintainability
B
6 hrs
Test Coverage
import datetime

from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from events.models import EventRegistration
from members.models import Member
from moneybirdsynchronization.moneybird import get_moneybird_api_service
from payments.models import BankAccount, Payment
from payments.payables import payables
from pizzas.models import FoodOrder
from registrations.models import Registration, Renewal
from sales.models.order import Order


def financial_account_id_for_payment_type(payment_type) -> int | None:
    if payment_type == Payment.CARD:
        return settings.MONEYBIRD_CARD_FINANCIAL_ACCOUNT_ID
    if payment_type == Payment.CASH:
        return settings.MONEYBIRD_CASH_FINANCIAL_ACCOUNT_ID
    if payment_type == Payment.TPAY:
        return settings.MONEYBIRD_TPAY_FINANCIAL_ACCOUNT_ID
    return None


def project_name_for_payable_model(obj) -> str | None:
    if isinstance(obj, EventRegistration):
        start_date = obj.event.start.strftime("%Y-%m-%d")
        return f"{obj.event.title} [{start_date}]"
    if isinstance(obj, FoodOrder):
        start_date = obj.food_event.event.start.strftime("%Y-%m-%d")
        return f"{obj.food_event.event.title} [{start_date}]"
    if isinstance(obj, Order):
        start_date = obj.shift.start.strftime("%Y-%m-%d")
        return f"{obj.shift} [{start_date}]"
    if isinstance(obj, Registration | Renewal):
        return None

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


def date_for_payable_model(obj) -> datetime.datetime | datetime.date:
    if isinstance(obj, EventRegistration):
        return obj.date.date()
    if isinstance(obj, FoodOrder):
        return obj.food_event.event.start
    if isinstance(obj, Order):
        return obj.shift.start
    if isinstance(obj, Registration | Renewal):
        return obj.created_at.date()

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


def period_for_payable_model(obj) -> str | None:
    if isinstance(obj, Registration | Renewal):
        if obj.membership is not None:
            # Only bill for the start date, ignore the until date.
            date = obj.membership.since
            return f"{date.strftime('%Y%m%d')}..{date.strftime('%Y%m%d')}"
    return None


def tax_rate_for_payable_model(obj) -> int | None:
    if isinstance(obj, Registration | Renewal):
        return settings.MONEYBIRD_ZERO_TAX_RATE_ID
    return None


def ledger_id_for_payable_model(obj) -> int | None:
    if isinstance(obj, Registration | Renewal):
        return settings.MONEYBIRD_CONTRIBUTION_LEDGER_ID
    return None


class MoneybirdProject(models.Model):
    name = models.CharField(
        _("Name"),
        max_length=255,
        blank=False,
        null=False,
        unique=True,
        db_index=True,
    )

    moneybird_id = models.CharField(
        _("Moneybird ID"),
        max_length=255,
        blank=True,
        null=True,
    )

    class Meta:
        verbose_name = _("moneybird project")
        verbose_name_plural = _("moneybird projects")

    def __str__(self):
        return f"Moneybird project {self.name}"

    def to_moneybird(self):
        return {
            "project": {
                "name": self.name,
            }
        }


class MoneybirdContact(models.Model):
    member = models.OneToOneField(
        Member,
        on_delete=models.CASCADE,
        verbose_name=_("member"),
        related_name="moneybird_contact",
        null=True,
        blank=True,
    )
    moneybird_id = models.CharField(
        _("Moneybird ID"),
        max_length=255,
        blank=True,
        null=True,
    )

    moneybird_sepa_mandate_id = models.CharField(
        _("Moneybird SEPA mandate ID"),
        max_length=255,
        blank=True,
        null=True,
        unique=True,
    )

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

    def to_moneybird(self):
        if self.member.profile is None:
            return None
        data = {
            "contact": {
                "firstname": self.member.first_name,
                "lastname": self.member.last_name,
                "address1": self.member.profile.address_street,
                "address2": self.member.profile.address_street2,
                "zipcode": self.member.profile.address_postal_code,
                "city": self.member.profile.address_city,
                "country": self.member.profile.address_country,
                "send_invoices_to_email": self.member.email,
            }
        }
        bank_account = BankAccount.objects.filter(owner=self.member).last()
        if bank_account:
            data["contact"]["sepa_iban"] = bank_account.iban
            data["contact"]["sepa_bic"] = bank_account.bic or ""
            data["contact"][
                "sepa_iban_account_name"
            ] = f"{bank_account.initials} {bank_account.last_name}"
            if bank_account.valid and bank_account.valid_from < timezone.now().date():
                data["contact"]["sepa_active"] = True
                data["contact"]["sepa_mandate_id"] = bank_account.mandate_no
                data["contact"]["sepa_mandate_date"] = bank_account.valid_from.strftime(
                    "%Y-%m-%d"
                )
                data["contact"]["sepa_sequence_type"] = "RCUR"
            else:
                data["contact"]["sepa_active"] = False
        else:
            data["contact"]["sepa_iban_account_name"] = ""
            data["contact"]["sepa_iban"] = ""
            data["contact"]["sepa_bic"] = ""
            data["contact"]["sepa_active"] = False
        if self.moneybird_id is not None:
            data["id"] = self.moneybird_id
        if settings.MONEYBIRD_MEMBER_PK_CUSTOM_FIELD_ID:
            data["contact"]["custom_fields_attributes"] = {}
            data["contact"]["custom_fields_attributes"]["0"] = {
                "id": settings.MONEYBIRD_MEMBER_PK_CUSTOM_FIELD_ID,
                "value": self.member.pk,
            }
        return data

    def get_moneybird_info(self):
        return {
            "id": self.moneybird_id,
            "pk": self.member.pk,
        }

    def __str__(self):
        return f"Moneybird contact for {self.member}"

    class Meta:
        verbose_name = _("moneybird contact")
        verbose_name_plural = _("moneybird contacts")


class MoneybirdExternalInvoice(models.Model):
    payable_model = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.CharField(max_length=255)
    payable_object = GenericForeignKey("payable_model", "object_id")

    moneybird_invoice_id = models.CharField(
        verbose_name=_("moneybird invoice 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.",
    )

    @property
    def payable(self):
        payable = payables.get_payable(self.payable_object)
        if payable is None:
            raise ValueError(f"Could not find payable for {self.payable_object}")
        return payable

    @classmethod
    def create_for_object(cls, obj):
        content_type = ContentType.objects.get_for_model(obj)
        return cls.objects.create(
            payable_model=content_type,
            object_id=obj.pk,
        )

    @classmethod
    def get_for_object(cls, obj):
        content_type = ContentType.objects.get_for_model(obj)
        try:
            return cls.objects.get(
                payable_model=content_type,
                object_id=obj.pk,
            )
        except cls.DoesNotExist:
            return None

    def to_moneybird(self):
        moneybird = get_moneybird_api_service()

        contact_id = settings.MONEYBIRD_UNKNOWN_PAYER_CONTACT_ID

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

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

        period = period_for_payable_model(self.payable_object)

        tax_rate_id = tax_rate_for_payable_model(self.payable_object)

        project_name = project_name_for_payable_model(self.payable_object)

        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

        ledger_id = ledger_id_for_payable_model(self.payable_object)

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

        data = {
            "external_sales_invoice": {
                "contact_id": int(contact_id),
                "reference": f"{self.payable.payment_topic} [{self.payable.model.pk}]",
                "source": f"Concrexit ({settings.SITE_DOMAIN})",
                "date": invoice_date,
                "currency": "EUR",
                "prices_are_incl_tax": True,
                "details_attributes": [
                    {
                        "description": self.payable.payment_notes,
                        "price": str(self.payable.payment_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 ledger_id is not None:
            data["external_sales_invoice"]["details_attributes"][0]["ledger_id"] = int(
                ledger_id
            )

        if self.moneybird_details_attribute_id is not None:
            data["external_sales_invoice"]["details_attributes"][0]["id"] = int(
                self.moneybird_details_attribute_id
            )
        if period is not None:
            data["external_sales_invoice"]["details_attributes"][0]["period"] = period
        if tax_rate_id is not None:
            data["external_sales_invoice"]["details_attributes"][0][
                "tax_rate_id"
            ] = int(tax_rate_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",
        on_delete=models.CASCADE,
        verbose_name=_("payment"),
        related_name="moneybird_payment",
    )

    moneybird_financial_statement_id = models.CharField(
        verbose_name=_("moneybird financial statement id"), max_length=255
    )

    moneybird_financial_mutation_id = models.CharField(
        verbose_name=_("moneybird financial mutation id"), max_length=255
    )

    def __str__(self):
        return f"Moneybird payment for {self.payment}"

    def to_moneybird(self):
        data = {
            "date": self.payment.created_at.strftime("%Y-%m-%d"),
            "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}.",
            "sepa_fields": {
                "trtp": f"Concrexit - {self.payment.get_type_display()}",
                "name": self.payment.paid_by.get_full_name()
                if self.payment.paid_by
                else "",
                "remi": self.payment.notes,
                "eref": f"{self.payment.pk} {self.payment.created_at.astimezone():%Y-%m-%d %H:%M:%S}",
                "pref": self.payment.topic,
                "marf": f"Processed by {self.payment.processed_by.get_full_name()}"
                if self.payment.processed_by
                else "",
            },
            "amount": str(self.payment.amount),
            "contra_account_name": self.payment.paid_by.get_full_name()
            if self.payment.paid_by
            else "",
            "batch_reference": str(self.payment.pk),
        }
        if self.moneybird_financial_mutation_id:
            data["financial_mutation_id"] = int(self.moneybird_financial_mutation_id)
            data["financial_account_id"] = financial_account_id_for_payment_type(
                self.payment.type
            )

        return data

    class Meta:
        verbose_name = _("moneybird payment")
        verbose_name_plural = _("moneybird payments")