diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index e8597759b..a5cc6d291 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -1,8 +1,26 @@ """Registers admin interfaces for the models defined in this module.""" + from django.contrib import admin from django.contrib.admin import ModelAdmin +from django.shortcuts import redirect +from django.urls import reverse + +from sales.admin import ProductAdmin, ProductListAdmin, ProductListItemInline +from sales.models.product import Product, ProductList + +from .models import MerchandiseItem, MerchandiseProduct + -from .models import MerchandiseItem +class MerchandiseProductInline(admin.TabularInline): + """Inline admin interface for the merchandise products.""" + + model = MerchandiseProduct + extra = 0 + + fields = ( + "name", + "stock_value", + ) @admin.register(MerchandiseItem) @@ -16,3 +34,77 @@ class MerchandiseItemAdmin(ModelAdmin): "description", "image", ) + search_fields = ("name", "description") + list_display = ("name", "price") + list_filter = ("name", "price") + + inlines = [MerchandiseProductInline] + + +class MerchandiseDisabledProductAdmin(ProductAdmin): + def has_change_permission(self, request, obj=None): + if obj is not None and isinstance(obj.merchandiseproduct, MerchandiseProduct): + return False + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + if obj is not None and isinstance(obj.merchandiseproduct, MerchandiseProduct): + return False + return super().has_change_permission(request, obj) + + def change_view(self, request, object_id, form_url="", extra_context=None): + obj = self.get_object(request, object_id) + if obj is not None and isinstance(obj.merchandiseproduct, MerchandiseProduct): + return redirect( + reverse( + "admin:merchandise_merchandiseitem_change", + args=(obj.merchandiseproduct.merchandise_item.id,), + ) + ) + return super().change_view(request, object_id, form_url, extra_context) + + +admin.site.unregister(Product) +admin.site.register(Product, MerchandiseDisabledProductAdmin) + + +class MerchandiseDisabledProductListItemInline(ProductListItemInline): + fields = ( + "product", + "price", + "priority", + ) + + def has_delete_permission(self, request, obj=None): + return False + + def has_add_permission(self, request, obj): + return False + + def get_readonly_fields(self, request, obj=None): + readonly_fields = super().get_readonly_fields(request, obj) + return readonly_fields + ( + "product", + "price", + ) + + +class MerchandiseDisabledProductListAdmin(ProductListAdmin): + inlines = [ + MerchandiseDisabledProductListItemInline, + ] + + def get_readonly_fields(self, request, obj=None): + readonly_fields = super().get_readonly_fields(request, obj) + if obj is not None and obj.name == "Merchandise": + return readonly_fields + ("name",) + return readonly_fields + + def has_delete_permission(self, request, obj=None): + if obj is not None and obj.name == "Merchandise": + return False + return super().has_change_permission(request, obj) + + +admin.site.unregister(ProductList) +admin.site.register(ProductList, MerchandiseDisabledProductListAdmin) diff --git a/website/merchandise/apps.py b/website/merchandise/apps.py index fd3d26277..3c2efdbf2 100644 --- a/website/merchandise/apps.py +++ b/website/merchandise/apps.py @@ -11,6 +11,9 @@ class MerchandiseConfig(AppConfig): name = "merchandise" verbose_name = _("Merchandise") + def ready(self): + from . import signals # noqa: F401 + def menu_items(self): return { "categories": [{"name": "association", "title": "Association", "key": 1}], diff --git a/website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py b/website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py new file mode 100644 index 000000000..dff56d289 --- /dev/null +++ b/website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py @@ -0,0 +1,58 @@ +# Generated by Django 4.2.6 on 2023-11-12 21:51 + +from django.db import migrations, models +import django.db.models.deletion +import payments.models + + +class Migration(migrations.Migration): + dependencies = [ + ("sales", "0007_alter_productlistitem_options_and_more"), + ("merchandise", "0011_alter_merchandiseitem_image"), + ] + + operations = [ + migrations.AlterField( + model_name="merchandiseitem", + name="price", + field=payments.models.PaymentAmountField( + decimal_places=2, + help_text="Current sales price of the merchandise item per piece (incl. VAT).", + max_digits=8, + validators=[payments.models.validate_not_zero], + ), + ), + migrations.CreateModel( + name="MerchandiseProduct", + fields=[ + ( + "product_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="sales.product", + ), + ), + ( + "stock_value", + payments.models.PaymentAmountField( + decimal_places=2, + help_text="Current stock ledger value of this product per piece (excl. VAT).", + max_digits=8, + validators=[payments.models.validate_not_zero], + ), + ), + ( + "merchandise_item", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="merchandise.merchandiseitem", + ), + ), + ], + bases=("sales.product",), + ), + ] diff --git a/website/merchandise/models.py b/website/merchandise/models.py index cc0e0e16b..ef84fd36e 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -4,6 +4,8 @@ from thumbnails.fields import ImageField +from payments.models import PaymentAmountField +from sales.models.product import Product from utils.media.services import get_upload_to_function _merchandise_photo_upload_to = get_upload_to_function("merchandise") @@ -15,13 +17,11 @@ class MerchandiseItem(models.Model): This model describes merchandise items. """ - #: Name of the merchandise item. name = models.CharField(max_length=200) #: Price of the merchandise item - price = models.DecimalField( - max_digits=8, - decimal_places=2, + price = PaymentAmountField( + help_text="Current sales price of the merchandise item per piece (incl. VAT)." ) #: Description of the merchandise item @@ -61,3 +61,20 @@ def __str__(self): :rtype: str """ return str(self.name) + + +class MerchandiseProduct(Product): + """Merchandise products.""" + + merchandise_item = models.ForeignKey( + MerchandiseItem, + on_delete=models.CASCADE, + ) + + stock_value = PaymentAmountField( + help_text="Current stock ledger value of this product per piece (excl. VAT)." + ) + + def __str__(self): + """Give the name of the merchandise product in the currently active locale.""" + return f"{self.name} ({self.merchandise_item})" diff --git a/website/merchandise/services.py b/website/merchandise/services.py new file mode 100644 index 000000000..1c888a718 --- /dev/null +++ b/website/merchandise/services.py @@ -0,0 +1,48 @@ +import datetime + +from django.utils import timezone + +from activemembers.models import Board +from merchandise.models import MerchandiseProduct +from sales.models.product import ProductList +from sales.models.shift import Shift + + +def update_merchandise_product_list(): + product_list = ProductList.objects.get_or_create(name="Merchandise")[0] + merchandise_products = MerchandiseProduct.objects.all() + + for merchandise_product in merchandise_products: + item, _ = product_list.product_items.get_or_create( + product=merchandise_product, + defaults={"price": merchandise_product.merchandise_item.price}, + ) + item.price = merchandise_product.merchandise_item.price + item.save() + + return product_list + + +def create_daily_merchandise_sale_shift(): + today = timezone.now().date() + merchandise_product_list = update_merchandise_product_list() + active_board = Board.objects.filter(since__lte=today, until__gte=today) + + shift = Shift.objects.create( + title="Merchandise sales", + start=timezone.now(), + end=timezone.datetime.combine(today, datetime.time(23, 59, 59)), + product_list=merchandise_product_list, + ) + shift.managers.set(active_board) + shift.save() + + +def lock_merchandise_sale_shift(): + shifts = Shift.objects.filter(title="Merchandise sales").all() + for shift in shifts: + if shift.num_orders == 0: + shift.delete() + else: + shift.locked = True + shift.save() diff --git a/website/merchandise/signals.py b/website/merchandise/signals.py new file mode 100644 index 000000000..04061c7bc --- /dev/null +++ b/website/merchandise/signals.py @@ -0,0 +1,12 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from merchandise.models import MerchandiseItem, MerchandiseProduct + +from .services import update_merchandise_product_list + + +@receiver(post_save, sender=MerchandiseProduct) +@receiver(post_save, sender=MerchandiseItem) +def update_merchandise_product_list_on_save(sender, instance, **kwargs): + update_merchandise_product_list() diff --git a/website/merchandise/tasks.py b/website/merchandise/tasks.py new file mode 100644 index 000000000..146635fcf --- /dev/null +++ b/website/merchandise/tasks.py @@ -0,0 +1,9 @@ +from celery import shared_task + +from . import services + + +@shared_task +def renew_merchandise_sale_shift(): + services.lock_merchandise_sale_shift() + services.create_daily_merchandise_sale_shift() diff --git a/website/moneybirdsynchronization/admin.py b/website/moneybirdsynchronization/admin.py index acafb5ae5..ba8b13def 100644 --- a/website/moneybirdsynchronization/admin.py +++ b/website/moneybirdsynchronization/admin.py @@ -1,7 +1,12 @@ from django.contrib import admin from django.contrib.admin import RelatedOnlyFieldListFilter -from .models import MoneybirdContact, MoneybirdExternalInvoice, MoneybirdPayment +from .models import ( + MoneybirdContact, + MoneybirdExternalInvoice, + MoneybirdMerchandiseSaleJournal, + MoneybirdPayment, +) @admin.register(MoneybirdContact) @@ -100,3 +105,35 @@ def get_readonly_fields(self, request, obj=None): if not obj: return () return ("payment",) + + +@admin.register(MoneybirdMerchandiseSaleJournal) +class MoneybirdMerchandiseSaleJournalAdmin(admin.ModelAdmin): + list_display = ( + "order", + "moneybird_general_journal_document_id", + "needs_synchronization", + "needs_deletion", + ) + + readonly_fields = ( + "order", + "moneybird_general_journal_document_id", + "moneybird_details_debit_attribute_id", + "moneybird_details_credit_attribute_id", + "needs_synchronization", + "needs_deletion", + ) + + fields = ( + "order", + "moneybird_general_journal_document_id", + "moneybird_details_debit_attribute_id", + "moneybird_details_credit_attribute_id", + "external_invoice", + ) + + list_filter = ( + "needs_synchronization", + "needs_deletion", + ) diff --git a/website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py b/website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py new file mode 100644 index 000000000..7c9efbd17 --- /dev/null +++ b/website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py @@ -0,0 +1,112 @@ +# Generated by Django 4.2.6 on 2023-11-01 09:33 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("sales", "0007_alter_productlistitem_options_and_more"), + ( + "moneybirdsynchronization", + "0007_alter_moneybirdpayment_moneybird_financial_mutation_id_and_more", + ), + ] + + operations = [ + migrations.CreateModel( + name="MoneybirdGeneralJournalDocument", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "moneybird_general_journal_document_id", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="moneybird general journal document id", + ), + ), + ( + "needs_synchronization", + models.BooleanField( + default=True, + help_text="Indicates that the journal has to be synchronized (again).", + ), + ), + ( + "needs_deletion", + models.BooleanField( + default=False, + help_text="Indicates that the journal has to be deleted from moneybird.", + ), + ), + ( + "moneybird_details_debit_attribute_id", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="moneybird details attribute id (debit)", + ), + ), + ( + "moneybird_details_credit_attribute_id", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="moneybird details attribute id (credit)", + ), + ), + ], + ), + migrations.CreateModel( + name="MoneybirdMerchandiseSaleJournal", + fields=[ + ( + "moneybirdgeneraljournaldocument_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="moneybirdsynchronization.moneybirdgeneraljournaldocument", + ), + ), + ( + "external_invoice", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="moneybird_journal_external_invoice", + to="moneybirdsynchronization.moneybirdexternalinvoice", + verbose_name="external invoice", + ), + ), + ( + "order", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to="sales.order", + verbose_name="order", + ), + ), + ], + options={ + "verbose_name": "moneybird general journal document", + "verbose_name_plural": "moneybird general journal documents", + }, + bases=("moneybirdsynchronization.moneybirdgeneraljournaldocument",), + ), + ] diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 0f299acbc..543f43577 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -11,12 +11,16 @@ from events.models import EventRegistration from members.models import Member -from moneybirdsynchronization.moneybird import get_moneybird_api_service +from merchandise.models import MerchandiseItem +from moneybirdsynchronization.moneybird import ( + MoneybirdAPIService, + 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 +from sales.models.order import Order, OrderItem def financial_account_id_for_payment_type(payment_type) -> Optional[int]: @@ -37,12 +41,29 @@ def project_name_for_payable_model(obj) -> Optional[str]: start_date = obj.food_event.event.start.strftime("%Y-%m-%d") return f"{obj.food_event.event.title} [{start_date}]" if isinstance(obj, Order): + if obj.shift.product_list.name == "Merchandise": + return None 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}") + return None + + +def project_id_for_project_name( + project_name: str, moneybird: MoneybirdAPIService +) -> Optional[int]: + 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 + return project_id def date_for_payable_model(obj) -> Union[datetime.datetime, datetime.date]: @@ -51,7 +72,7 @@ def date_for_payable_model(obj) -> Union[datetime.datetime, datetime.date]: if isinstance(obj, FoodOrder): return obj.food_event.event.start if isinstance(obj, Order): - return obj.shift.start + return obj.created_at.date() if isinstance(obj, (Registration, Renewal)): return obj.created_at.date() @@ -67,9 +88,12 @@ def period_for_payable_model(obj) -> Optional[str]: return None -def tax_rate_for_payable_model(obj) -> Optional[int]: +def tax_rate_id_for_payable_model(obj) -> Optional[int]: if isinstance(obj, (Registration, Renewal)): return settings.MONEYBIRD_ZERO_TAX_RATE_ID + + +def tax_rate_id_for_sales_order_item(obj: OrderItem) -> Optional[int]: return None @@ -79,6 +103,12 @@ def ledger_id_for_payable_model(obj) -> Optional[int]: return None +def ledger_id_for_sales_order_item(obj: OrderItem) -> Optional[int]: + if isinstance(obj.product, MerchandiseItem): + return settings.MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID + return None + + class MoneybirdProject(models.Model): name = models.CharField( _("Name"), @@ -272,24 +302,6 @@ def to_moneybird(self): 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,), @@ -299,40 +311,84 @@ def to_moneybird(self): "external_sales_invoice": { "contact_id": int(contact_id), "reference": f"{self.payable.payment_topic} [{self.payable.model.pk}]", - "source": f"Concrexit ({settings.SITE_DOMAIN})", + "source": str(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) + project_name = project_name_for_payable_model(self.payable_object) + project_id = project_id_for_project_name(project_name, moneybird) + + data["external_sales_invoice"]["details_attributes"] = [] + + if isinstance(self.payable.model.payable_object, Order): + # Sales orders are a bit more complicated, because we need to add the individual order items. + order = self.payable.model.payable_object + + for item in order.order_items.all(): + ledger_id = ledger_id_for_sales_order_item(item) + tax_rate_id = tax_rate_id_for_sales_order_item(item) + data = { + "description": item.product_name, + "price": str(item.price), + "amount": item.amount, + } + if project_id is not None: + data["project_id"] = int(project_id) + if ledger_id is not None: + data["ledger_account_id"] = int(ledger_id) + if tax_rate_id is not None: + data["tax_rate_id"] = int(tax_rate_id) + + data["external_sales_invoice"]["details_attributes"].append(data) + + if order.discount: + data = { + "description": "Discout", + "price": str(-1 * order.discount), + } + if project_id is not None: + data["project_id"] = int(project_id) + + data["external_sales_invoice"]["details_attributes"].append(data) + else: + data["external_sales_invoice"]["details_attributes"] = [ + { + "description": self.payable.payment_notes, + "price": str(self.payable.payment_amount), + }, + ] + if self.moneybird_details_attribute_id is not None: + data["external_sales_invoice"]["details_attributes"][0]["id"] = int( + self.moneybird_details_attribute_id + ) + + ledger_id = ledger_id_for_payable_model(self.payable_object) + if ledger_id is not None: + data["external_sales_invoice"]["details_attributes"][0][ + "ledger_account_id" + ] = int(ledger_id) + + tax_rate_id = tax_rate_id_for_payable_model(self.payable_object) + if tax_rate_id is not None: + data["external_sales_invoice"]["details_attributes"][0][ + "tax_rate_id" + ] = int(tax_rate_id) + + period = period_for_payable_model(self.payable_object) + if period is not None: + data["external_sales_invoice"]["details_attributes"][0][ + "period" + ] = period + + if project_id is not None: + data["external_sales_invoice"]["details_attributes"][0][ + "project_id" + ] = int(project_id) return data @@ -397,3 +453,112 @@ def to_moneybird(self): class Meta: verbose_name = _("moneybird payment") verbose_name_plural = _("moneybird payments") + + +class MoneybirdGeneralJournalDocument(models.Model): + moneybird_general_journal_document_id = models.CharField( + verbose_name=_("moneybird general journal document id"), + max_length=255, + blank=True, + null=True, + ) + + needs_synchronization = models.BooleanField( + default=True, # The field is set False only when it has been successfully synchronized. + help_text="Indicates that the journal has to be synchronized (again).", + ) + + needs_deletion = models.BooleanField( + default=False, + help_text="Indicates that the journal has to be deleted from moneybird.", + ) + + moneybird_details_debit_attribute_id = models.CharField( + verbose_name=_("moneybird details attribute id (debit)"), + max_length=255, + blank=True, + null=True, + ) + + moneybird_details_credit_attribute_id = models.CharField( + verbose_name=_("moneybird details attribute id (credit)"), + max_length=255, + blank=True, + null=True, + ) + + def to_moneybird(self): + raise NotImplementedError + + def __str__(self): + return f"Moneybird journal for {self.order}" + + +class MoneybirdMerchandiseSaleJournal(MoneybirdGeneralJournalDocument): + order = models.OneToOneField( + Order, + on_delete=models.CASCADE, + verbose_name=_("order"), + ) + + external_invoice = models.OneToOneField( + MoneybirdExternalInvoice, + on_delete=models.DO_NOTHING, + verbose_name=_("external invoice"), + related_name="moneybird_journal_external_invoice", + blank=True, + null=True, + ) + + def to_moneybird(self): + items = self.order.order_items.all() + total_purchase_amount = sum( + i.product.product.merchandiseproduct.stock_value or 0 * i.amount + for i in items + ) + + merchandise_stock_ledger_id = settings.MONEYBIRD_MERCHANDISE_STOCK_LEDGER_ID + merchandise_costs_ledger_id = settings.MONEYBIRD_MERCHANDISE_COSTS_LEDGER_ID + + data = { + "general_journal_document": { + "date": self.order.payment.created_at.strftime("%Y-%m-%d"), + "reference": f"M {self.external_invoice.payable.model.pk}", + "general_journal_document_entries_attributes": { + "0": { + "ledger_account_id": merchandise_costs_ledger_id, + "debit": str(total_purchase_amount), + "credit": "0", + "description": self.order.payment.notes, + "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id + if self.external_invoice.payable.payment_payer + else None, + }, + "1": { + "ledger_account_id": merchandise_stock_ledger_id, + "debit": "0", + "credit": str(total_purchase_amount), + "description": self.order.payment.notes, + "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id + if self.external_invoice.payable.payment_payer + else None, + }, + }, + } + } + + if self.moneybird_details_debit_attribute_id is not None: + data["general_journal_document"][ + "general_journal_document_entries_attributes" + ][0]["id"] = int(self.moneybird_details_debit_attribute_id) + + if self.moneybird_details_credit_attribute_id is not None: + data["general_journal_document"][ + "general_journal_document_entries_attributes" + ][1]["id"] = int(self.moneybird_details_credit_attribute_id) + + return data + + class Meta: + verbose_name = _("moneybird general journal document") + verbose_name_plural = _("moneybird general journal documents") diff --git a/website/moneybirdsynchronization/moneybird.py b/website/moneybirdsynchronization/moneybird.py index d576c2f0a..330b38163 100644 --- a/website/moneybirdsynchronization/moneybird.py +++ b/website/moneybirdsynchronization/moneybird.py @@ -81,6 +81,21 @@ def unlink_mutation_from_booking( {"booking_type": booking_type, "booking_id": booking_id}, ) + def create_general_journal_document(self, document_data): + return self._administration.post( + "documents/general_journal_documents", document_data + ) + + def update_general_journal_document(self, document_id, document_data): + return self._administration.patch( + f"documents/general_journal_documents/{document_id}", document_data + ) + + def delete_general_journal_document(self, document_id): + return self._administration.delete( + f"documents/general_journal_documents/{document_id}" + ) + def get_moneybird_api_service(): if ( diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 2c51393bf..b123f47e5 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -10,11 +10,14 @@ from events.models import EventRegistration from members.models import Member +from merchandise.models import MerchandiseProduct from moneybirdsynchronization.administration import Administration from moneybirdsynchronization.emails import send_sync_error from moneybirdsynchronization.models import ( MoneybirdContact, MoneybirdExternalInvoice, + MoneybirdGeneralJournalDocument, + MoneybirdMerchandiseSaleJournal, MoneybirdPayment, financial_account_id_for_payment_type, ) @@ -89,6 +92,7 @@ def create_or_update_external_invoice(obj): 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"] @@ -165,6 +169,64 @@ def delete_external_invoice(obj): external_invoice.delete() +def create_or_update_merchandise_sale_journal(obj): + if not settings.MONEYBIRD_SYNC_ENABLED: + return None + + moneybird = get_moneybird_api_service() + external_invoice = create_or_update_external_invoice(obj) + + merchandise_sale_journal, _ = MoneybirdMerchandiseSaleJournal.objects.get_or_create( + order=obj + ) + merchandise_sale_journal.external_invoice = external_invoice + merchandise_sale_journal.save() + + # Apparently each journal line has a unique id so for now we just delete and create again + if merchandise_sale_journal.moneybird_general_journal_document_id: + moneybird.update_general_journal_document( + merchandise_sale_journal.moneybird_general_journal_document_id, + merchandise_sale_journal.to_moneybird(), + ) + else: + response = moneybird.create_general_journal_document( + merchandise_sale_journal.to_moneybird(), + ) + + merchandise_sale_journal.moneybird_general_journal_document_id = response["id"] + merchandise_sale_journal.moneybird_details_debit_attribute_id = response[ + "general_journal_document_entries" + ][0]["id"] + merchandise_sale_journal.moneybird_details_credit_attribute_id = response[ + "general_journal_document_entries" + ][1]["id"] + + merchandise_sale_journal.needs_synchronization = False + merchandise_sale_journal.save() + + +def delete_merchandise_sale_journal(obj): + if not settings.MONEYBIRD_SYNC_ENABLED: + return None + + try: + merchandise_sale_journal = MoneybirdMerchandiseSaleJournal.objects.get( + order=obj + ) + except MoneybirdMerchandiseSaleJournal.DoesNotExist: + return None + + if merchandise_sale_journal.moneybird_general_journal_document_id is None: + merchandise_sale_journal.delete() + return None + + moneybird = get_moneybird_api_service() + moneybird.delete_general_journal_document( + merchandise_sale_journal.moneybird_general_journal_document_id + ) + merchandise_sale_journal.delete() + + def synchronize_moneybird(): """Perform all synchronization to moneybird.""" if not settings.MONEYBIRD_SYNC_ENABLED: @@ -180,15 +242,18 @@ def synchronize_moneybird(): # already exist on moneybird. _sync_moneybird_payments() - # Delete invoices that have been marked for deletion. + # Delete invoices and journals that have been marked for deletion. _delete_invoices() + _delete_journals() - # Resynchronize outdated invoices. + # Resynchronize outdated invoices and journals. _sync_outdated_invoices() + _sync_outdated_merchandise_sale_journals() - # Push all invoices to moneybird. + # Push all invoices and journals to moneybird. _sync_food_orders() _sync_sales_orders() + _sync_merchandise_sales() _sync_registrations() _sync_renewals() _sync_event_registrations() @@ -216,6 +281,28 @@ def _delete_invoices(): send_sync_error(e, invoice) +def _delete_journals(): + """Delete the journals that have been marked for deletion from moneybird.""" + journals = MoneybirdGeneralJournalDocument.objects.filter(needs_deletion=True) + + if not journals.exists(): + return + + logger.info("Deleting %d journals.", journals.count()) + moneybird = get_moneybird_api_service() + + for journal in journals: + try: + if journal.moneybird_general_journal_document_id is not None: + moneybird.delete_general_journal_document( + journal.moneybird_general_journal_document_id + ) + journal.delete() + except Administration.Error as e: + logger.exception("Moneybird synchronization error: %s", e) + send_sync_error(e, journal) + + def _sync_outdated_invoices(): """Resynchronize all invoices that have been marked as outdated.""" invoices = MoneybirdExternalInvoice.objects.filter( @@ -235,6 +322,25 @@ def _sync_outdated_invoices(): logger.exception("Payable object for outdated invoice does not exist.") +def _sync_outdated_merchandise_sale_journals(): + """Resynchronize all journals that have been marked as outdated.""" + journals = MoneybirdMerchandiseSaleJournal.objects.filter( + needs_synchronization=True, needs_deletion=False + ).order_by("order__pk") + + if journals.exists(): + logger.info("Resynchronizing %d journals.", journals.count()) + for journal in journals: + try: + instance = journal.order + create_or_update_merchandise_sale_journal(instance) + except Administration.Error as e: + logger.exception("Moneybird synchronization error: %s", e) + send_sync_error(e, instance) + except ObjectDoesNotExist: + logger.exception("Payable object for outdated journal does not exist.") + + def _sync_contacts_with_outdated_mandates(): """Update contacts with outdated mandates. @@ -322,6 +428,35 @@ def _sync_sales_orders(): _try_create_or_update_external_invoices(sales_orders) +def _sync_merchandise_sales(): + """Create journals for sales orders that are merchandise sales.""" + merchandise_sales = Order.objects.filter( + shift__start__date__gte=settings.MONEYBIRD_START_DATE, + payment__isnull=False, + items__product__merchandiseproduct__in=MerchandiseProduct.objects.all(), + ).exclude( + Exists( + MoneybirdMerchandiseSaleJournal.objects.filter( + order=OuterRef("pk"), + ) + ) + ) + if not merchandise_sales.exists(): + return + + logger.info( + "Pushing %d merchandise sales journals to Moneybird.", + merchandise_sales.count(), + ) + + for instance in merchandise_sales: + try: + create_or_update_merchandise_sale_journal(instance) + except Administration.Error as e: + logger.exception("Moneybird synchronization error: %s", e) + send_sync_error(e, instance) + + def _sync_registrations(): """Create invoices for new, paid registrations.""" registrations = Registration.objects.filter( diff --git a/website/thaliawebsite/settings.py b/website/thaliawebsite/settings.py index 6be42eefe..de0e976a7 100644 --- a/website/thaliawebsite/settings.py +++ b/website/thaliawebsite/settings.py @@ -349,6 +349,10 @@ def from_env( # https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html CELERY_BEAT_SCHEDULE = { + "renew_merchandise_sale_shift": { + "task": "merchandise.tasks.renew_merchandise_sale_shift", + "schedule": crontab(minute=0, hour=0), + }, "synchronize_mailinglists": { "task": "mailinglists.tasks.sync_mail", "schedule": crontab(minute=30), @@ -1139,6 +1143,23 @@ def from_env( else None ) +MONEYBIRD_MERCHANDISE_STOCK_LEDGER_ID: Optional[int] = ( + int(os.environ.get("MONEYBIRD_MERCHANDISE_STOCK_LEDGER_ID")) + if os.environ.get("MONEYBIRD_MERCHANDISE_STOCK_LEDGER_ID") + else None +) +MONEYBIRD_MERCHANDISE_COSTS_LEDGER_ID: Optional[int] = ( + int(os.environ.get("MONEYBIRD_MERCHANDISE_COSTS_LEDGER_ID")) + if os.environ.get("MONEYBIRD_MERCHANDISE_COSTS_LEDGER_ID") + else None +) +MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID: Optional[int] = ( + int(os.environ.get("MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID")) + if os.environ.get("MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID") + else None +) + + MONEYBIRD_TPAY_FINANCIAL_ACCOUNT_ID: Optional[int] = ( int(os.environ.get("MONEYBIRD_TPAY_FINANCIAL_ACCOUNT_ID")) if os.environ.get("MONEYBIRD_TPAY_FINANCIAL_ACCOUNT_ID")