From 23246b3466231fa5a2104750f65ee49249b83301 Mon Sep 17 00:00:00 2001 From: movieminer Date: Mon, 16 Oct 2023 20:54:38 +0200 Subject: [PATCH 01/26] Implement merchandise sale in backend and to mb --- website/merchandise/admin.py | 205 +++++++++++++++++- website/merchandise/apps.py | 6 + website/merchandise/models.py | 147 +++++++++++++ website/merchandise/payables.py | 57 +++++ website/moneybirdsynchronization/admin.py | 17 +- website/moneybirdsynchronization/models.py | 77 +++++++ website/moneybirdsynchronization/moneybird.py | 15 ++ website/moneybirdsynchronization/services.py | 56 +++++ website/moneybirdsynchronization/signals.py | 13 ++ 9 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 website/merchandise/payables.py diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index e8597759b..6bf576906 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -1,8 +1,55 @@ """Registers admin interfaces for the models defined in this module.""" +import csv + from django.contrib import admin from django.contrib.admin import ModelAdmin +from django.db.models import QuerySet +from django.http import HttpRequest, HttpResponse +from django.utils.html import format_html +from django.utils.text import capfirst +from django.utils.translation import gettext_lazy as _ + +from moneybirdsynchronization import services as moneybird_services +from payments import services as payment_services +from payments.models import Payment, PaymentUser + +from .models import MerchandiseItem, MerchandiseSale, MerchandiseSaleItem + + +class MerchandiseSaleInline(admin.TabularInline): + """Inline for merchandise sales.""" + + model = MerchandiseSaleItem + extra = 0 + + fields = ("item", "amount", "total") + autocomplete_fields = ("item",) + readonly_fields = ("total",) + + def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): + if not obj: + return ("total",) + if obj.payment: + return ( + "item", + "amount", + "total", + ) + return super().get_readonly_fields(request, obj) + + def has_delete_permission(self, request, obj=None): + if isinstance(obj, MerchandiseSale): + if obj.payment: + return False -from .models import MerchandiseItem + return super().has_delete_permission(request, obj) + + def has_add_permission(self, request, obj=None): + if isinstance(obj, MerchandiseSale): + if obj.payment: + return False + + return super().has_add_permission(request, obj) @admin.register(MerchandiseItem) @@ -16,3 +63,159 @@ class MerchandiseItemAdmin(ModelAdmin): "description", "image", ) + search_fields = ("name", "description") + + +@admin.register(MerchandiseSale) +class MerchandiseSaleAdmin(admin.ModelAdmin): + """Manage the merch payments.""" + + inlines = [MerchandiseSaleInline] + list_display = ( + "created_at", + "paid_by_link", + "total_amount", + "type", + "payment_link", + ) + list_filter = ["type"] + date_hierarchy = "created_at" + fields = ( + "created_at", + "paid_by", + "type", + "payment", + "total_amount", + "notes", + ) + readonly_fields = ( + "created_at", + "paid_by", + "total_amount", + "type", + "payment", + "notes", + ) + search_fields = ( + "items__item__name", + "paid_by__username", + "paid_by__first_name", + "paid_by__last_name", + "notes", + "total_amount", + ) + ordering = ("-created_at",) + autocomplete_fields = ("paid_by",) + actions = [ + "export_csv", + ] + + @staticmethod + def _member_link(member: PaymentUser) -> str: + return ( + format_html( + "{}", member.get_absolute_url(), member.get_full_name() + ) + if member + else None + ) + + def paid_by_link(self, obj: MerchandiseSale) -> str: + return self._member_link(obj.paid_by) + + paid_by_link.admin_order_field = "paid_by" + paid_by_link.short_description = _("paid by") + + @staticmethod + def _payment_link(payment: Payment) -> str: + if payment: + return format_html( + "{}", payment.get_admin_url(), str(payment) + ) + + def payment_link(self, obj: MerchandiseSale) -> str: + if obj.payment: + return self._payment_link(obj.payment) + return None + + payment_link.admin_order_field = "payment" + payment_link.short_description = _("payment") + + def has_delete_permission(self, request, obj=None): + if isinstance(obj, MerchandiseSale): + if obj.payment.batch and obj.payment.batch.processed: + return False + if ( + "merchandisesale/" in request.path + and request.POST + and request.POST.get("action") == "delete_selected" + ): + for sale_id in request.POST.getlist("_selected_action"): + sale = MerchandiseSale.objects.get(id=sale_id) + if sale.payment.batch and sale.payment.batch.processed: + return False + + return super().has_delete_permission(request, obj) + + def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): + if not obj: + return "created_at", "total_amount", "payment" + if obj.payment: + return ( + "created_at", + "paid_by", + "total_amount", + "type", + "payment", + "notes", + ) + return super().get_readonly_fields(request, obj) + + def save_related(self, request, form, formsets, change): + obj = form.instance + for formset in formsets: + formset.save() + total_amount = sum([item.total for item in obj.sale_items.all()]) + obj.total_amount = total_amount + obj.save() + + obj.payment = payment_services.create_payment( + model_payable=obj, processed_by=request.user, pay_type=obj.type + ) + obj.save() + moneybird_services.create_or_update_merchandise_sale(obj) + + super().save_related(request, form, formsets, change) + + def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse: + """Export a CSV of payments. + + :param request: Request + :param queryset: Items to be exported + """ + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment;filename="merchandise_sales.csv"' + writer = csv.writer(response) + headers = [ + _("created"), + _("payer id"), + _("payer name"), + _("total_amount"), + _("type"), + _("notes"), + ] + writer.writerow([capfirst(x) for x in headers]) + for sale in queryset: + writer.writerow( + [ + sale.created_at, + sale.paid_by.pk if sale.paid_by else "-", + sale.paid_by.get_full_name() if sale.paid_by else "-", + sale.total_amount, + sale.get_type_display(), + sale.notes, + ] + ) + return response + + export_csv.short_description = _("Export") diff --git a/website/merchandise/apps.py b/website/merchandise/apps.py index fd3d26277..970857bbd 100644 --- a/website/merchandise/apps.py +++ b/website/merchandise/apps.py @@ -23,3 +23,9 @@ def menu_items(self): }, ], } + + def ready(self): + """Register the payable when the app is ready.""" + from .payables import register + + register() diff --git a/website/merchandise/models.py b/website/merchandise/models.py index c98df2c2a..dd245ae6a 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -1,8 +1,16 @@ """Models for the merchandise database tables.""" +import uuid + from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from queryable_properties.managers import QueryablePropertiesManager from thumbnails.fields import ImageField +from members.models import Member +from payments.models import Payment, PaymentAmountField from thaliawebsite.storage.backend import get_public_storage @@ -58,3 +66,142 @@ def __str__(self): :rtype: str """ return str(self.name) + + +class MerchandiseSale(models.Model): + """Describes a merchandise sale.""" + + objects = QueryablePropertiesManager() + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + created_at = models.DateTimeField(_("created at"), default=timezone.now) + + items = models.ManyToManyField( + MerchandiseItem, + through="MerchandiseSaleItem", + verbose_name=_("items"), + ) + + paid_by = models.ForeignKey( + Member, + models.PROTECT, + verbose_name=_("paid by"), + related_name="merchandise_sale_set", + blank=False, + null=True, + ) + + CASH = "cash_payment" + CARD = "card_payment" + TPAY = "tpay_payment" + WIRE = "wire_payment" + + PAYMENT_TYPE = ( + (CASH, _("Cash payment")), + (CARD, _("Card payment")), + (TPAY, _("Thalia Pay payment")), + (WIRE, _("Wire payment")), + ) + + type = models.CharField( + verbose_name=_("type"), + blank=False, + null=False, + max_length=20, + choices=PAYMENT_TYPE, + ) + + payment = models.OneToOneField( + Payment, + models.CASCADE, + verbose_name=_("payment"), + related_name="merchandise_sale_set", + blank=True, + null=True, + ) + + total_amount = PaymentAmountField( + allow_zero=True, verbose_name=_("total amount"), null=True + ) + + notes = models.TextField(verbose_name=_("notes"), blank=True, null=True) + + @property + def sale_description(self): + return "Merch:" + ", ".join( + [str(item.amount) + "x " + str(item.item) for item in self.sale_items.all()] + ) + + def get_absolute_url(self): + return reverse("admin:merchandise_sales_change", args=[str(self.pk)]) + + class Meta: + verbose_name = _("merchandise sale") + verbose_name_plural = _("merchandise sales") + + def __str__(self): + return _("Merchandise sale of {total_amount}").format( + total_amount=self.total_amount + ) + + +class MerchandiseSaleItem(models.Model): + """Describes a merchandise sale item.""" + + sale = models.ForeignKey( + MerchandiseSale, + models.CASCADE, + verbose_name=_("sale"), + related_name="sale_items", + blank=False, + null=True, + ) + + item = models.ForeignKey( + MerchandiseItem, + models.PROTECT, + verbose_name=_("item"), + related_name="item_merchandise_sale_item_set", + blank=True, + null=True, + ) + + amount = models.PositiveSmallIntegerField( + verbose_name=_("amount"), + default=1, + blank=False, + null=True, + ) + + total = PaymentAmountField( + verbose_name=_("total"), + allow_zero=False, + blank=False, + null=True, + ) + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + if self.amount == 0: + if self.pk: + self.delete() + else: + return + + self.total = self.item.price * self.amount + + super().save(force_insert, force_update, using, update_fields) + self.sale.save() + + def __str__(self): + return _("Sale: {sale}").format(sale=self.sale) + + class Meta: + verbose_name = _("sale item") + verbose_name_plural = _("sale items") + + def delete(self, using=None, keep_parents=False): + super().delete(using, keep_parents) + self.sale.save() diff --git a/website/merchandise/payables.py b/website/merchandise/payables.py new file mode 100644 index 000000000..ac16e0307 --- /dev/null +++ b/website/merchandise/payables.py @@ -0,0 +1,57 @@ +from django.utils.functional import classproperty + +from merchandise.models import MerchandiseSale, MerchandiseSaleItem +from payments.payables import Payable, payables + + +class MerchandiseSalePayable(Payable): + @property + def payment_amount(self): + return self.model.total_amount + + @property + def payment_topic(self): + return f"{self.model.sale_description}" + + @property + def payment_notes(self): + return f"{self.model.sale_description}" + + @property + def payment_payer(self): + return self.model.paid_by + + @property + def paying_allowed(self): + return True + + @property + def tpay_allowed(self): + return True + + @classproperty + def immutable_after_payment(self): + return True + + @classproperty + def immutable_foreign_key_models(self): + return {MerchandiseSaleItem: "sale"} + + @classproperty + def immutable_model_fields_after_payment(self): + return { + MerchandiseSale: [ + "items", + "sale_description", + "total_amount", + "paid_by", + ], + MerchandiseSaleItem: ["item", "sale", "total", "amount"], + } + + def can_manage_payment(self, member): + return True + + +def register(): + payables.register(MerchandiseSale, MerchandiseSalePayable) diff --git a/website/moneybirdsynchronization/admin.py b/website/moneybirdsynchronization/admin.py index acafb5ae5..92ed06129 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,13 @@ def get_readonly_fields(self, request, obj=None): if not obj: return () return ("payment",) + + +@admin.register(MoneybirdMerchandiseSaleJournal) +class MoneybirdMerchandiseSaleJournalAdmin(admin.ModelAdmin): + list_display = ("merchandise_sale", "moneybird_general_journal_document_id") + + readonly_fields = ( + "merchandise_sale", + "moneybird_general_journal_document_id", + ) diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 4c84d12d3..db1aad9ba 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -11,6 +11,7 @@ from events.models import EventRegistration from members.models import Member +from merchandise.models import MerchandiseSale from moneybirdsynchronization.moneybird import get_moneybird_api_service from payments.models import BankAccount, Payment from payments.payables import payables @@ -41,6 +42,8 @@ def project_name_for_payable_model(obj) -> Optional[str]: return f"{obj.shift} [{start_date}]" if isinstance(obj, (Registration, Renewal)): return None + if isinstance(obj, MerchandiseSale): + return None raise ValueError(f"Unknown payable model {obj}") @@ -54,6 +57,8 @@ def date_for_payable_model(obj) -> Union[datetime.datetime, datetime.date]: return obj.shift.start if isinstance(obj, (Registration, Renewal)): return obj.created_at.date() + if isinstance(obj, MerchandiseSale): + return obj.created_at.date() raise ValueError(f"Unknown payable model {obj}") @@ -64,6 +69,18 @@ def ledger_id_for_payable_model(obj) -> Optional[int]: return None +def ledger_id_for_merchandise_stock(obj) -> Optional[int]: + if isinstance(obj, MerchandiseSale): + return settings.MONEYBIRD_MERCHANDISE_STOCK_LEDGER_ID + return None + + +def ledger_id_for_merchandise_sale(obj) -> Optional[int]: + if isinstance(obj, MerchandiseSale): + return settings.MONEYBIRD_MERCHANDISE_LEDGER_ID + return None + + class MoneybirdProject(models.Model): name = models.CharField( _("Name"), @@ -370,3 +387,63 @@ def to_moneybird(self): class Meta: verbose_name = _("moneybird payment") verbose_name_plural = _("moneybird payments") + + +class MoneybirdMerchandiseSaleJournal(models.Model): + merchandise_sale = models.OneToOneField( + MerchandiseSale, + on_delete=models.DO_NOTHING, + verbose_name=_("merchandise sale"), + related_name="moneybird_journal_merchandise_sale", + ) + + external_invoice = models.OneToOneField( + MoneybirdExternalInvoice, + on_delete=models.DO_NOTHING, + verbose_name=_("external invoice"), + related_name="moneybird_journal_external_invoice", + blank=True, + null=True, + ) + + moneybird_general_journal_document_id = models.CharField( + verbose_name=_("moneybird general journal document id"), + max_length=255, + blank=True, + null=True, + ) + + def __str__(self): + return f"Moneybird journal for {self.merchandise_sale}" + + def to_moneybird(self): + merchandise_stock_ledger_id = ledger_id_for_merchandise_stock( + self.merchandise_sale + ) + merchandise_ledger_id = ledger_id_for_merchandise_sale(self.merchandise_sale) + data = { + "general_journal_document": { + "date": self.merchandise_sale.payment.created_at.strftime("%Y-%m-%d"), + "reference": f"{self.external_invoice.payable.payment_topic} [{self.external_invoice.payable.model.pk}]", + "general_journal_document_entries_attributes": { + "0": { + "ledger_account_id": merchandise_ledger_id, + "debit": str(self.merchandise_sale.total_amount), + "credit": "0", + "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, + }, + "1": { + "ledger_account_id": merchandise_stock_ledger_id, + "debit": "0", + "credit": str(self.merchandise_sale.total_amount), + "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, + }, + }, + } + } + + return data + + class Meta: + verbose_name = _("moneybird merchandise sale journal") + verbose_name_plural = _("moneybird merchandise sale journals") 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..de21f1328 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -15,6 +15,7 @@ from moneybirdsynchronization.models import ( MoneybirdContact, MoneybirdExternalInvoice, + MoneybirdMerchandiseSaleJournal, MoneybirdPayment, financial_account_id_for_payment_type, ) @@ -538,3 +539,58 @@ def process_thalia_pay_batch(batch): } } ) + + +def create_or_update_merchandise_sale(sale): + if not settings.MONEYBIRD_SYNC_ENABLED: + return None + + moneybird = get_moneybird_api_service() + external_invoice = create_or_update_external_invoice(sale) + + merchandise_sale_journal, _ = MoneybirdMerchandiseSaleJournal.objects.get_or_create( + merchandise_sale=sale + ) + merchandise_sale_journal.external_invoice = external_invoice + merchandise_sale_journal.save() + + 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.save() + + +def delete_merchandise_sale(sale): + if not settings.MONEYBIRD_SYNC_ENABLED: + return None + + try: + merchandise_sale_journal = MoneybirdMerchandiseSaleJournal.objects.get( + merchandise_sale=sale + ) + except MoneybirdMerchandiseSaleJournal.DoesNotExist: + return None + + if sale.payment is not None: + delete_moneybird_payment(sale.payment.moneybird_payment) + sale.payment.delete() + + if merchandise_sale_journal.external_invoice is not None: + delete_external_invoice(sale) + + 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() diff --git a/website/moneybirdsynchronization/signals.py b/website/moneybirdsynchronization/signals.py index 66f54da5c..e849b1382 100644 --- a/website/moneybirdsynchronization/signals.py +++ b/website/moneybirdsynchronization/signals.py @@ -196,3 +196,16 @@ def post_processed_batch(sender, instance, **kwargs): except Administration.Error as e: logger.exception("Moneybird synchronization error: %s", e) send_sync_error(e, instance) + logging.exception("Moneybird synchronization error: %s", e) + + +@suspendingreceiver( + post_delete, + sender="merchandise.MerchandiseSale", +) +def post_merchandise_sale_delete(sender, instance, **kwargs): + try: + services.delete_merchandise_sale(instance) + except Administration.Error as e: + send_sync_error(e, instance) + logging.exception("Moneybird synchronization error: %s", e) From f74f5cb6155adcd5cc1aa92c04478b5fa342a1fc Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Wed, 18 Oct 2023 00:05:18 +0200 Subject: [PATCH 02/26] parent cb8b4d9d631465e54739de5f58d58fa85eedf785 author Thijs de Jong 1697580318 +0200 committer movieminer 1697613494 +0200 parent cb8b4d9d631465e54739de5f58d58fa85eedf785 author Thijs de Jong 1697580318 +0200 committer movieminer 1697613467 +0200 Add purchase price to merchandise Update settings.py Update models.py Update models.py Update payables.py Update admin.py Update models.py Update models.py Update settings.py Update models.py Update payables.py Update models.py Add purchase price to merchandise Update settings.py Update models.py Update payables.py Update admin.py Update models.py Update settings.py Update models.py Update payables.py Update models.py Add purchase price to merchandise --- website/merchandise/admin.py | 19 ++++++++++++---- website/merchandise/models.py | 24 ++++++++++++++++++-- website/merchandise/payables.py | 5 +++-- website/moneybirdsynchronization/models.py | 26 +++++++--------------- website/thaliawebsite/settings.py | 17 ++++++++++++++ 5 files changed, 65 insertions(+), 26 deletions(-) diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index 6bf576906..d65b3d796 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -22,18 +22,19 @@ class MerchandiseSaleInline(admin.TabularInline): model = MerchandiseSaleItem extra = 0 - fields = ("item", "amount", "total") + fields = ("item", "amount", "total", "purchase_total",) autocomplete_fields = ("item",) - readonly_fields = ("total",) + readonly_fields = ("total","purchase_total",) def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): if not obj: - return ("total",) + return ("total","purchase_total",) if obj.payment: return ( "item", "amount", "total", + "purchase_total", ) return super().get_readonly_fields(request, obj) @@ -60,6 +61,7 @@ class MerchandiseItemAdmin(ModelAdmin): fields = ( "name", "price", + "purchase_price", "description", "image", ) @@ -75,6 +77,7 @@ class MerchandiseSaleAdmin(admin.ModelAdmin): "created_at", "paid_by_link", "total_amount", + "total_purchase_amount", "type", "payment_link", ) @@ -86,12 +89,14 @@ class MerchandiseSaleAdmin(admin.ModelAdmin): "type", "payment", "total_amount", + "total_purchase_amount", "notes", ) readonly_fields = ( "created_at", "paid_by", "total_amount", + "total_purchase_amount", "type", "payment", "notes", @@ -103,6 +108,7 @@ class MerchandiseSaleAdmin(admin.ModelAdmin): "paid_by__last_name", "notes", "total_amount", + "total_purchase_amount", ) ordering = ("-created_at",) autocomplete_fields = ("paid_by",) @@ -159,12 +165,13 @@ def has_delete_permission(self, request, obj=None): def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): if not obj: - return "created_at", "total_amount", "payment" + return "created_at", "total_amount", "total_purchase_amount", "payment" if obj.payment: return ( "created_at", "paid_by", "total_amount", + "total_purchase_amount", "type", "payment", "notes", @@ -177,6 +184,8 @@ def save_related(self, request, form, formsets, change): formset.save() total_amount = sum([item.total for item in obj.sale_items.all()]) obj.total_amount = total_amount + total_purchase_amount = sum([item.purchase_total for item in obj.sale_items.all()]) + obj.total_purchase_amount = total_purchase_amount obj.save() obj.payment = payment_services.create_payment( @@ -201,6 +210,7 @@ def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse: _("payer id"), _("payer name"), _("total_amount"), + _("total_purchase_amount"), _("type"), _("notes"), ] @@ -212,6 +222,7 @@ def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse: sale.paid_by.pk if sale.paid_by else "-", sale.paid_by.get_full_name() if sale.paid_by else "-", sale.total_amount, + sale.total_purchase_amount sale.get_type_display(), sale.notes, ] diff --git a/website/merchandise/models.py b/website/merchandise/models.py index dd245ae6a..bd39548c3 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -29,6 +29,12 @@ class MerchandiseItem(models.Model): decimal_places=2, ) + #: Purchase price of the merchandise item + purchase_price = models.DecimalField( + max_digits=8, + decimal_places=2, + ) + #: Description of the merchandise item description = models.TextField() @@ -125,11 +131,16 @@ class MerchandiseSale(models.Model): allow_zero=True, verbose_name=_("total amount"), null=True ) + total_purchase_amount = PaymentAmountField( + allow_zero=True, verbose_name=_("total purchase amount"), null=True + ) + + notes = models.TextField(verbose_name=_("notes"), blank=True, null=True) @property def sale_description(self): - return "Merch:" + ", ".join( + return "Merchandise sale:" + ", ".join( [str(item.amount) + "x " + str(item.item) for item in self.sale_items.all()] ) @@ -181,6 +192,14 @@ class MerchandiseSaleItem(models.Model): null=True, ) + purchase_total = PaymentAmountField( + verbose_name=_("purchase total"), + allow_zero=False, + blank=False, + null=True, + ) + + def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): @@ -191,12 +210,13 @@ def save( return self.total = self.item.price * self.amount + self.purchase_total = self.item.purchase_price * self.amount super().save(force_insert, force_update, using, update_fields) self.sale.save() def __str__(self): - return _("Sale: {sale}").format(sale=self.sale) + return str(self.sale) class Meta: verbose_name = _("sale item") diff --git a/website/merchandise/payables.py b/website/merchandise/payables.py index ac16e0307..01bfff5ef 100644 --- a/website/merchandise/payables.py +++ b/website/merchandise/payables.py @@ -11,7 +11,7 @@ def payment_amount(self): @property def payment_topic(self): - return f"{self.model.sale_description}" + return str(self.model) @property def payment_notes(self): @@ -44,9 +44,10 @@ def immutable_model_fields_after_payment(self): "items", "sale_description", "total_amount", + "total_purchase_amount", "paid_by", ], - MerchandiseSaleItem: ["item", "sale", "total", "amount"], + MerchandiseSaleItem: ["item", "sale", "total", "purchase_total" "amount"], } def can_manage_payment(self, member): diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index db1aad9ba..565bbad7c 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -66,18 +66,8 @@ def date_for_payable_model(obj) -> Union[datetime.datetime, datetime.date]: def ledger_id_for_payable_model(obj) -> Optional[int]: if isinstance(obj, (Registration, Renewal)): return settings.MONEYBIRD_CONTRIBUTION_LEDGER_ID - return None - - -def ledger_id_for_merchandise_stock(obj) -> Optional[int]: - if isinstance(obj, MerchandiseSale): - return settings.MONEYBIRD_MERCHANDISE_STOCK_LEDGER_ID - return None - - -def ledger_id_for_merchandise_sale(obj) -> Optional[int]: if isinstance(obj, MerchandiseSale): - return settings.MONEYBIRD_MERCHANDISE_LEDGER_ID + return settings.MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID return None @@ -417,25 +407,25 @@ def __str__(self): return f"Moneybird journal for {self.merchandise_sale}" def to_moneybird(self): - merchandise_stock_ledger_id = ledger_id_for_merchandise_stock( - self.merchandise_sale - ) - merchandise_ledger_id = ledger_id_for_merchandise_sale(self.merchandise_sale) + 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.merchandise_sale.payment.created_at.strftime("%Y-%m-%d"), "reference": f"{self.external_invoice.payable.payment_topic} [{self.external_invoice.payable.model.pk}]", "general_journal_document_entries_attributes": { "0": { - "ledger_account_id": merchandise_ledger_id, - "debit": str(self.merchandise_sale.total_amount), + "ledger_account_id": merchandise_costs_ledger_id, + "debit": str(self.merchandise_sale.total_purchase_amount), "credit": "0", + "description": self.merchandise_sale.sale_description, "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, }, "1": { "ledger_account_id": merchandise_stock_ledger_id, "debit": "0", - "credit": str(self.merchandise_sale.total_amount), + "credit": str(self.merchandise_sale.total_purchase_amount), + "description": self.merchandise_sale.sale_description, "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, }, }, diff --git a/website/thaliawebsite/settings.py b/website/thaliawebsite/settings.py index ed9908f77..58ec21b7a 100644 --- a/website/thaliawebsite/settings.py +++ b/website/thaliawebsite/settings.py @@ -1100,6 +1100,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") From 6ee71778fd012b4099c2aeebb4cdc29b1d19bba1 Mon Sep 17 00:00:00 2001 From: movieminer Date: Wed, 18 Oct 2023 16:47:06 +0200 Subject: [PATCH 03/26] Correct ledger and small fixes --- website/merchandise/admin.py | 23 ++- ...merchandiseitem_purchase_price_and_more.py | 181 ++++++++++++++++++ website/merchandise/models.py | 3 +- website/merchandise/payables.py | 2 +- .../0005_moneybirdmerchandisesalejournal.py | 61 ++++++ website/moneybirdsynchronization/models.py | 10 +- website/moneybirdsynchronization/services.py | 1 + 7 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 website/merchandise/migrations/0011_merchandisesale_merchandiseitem_purchase_price_and_more.py create mode 100644 website/moneybirdsynchronization/migrations/0005_moneybirdmerchandisesalejournal.py diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index d65b3d796..74a906ba4 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -22,13 +22,24 @@ class MerchandiseSaleInline(admin.TabularInline): model = MerchandiseSaleItem extra = 0 - fields = ("item", "amount", "total", "purchase_total",) + fields = ( + "item", + "amount", + "total", + "purchase_total", + ) autocomplete_fields = ("item",) - readonly_fields = ("total","purchase_total",) + readonly_fields = ( + "total", + "purchase_total", + ) def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): if not obj: - return ("total","purchase_total",) + return ( + "total", + "purchase_total", + ) if obj.payment: return ( "item", @@ -184,7 +195,9 @@ def save_related(self, request, form, formsets, change): formset.save() total_amount = sum([item.total for item in obj.sale_items.all()]) obj.total_amount = total_amount - total_purchase_amount = sum([item.purchase_total for item in obj.sale_items.all()]) + total_purchase_amount = sum( + [item.purchase_total for item in obj.sale_items.all()] + ) obj.total_purchase_amount = total_purchase_amount obj.save() @@ -222,7 +235,7 @@ def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse: sale.paid_by.pk if sale.paid_by else "-", sale.paid_by.get_full_name() if sale.paid_by else "-", sale.total_amount, - sale.total_purchase_amount + sale.total_purchase_amount, sale.get_type_display(), sale.notes, ] diff --git a/website/merchandise/migrations/0011_merchandisesale_merchandiseitem_purchase_price_and_more.py b/website/merchandise/migrations/0011_merchandisesale_merchandiseitem_purchase_price_and_more.py new file mode 100644 index 000000000..b9a1d41ae --- /dev/null +++ b/website/merchandise/migrations/0011_merchandisesale_merchandiseitem_purchase_price_and_more.py @@ -0,0 +1,181 @@ +# Generated by Django 4.2.6 on 2023-10-18 13:00 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import payments.models +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("members", "0047_remove_profile_receive_magazine"), + ("payments", "0024_alter_payment_paid_by"), + ("merchandise", "0010_alter_merchandiseitem_image"), + ] + + operations = [ + migrations.CreateModel( + name="MerchandiseSale", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "created_at", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="created at" + ), + ), + ( + "type", + models.CharField( + choices=[ + ("cash_payment", "Cash payment"), + ("card_payment", "Card payment"), + ("tpay_payment", "Thalia Pay payment"), + ("wire_payment", "Wire payment"), + ], + max_length=20, + verbose_name="type", + ), + ), + ( + "total_amount", + payments.models.PaymentAmountField( + decimal_places=2, + max_digits=8, + null=True, + validators=[payments.models.validate_not_zero], + verbose_name="total amount", + ), + ), + ( + "total_purchase_amount", + payments.models.PaymentAmountField( + decimal_places=2, + max_digits=8, + null=True, + validators=[payments.models.validate_not_zero], + verbose_name="total purchase amount", + ), + ), + ( + "notes", + models.TextField(blank=True, null=True, verbose_name="notes"), + ), + ], + options={ + "verbose_name": "merchandise sale", + "verbose_name_plural": "merchandise sales", + }, + ), + migrations.AddField( + model_name="merchandiseitem", + name="purchase_price", + field=models.DecimalField(decimal_places=2, default=0, max_digits=8), + ), + migrations.CreateModel( + name="MerchandiseSaleItem", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "amount", + models.PositiveSmallIntegerField( + default=1, null=True, verbose_name="amount" + ), + ), + ( + "total", + payments.models.PaymentAmountField( + decimal_places=2, + max_digits=8, + null=True, + validators=[payments.models.validate_not_zero], + verbose_name="total", + ), + ), + ( + "purchase_total", + payments.models.PaymentAmountField( + decimal_places=2, + max_digits=8, + null=True, + validators=[payments.models.validate_not_zero], + verbose_name="purchase total", + ), + ), + ( + "item", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="item_merchandise_sale_item_set", + to="merchandise.merchandiseitem", + verbose_name="item", + ), + ), + ( + "sale", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sale_items", + to="merchandise.merchandisesale", + verbose_name="sale", + ), + ), + ], + options={ + "verbose_name": "sale item", + "verbose_name_plural": "sale items", + }, + ), + migrations.AddField( + model_name="merchandisesale", + name="items", + field=models.ManyToManyField( + through="merchandise.MerchandiseSaleItem", + to="merchandise.merchandiseitem", + verbose_name="items", + ), + ), + migrations.AddField( + model_name="merchandisesale", + name="paid_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="merchandise_sale_set", + to="members.member", + verbose_name="paid by", + ), + ), + migrations.AddField( + model_name="merchandisesale", + name="payment", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="merchandise_sale_set", + to="payments.payment", + verbose_name="payment", + ), + ), + ] diff --git a/website/merchandise/models.py b/website/merchandise/models.py index bd39548c3..6427b550a 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -33,6 +33,7 @@ class MerchandiseItem(models.Model): purchase_price = models.DecimalField( max_digits=8, decimal_places=2, + default=0, ) #: Description of the merchandise item @@ -135,7 +136,6 @@ class MerchandiseSale(models.Model): allow_zero=True, verbose_name=_("total purchase amount"), null=True ) - notes = models.TextField(verbose_name=_("notes"), blank=True, null=True) @property @@ -199,7 +199,6 @@ class MerchandiseSaleItem(models.Model): null=True, ) - def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): diff --git a/website/merchandise/payables.py b/website/merchandise/payables.py index 01bfff5ef..ecdf88141 100644 --- a/website/merchandise/payables.py +++ b/website/merchandise/payables.py @@ -47,7 +47,7 @@ def immutable_model_fields_after_payment(self): "total_purchase_amount", "paid_by", ], - MerchandiseSaleItem: ["item", "sale", "total", "purchase_total" "amount"], + MerchandiseSaleItem: ["item", "sale", "total", "purchase_total", "amount"], } def can_manage_payment(self, member): diff --git a/website/moneybirdsynchronization/migrations/0005_moneybirdmerchandisesalejournal.py b/website/moneybirdsynchronization/migrations/0005_moneybirdmerchandisesalejournal.py new file mode 100644 index 000000000..4dc9b5681 --- /dev/null +++ b/website/moneybirdsynchronization/migrations/0005_moneybirdmerchandisesalejournal.py @@ -0,0 +1,61 @@ +# Generated by Django 4.2.6 on 2023-10-18 13:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("merchandise", "0011_merchandisesale_merchandiseitem_purchase_price_and_more"), + ("moneybirdsynchronization", "0004_moneybirdproject"), + ] + + operations = [ + migrations.CreateModel( + name="MoneybirdMerchandiseSaleJournal", + 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", + ), + ), + ( + "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", + ), + ), + ( + "merchandise_sale", + models.OneToOneField( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="moneybird_journal_merchandise_sale", + to="merchandise.merchandisesale", + verbose_name="merchandise sale", + ), + ), + ], + options={ + "verbose_name": "moneybird merchandise sale journal", + "verbose_name_plural": "moneybird merchandise sale journals", + }, + ), + ] diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 565bbad7c..54836580b 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -68,7 +68,8 @@ def ledger_id_for_payable_model(obj) -> Optional[int]: return settings.MONEYBIRD_CONTRIBUTION_LEDGER_ID if isinstance(obj, MerchandiseSale): return settings.MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID - return None + + raise ValueError(f"Unknown payable model {obj}") class MoneybirdProject(models.Model): @@ -305,15 +306,14 @@ def to_moneybird(self): project_id ) if ledger_id is not None: - data["external_sales_invoice"]["details_attributes"][0]["ledger_id"] = int( - ledger_id - ) + data["external_sales_invoice"]["details_attributes"][0][ + "ledger_account_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 ) - return data def __str__(self): diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index de21f1328..22f26fdc2 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -90,6 +90,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"] From 23f8672d26ce29153c45329ae7b3bd0c34d1864b Mon Sep 17 00:00:00 2001 From: movieminer Date: Thu, 19 Oct 2023 10:42:43 +0200 Subject: [PATCH 04/26] Textual changes --- website/merchandise/payables.py | 2 +- website/moneybirdsynchronization/models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/merchandise/payables.py b/website/merchandise/payables.py index ecdf88141..3a95b1be5 100644 --- a/website/merchandise/payables.py +++ b/website/merchandise/payables.py @@ -11,7 +11,7 @@ def payment_amount(self): @property def payment_topic(self): - return str(self.model) + return "Merchandise" @property def payment_notes(self): diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 54836580b..dc34203ec 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -412,7 +412,7 @@ def to_moneybird(self): data = { "general_journal_document": { "date": self.merchandise_sale.payment.created_at.strftime("%Y-%m-%d"), - "reference": f"{self.external_invoice.payable.payment_topic} [{self.external_invoice.payable.model.pk}]", + "reference": f"M {self.external_invoice.payable.model.pk}", "general_journal_document_entries_attributes": { "0": { "ledger_account_id": merchandise_costs_ledger_id, From c708f322806190f126cf559cb3c7114ea36de2d8 Mon Sep 17 00:00:00 2001 From: movieminer Date: Wed, 18 Oct 2023 19:34:21 +0200 Subject: [PATCH 05/26] Remove TPay and Wire as options --- website/merchandise/admin.py | 6 ++---- website/merchandise/models.py | 4 ---- website/merchandise/payables.py | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index 74a906ba4..d2bc4fba9 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -193,12 +193,10 @@ def save_related(self, request, form, formsets, change): obj = form.instance for formset in formsets: formset.save() - total_amount = sum([item.total for item in obj.sale_items.all()]) - obj.total_amount = total_amount - total_purchase_amount = sum( + obj.total_amount = sum([item.total for item in obj.sale_items.all()]) + obj.total_purchase_amount = sum( [item.purchase_total for item in obj.sale_items.all()] ) - obj.total_purchase_amount = total_purchase_amount obj.save() obj.payment = payment_services.create_payment( diff --git a/website/merchandise/models.py b/website/merchandise/models.py index 6427b550a..634b8d9cb 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -101,14 +101,10 @@ class MerchandiseSale(models.Model): CASH = "cash_payment" CARD = "card_payment" - TPAY = "tpay_payment" - WIRE = "wire_payment" PAYMENT_TYPE = ( (CASH, _("Cash payment")), (CARD, _("Card payment")), - (TPAY, _("Thalia Pay payment")), - (WIRE, _("Wire payment")), ) type = models.CharField( diff --git a/website/merchandise/payables.py b/website/merchandise/payables.py index 3a95b1be5..e48236d91 100644 --- a/website/merchandise/payables.py +++ b/website/merchandise/payables.py @@ -27,7 +27,7 @@ def paying_allowed(self): @property def tpay_allowed(self): - return True + return False @classproperty def immutable_after_payment(self): From 26e69ca3f1e025823ad788c04b3589cda88edaf2 Mon Sep 17 00:00:00 2001 From: movieminer Date: Thu, 26 Oct 2023 20:10:00 +0200 Subject: [PATCH 06/26] Switch merchandise sales to sales --- website/merchandise/admin.py | 431 +++++++++--------- website/merchandise/apps.py | 6 - ...merchandiseitem_purchase_price_and_more.py | 181 -------- ...0011_remove_merchandiseitem_id_and_more.py | 60 +++ website/merchandise/models.py | 305 ++++++------- website/merchandise/payables.py | 88 ++-- website/moneybirdsynchronization/admin.py | 17 +- .../0005_moneybirdmerchandisesalejournal.py | 61 --- website/moneybirdsynchronization/models.py | 129 +++--- website/moneybirdsynchronization/services.py | 109 +++-- website/moneybirdsynchronization/signals.py | 20 +- 11 files changed, 597 insertions(+), 810 deletions(-) delete mode 100644 website/merchandise/migrations/0011_merchandisesale_merchandiseitem_purchase_price_and_more.py create mode 100644 website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py delete mode 100644 website/moneybirdsynchronization/migrations/0005_moneybirdmerchandisesalejournal.py diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index d2bc4fba9..19af9c765 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -1,67 +1,56 @@ """Registers admin interfaces for the models defined in this module.""" -import csv from django.contrib import admin from django.contrib.admin import ModelAdmin -from django.db.models import QuerySet -from django.http import HttpRequest, HttpResponse -from django.utils.html import format_html -from django.utils.text import capfirst -from django.utils.translation import gettext_lazy as _ -from moneybirdsynchronization import services as moneybird_services -from payments import services as payment_services -from payments.models import Payment, PaymentUser - -from .models import MerchandiseItem, MerchandiseSale, MerchandiseSaleItem - - -class MerchandiseSaleInline(admin.TabularInline): - """Inline for merchandise sales.""" - - model = MerchandiseSaleItem - extra = 0 - - fields = ( - "item", - "amount", - "total", - "purchase_total", - ) - autocomplete_fields = ("item",) - readonly_fields = ( - "total", - "purchase_total", - ) - - def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): - if not obj: - return ( - "total", - "purchase_total", - ) - if obj.payment: - return ( - "item", - "amount", - "total", - "purchase_total", - ) - return super().get_readonly_fields(request, obj) - - def has_delete_permission(self, request, obj=None): - if isinstance(obj, MerchandiseSale): - if obj.payment: - return False - - return super().has_delete_permission(request, obj) - - def has_add_permission(self, request, obj=None): - if isinstance(obj, MerchandiseSale): - if obj.payment: - return False - - return super().has_add_permission(request, obj) +from .models import MerchandiseItem + +# class MerchandiseSaleInline(admin.TabularInline): +# """Inline for merchandise sales.""" + +# model = MerchandiseSaleItem +# extra = 0 + +# fields = ( +# "item", +# "amount", +# "total", +# "purchase_total", +# ) +# autocomplete_fields = ("item",) +# readonly_fields = ( +# "total", +# "purchase_total", +# ) + +# def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): +# if not obj: +# return ( +# "total", +# "purchase_total", +# ) +# if obj.payment: +# return ( +# "item", +# "amount", +# "total", +# "purchase_total", +# ) +# return super().get_readonly_fields(request, obj) + +# def has_delete_permission(self, request, obj=None): +# if isinstance(obj, MerchandiseSale): +# if obj.payment: +# return False + +# return super().has_delete_permission(request, obj) + +# def has_add_permission(self, request, obj=None): +# if isinstance(obj, MerchandiseSale): +# if obj.payment: +# return False + +# return super().has_add_permission(request, obj) @admin.register(MerchandiseItem) @@ -79,165 +68,165 @@ class MerchandiseItemAdmin(ModelAdmin): search_fields = ("name", "description") -@admin.register(MerchandiseSale) -class MerchandiseSaleAdmin(admin.ModelAdmin): - """Manage the merch payments.""" - - inlines = [MerchandiseSaleInline] - list_display = ( - "created_at", - "paid_by_link", - "total_amount", - "total_purchase_amount", - "type", - "payment_link", - ) - list_filter = ["type"] - date_hierarchy = "created_at" - fields = ( - "created_at", - "paid_by", - "type", - "payment", - "total_amount", - "total_purchase_amount", - "notes", - ) - readonly_fields = ( - "created_at", - "paid_by", - "total_amount", - "total_purchase_amount", - "type", - "payment", - "notes", - ) - search_fields = ( - "items__item__name", - "paid_by__username", - "paid_by__first_name", - "paid_by__last_name", - "notes", - "total_amount", - "total_purchase_amount", - ) - ordering = ("-created_at",) - autocomplete_fields = ("paid_by",) - actions = [ - "export_csv", - ] - - @staticmethod - def _member_link(member: PaymentUser) -> str: - return ( - format_html( - "{}", member.get_absolute_url(), member.get_full_name() - ) - if member - else None - ) - - def paid_by_link(self, obj: MerchandiseSale) -> str: - return self._member_link(obj.paid_by) - - paid_by_link.admin_order_field = "paid_by" - paid_by_link.short_description = _("paid by") - - @staticmethod - def _payment_link(payment: Payment) -> str: - if payment: - return format_html( - "{}", payment.get_admin_url(), str(payment) - ) - - def payment_link(self, obj: MerchandiseSale) -> str: - if obj.payment: - return self._payment_link(obj.payment) - return None - - payment_link.admin_order_field = "payment" - payment_link.short_description = _("payment") - - def has_delete_permission(self, request, obj=None): - if isinstance(obj, MerchandiseSale): - if obj.payment.batch and obj.payment.batch.processed: - return False - if ( - "merchandisesale/" in request.path - and request.POST - and request.POST.get("action") == "delete_selected" - ): - for sale_id in request.POST.getlist("_selected_action"): - sale = MerchandiseSale.objects.get(id=sale_id) - if sale.payment.batch and sale.payment.batch.processed: - return False - - return super().has_delete_permission(request, obj) - - def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): - if not obj: - return "created_at", "total_amount", "total_purchase_amount", "payment" - if obj.payment: - return ( - "created_at", - "paid_by", - "total_amount", - "total_purchase_amount", - "type", - "payment", - "notes", - ) - return super().get_readonly_fields(request, obj) - - def save_related(self, request, form, formsets, change): - obj = form.instance - for formset in formsets: - formset.save() - obj.total_amount = sum([item.total for item in obj.sale_items.all()]) - obj.total_purchase_amount = sum( - [item.purchase_total for item in obj.sale_items.all()] - ) - obj.save() - - obj.payment = payment_services.create_payment( - model_payable=obj, processed_by=request.user, pay_type=obj.type - ) - obj.save() - moneybird_services.create_or_update_merchandise_sale(obj) - - super().save_related(request, form, formsets, change) - - def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse: - """Export a CSV of payments. - - :param request: Request - :param queryset: Items to be exported - """ - response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment;filename="merchandise_sales.csv"' - writer = csv.writer(response) - headers = [ - _("created"), - _("payer id"), - _("payer name"), - _("total_amount"), - _("total_purchase_amount"), - _("type"), - _("notes"), - ] - writer.writerow([capfirst(x) for x in headers]) - for sale in queryset: - writer.writerow( - [ - sale.created_at, - sale.paid_by.pk if sale.paid_by else "-", - sale.paid_by.get_full_name() if sale.paid_by else "-", - sale.total_amount, - sale.total_purchase_amount, - sale.get_type_display(), - sale.notes, - ] - ) - return response - - export_csv.short_description = _("Export") +# @admin.register(MerchandiseSale) +# class MerchandiseSaleAdmin(admin.ModelAdmin): +# """Manage the merch payments.""" + +# inlines = [MerchandiseSaleInline] +# list_display = ( +# "created_at", +# "paid_by_link", +# "total_amount", +# "total_purchase_amount", +# "type", +# "payment_link", +# ) +# list_filter = ["type"] +# date_hierarchy = "created_at" +# fields = ( +# "created_at", +# "paid_by", +# "type", +# "payment", +# "total_amount", +# "total_purchase_amount", +# "notes", +# ) +# readonly_fields = ( +# "created_at", +# "paid_by", +# "total_amount", +# "total_purchase_amount", +# "type", +# "payment", +# "notes", +# ) +# search_fields = ( +# "items__item__name", +# "paid_by__username", +# "paid_by__first_name", +# "paid_by__last_name", +# "notes", +# "total_amount", +# "total_purchase_amount", +# ) +# ordering = ("-created_at",) +# autocomplete_fields = ("paid_by",) +# actions = [ +# "export_csv", +# ] + +# @staticmethod +# def _member_link(member: PaymentUser) -> str: +# return ( +# format_html( +# "{}", member.get_absolute_url(), member.get_full_name() +# ) +# if member +# else None +# ) + +# def paid_by_link(self, obj: MerchandiseSale) -> str: +# return self._member_link(obj.paid_by) + +# paid_by_link.admin_order_field = "paid_by" +# paid_by_link.short_description = _("paid by") + +# @staticmethod +# def _payment_link(payment: Payment) -> str: +# if payment: +# return format_html( +# "{}", payment.get_admin_url(), str(payment) +# ) + +# def payment_link(self, obj: MerchandiseSale) -> str: +# if obj.payment: +# return self._payment_link(obj.payment) +# return None + +# payment_link.admin_order_field = "payment" +# payment_link.short_description = _("payment") + +# def has_delete_permission(self, request, obj=None): +# if isinstance(obj, MerchandiseSale): +# if obj.payment.batch and obj.payment.batch.processed: +# return False +# if ( +# "merchandisesale/" in request.path +# and request.POST +# and request.POST.get("action") == "delete_selected" +# ): +# for sale_id in request.POST.getlist("_selected_action"): +# sale = MerchandiseSale.objects.get(id=sale_id) +# if sale.payment.batch and sale.payment.batch.processed: +# return False + +# return super().has_delete_permission(request, obj) + +# def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): +# if not obj: +# return "created_at", "total_amount", "total_purchase_amount", "payment" +# if obj.payment: +# return ( +# "created_at", +# "paid_by", +# "total_amount", +# "total_purchase_amount", +# "type", +# "payment", +# "notes", +# ) +# return super().get_readonly_fields(request, obj) + +# def save_related(self, request, form, formsets, change): +# obj = form.instance +# for formset in formsets: +# formset.save() +# obj.total_amount = sum([item.total for item in obj.sale_items.all()]) +# obj.total_purchase_amount = sum( +# [item.purchase_total for item in obj.sale_items.all()] +# ) +# obj.save() + +# obj.payment = payment_services.create_payment( +# model_payable=obj, processed_by=request.user, pay_type=obj.type +# ) +# obj.save() +# moneybird_services.create_or_update_merchandise_sale(obj) + +# super().save_related(request, form, formsets, change) + +# def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse: +# """Export a CSV of payments. + +# :param request: Request +# :param queryset: Items to be exported +# """ +# response = HttpResponse(content_type="text/csv") +# response["Content-Disposition"] = 'attachment;filename="merchandise_sales.csv"' +# writer = csv.writer(response) +# headers = [ +# _("created"), +# _("payer id"), +# _("payer name"), +# _("total_amount"), +# _("total_purchase_amount"), +# _("type"), +# _("notes"), +# ] +# writer.writerow([capfirst(x) for x in headers]) +# for sale in queryset: +# writer.writerow( +# [ +# sale.created_at, +# sale.paid_by.pk if sale.paid_by else "-", +# sale.paid_by.get_full_name() if sale.paid_by else "-", +# sale.total_amount, +# sale.total_purchase_amount, +# sale.get_type_display(), +# sale.notes, +# ] +# ) +# return response + +# export_csv.short_description = _("Export") diff --git a/website/merchandise/apps.py b/website/merchandise/apps.py index 970857bbd..fd3d26277 100644 --- a/website/merchandise/apps.py +++ b/website/merchandise/apps.py @@ -23,9 +23,3 @@ def menu_items(self): }, ], } - - def ready(self): - """Register the payable when the app is ready.""" - from .payables import register - - register() diff --git a/website/merchandise/migrations/0011_merchandisesale_merchandiseitem_purchase_price_and_more.py b/website/merchandise/migrations/0011_merchandisesale_merchandiseitem_purchase_price_and_more.py deleted file mode 100644 index b9a1d41ae..000000000 --- a/website/merchandise/migrations/0011_merchandisesale_merchandiseitem_purchase_price_and_more.py +++ /dev/null @@ -1,181 +0,0 @@ -# Generated by Django 4.2.6 on 2023-10-18 13:00 - -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import payments.models -import uuid - - -class Migration(migrations.Migration): - dependencies = [ - ("members", "0047_remove_profile_receive_magazine"), - ("payments", "0024_alter_payment_paid_by"), - ("merchandise", "0010_alter_merchandiseitem_image"), - ] - - operations = [ - migrations.CreateModel( - name="MerchandiseSale", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "created_at", - models.DateTimeField( - default=django.utils.timezone.now, verbose_name="created at" - ), - ), - ( - "type", - models.CharField( - choices=[ - ("cash_payment", "Cash payment"), - ("card_payment", "Card payment"), - ("tpay_payment", "Thalia Pay payment"), - ("wire_payment", "Wire payment"), - ], - max_length=20, - verbose_name="type", - ), - ), - ( - "total_amount", - payments.models.PaymentAmountField( - decimal_places=2, - max_digits=8, - null=True, - validators=[payments.models.validate_not_zero], - verbose_name="total amount", - ), - ), - ( - "total_purchase_amount", - payments.models.PaymentAmountField( - decimal_places=2, - max_digits=8, - null=True, - validators=[payments.models.validate_not_zero], - verbose_name="total purchase amount", - ), - ), - ( - "notes", - models.TextField(blank=True, null=True, verbose_name="notes"), - ), - ], - options={ - "verbose_name": "merchandise sale", - "verbose_name_plural": "merchandise sales", - }, - ), - migrations.AddField( - model_name="merchandiseitem", - name="purchase_price", - field=models.DecimalField(decimal_places=2, default=0, max_digits=8), - ), - migrations.CreateModel( - name="MerchandiseSaleItem", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "amount", - models.PositiveSmallIntegerField( - default=1, null=True, verbose_name="amount" - ), - ), - ( - "total", - payments.models.PaymentAmountField( - decimal_places=2, - max_digits=8, - null=True, - validators=[payments.models.validate_not_zero], - verbose_name="total", - ), - ), - ( - "purchase_total", - payments.models.PaymentAmountField( - decimal_places=2, - max_digits=8, - null=True, - validators=[payments.models.validate_not_zero], - verbose_name="purchase total", - ), - ), - ( - "item", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="item_merchandise_sale_item_set", - to="merchandise.merchandiseitem", - verbose_name="item", - ), - ), - ( - "sale", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="sale_items", - to="merchandise.merchandisesale", - verbose_name="sale", - ), - ), - ], - options={ - "verbose_name": "sale item", - "verbose_name_plural": "sale items", - }, - ), - migrations.AddField( - model_name="merchandisesale", - name="items", - field=models.ManyToManyField( - through="merchandise.MerchandiseSaleItem", - to="merchandise.merchandiseitem", - verbose_name="items", - ), - ), - migrations.AddField( - model_name="merchandisesale", - name="paid_by", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="merchandise_sale_set", - to="members.member", - verbose_name="paid by", - ), - ), - migrations.AddField( - model_name="merchandisesale", - name="payment", - field=models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="merchandise_sale_set", - to="payments.payment", - verbose_name="payment", - ), - ), - ] diff --git a/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py b/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py new file mode 100644 index 000000000..ecf949b5d --- /dev/null +++ b/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.6 on 2023-10-26 14:52 + +from django.db import migrations, models +import django.db.models.deletion + + +itemlist = [] + +class Migration(migrations.Migration): + dependencies = [ + ("sales", "0007_alter_productlistitem_options_and_more"), + ("merchandise", "0010_alter_merchandiseitem_image"), + ] + + def store_and_delete_merchandiseitems(apps, schema_editor): + MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") + itemlist = list(MerchandiseItem.objects.all()) + MerchandiseItem.objects.all().delete() + + def create_merchandiseitems(apps, schema_editor): + MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") + for item in itemlist: + MerchandiseItem.objects.create( + price=item.price, + description=item.description, + image=item.image, + purchase_price=item.purchase_price, + ) + + operations = [ + migrations.RunPython(store_and_delete_merchandiseitems), + migrations.RemoveField( + model_name="merchandiseitem", + name="id", + ), + migrations.RemoveField( + model_name="merchandiseitem", + name="name", + ), + migrations.AddField( + model_name="merchandiseitem", + name="product_ptr", + field=models.OneToOneField( + auto_created=True, + default="", + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="sales.product", + ), + preserve_default=False, + ), + migrations.RunPython(create_merchandiseitems), + migrations.AddField( + model_name="merchandiseitem", + name="purchase_price", + field=models.DecimalField(decimal_places=2, default=0, max_digits=8), + ), + ] diff --git a/website/merchandise/models.py b/website/merchandise/models.py index 634b8d9cb..481e7f2c9 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -1,28 +1,18 @@ """Models for the merchandise database tables.""" -import uuid - from django.db import models -from django.urls import reverse -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ -from queryable_properties.managers import QueryablePropertiesManager from thumbnails.fields import ImageField -from members.models import Member -from payments.models import Payment, PaymentAmountField +from sales.models.product import Product from thaliawebsite.storage.backend import get_public_storage -class MerchandiseItem(models.Model): +class MerchandiseItem(Product): """Merchandise items. 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, @@ -33,7 +23,6 @@ class MerchandiseItem(models.Model): purchase_price = models.DecimalField( max_digits=8, decimal_places=2, - default=0, ) #: Description of the merchandise item @@ -75,148 +64,148 @@ def __str__(self): return str(self.name) -class MerchandiseSale(models.Model): - """Describes a merchandise sale.""" - - objects = QueryablePropertiesManager() - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - created_at = models.DateTimeField(_("created at"), default=timezone.now) - - items = models.ManyToManyField( - MerchandiseItem, - through="MerchandiseSaleItem", - verbose_name=_("items"), - ) - - paid_by = models.ForeignKey( - Member, - models.PROTECT, - verbose_name=_("paid by"), - related_name="merchandise_sale_set", - blank=False, - null=True, - ) - - CASH = "cash_payment" - CARD = "card_payment" - - PAYMENT_TYPE = ( - (CASH, _("Cash payment")), - (CARD, _("Card payment")), - ) - - type = models.CharField( - verbose_name=_("type"), - blank=False, - null=False, - max_length=20, - choices=PAYMENT_TYPE, - ) - - payment = models.OneToOneField( - Payment, - models.CASCADE, - verbose_name=_("payment"), - related_name="merchandise_sale_set", - blank=True, - null=True, - ) - - total_amount = PaymentAmountField( - allow_zero=True, verbose_name=_("total amount"), null=True - ) - - total_purchase_amount = PaymentAmountField( - allow_zero=True, verbose_name=_("total purchase amount"), null=True - ) - - notes = models.TextField(verbose_name=_("notes"), blank=True, null=True) - - @property - def sale_description(self): - return "Merchandise sale:" + ", ".join( - [str(item.amount) + "x " + str(item.item) for item in self.sale_items.all()] - ) - - def get_absolute_url(self): - return reverse("admin:merchandise_sales_change", args=[str(self.pk)]) - - class Meta: - verbose_name = _("merchandise sale") - verbose_name_plural = _("merchandise sales") - - def __str__(self): - return _("Merchandise sale of {total_amount}").format( - total_amount=self.total_amount - ) - - -class MerchandiseSaleItem(models.Model): - """Describes a merchandise sale item.""" - - sale = models.ForeignKey( - MerchandiseSale, - models.CASCADE, - verbose_name=_("sale"), - related_name="sale_items", - blank=False, - null=True, - ) - - item = models.ForeignKey( - MerchandiseItem, - models.PROTECT, - verbose_name=_("item"), - related_name="item_merchandise_sale_item_set", - blank=True, - null=True, - ) - - amount = models.PositiveSmallIntegerField( - verbose_name=_("amount"), - default=1, - blank=False, - null=True, - ) - - total = PaymentAmountField( - verbose_name=_("total"), - allow_zero=False, - blank=False, - null=True, - ) - - purchase_total = PaymentAmountField( - verbose_name=_("purchase total"), - allow_zero=False, - blank=False, - null=True, - ) - - def save( - self, force_insert=False, force_update=False, using=None, update_fields=None - ): - if self.amount == 0: - if self.pk: - self.delete() - else: - return - - self.total = self.item.price * self.amount - self.purchase_total = self.item.purchase_price * self.amount - - super().save(force_insert, force_update, using, update_fields) - self.sale.save() - - def __str__(self): - return str(self.sale) - - class Meta: - verbose_name = _("sale item") - verbose_name_plural = _("sale items") - - def delete(self, using=None, keep_parents=False): - super().delete(using, keep_parents) - self.sale.save() +# class MerchandiseSale(models.Model): +# """Describes a merchandise sale.""" + +# objects = QueryablePropertiesManager() + +# id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + +# created_at = models.DateTimeField(_("created at"), default=timezone.now) + +# items = models.ManyToManyField( +# MerchandiseItem, +# through="MerchandiseSaleItem", +# verbose_name=_("items"), +# ) + +# paid_by = models.ForeignKey( +# Member, +# models.PROTECT, +# verbose_name=_("paid by"), +# related_name="merchandise_sale_set", +# blank=False, +# null=True, +# ) + +# CASH = "cash_payment" +# CARD = "card_payment" + +# PAYMENT_TYPE = ( +# (CASH, _("Cash payment")), +# (CARD, _("Card payment")), +# ) + +# type = models.CharField( +# verbose_name=_("type"), +# blank=False, +# null=False, +# max_length=20, +# choices=PAYMENT_TYPE, +# ) + +# payment = models.OneToOneField( +# Payment, +# models.CASCADE, +# verbose_name=_("payment"), +# related_name="merchandise_sale_set", +# blank=True, +# null=True, +# ) + +# total_amount = PaymentAmountField( +# allow_zero=True, verbose_name=_("total amount"), null=True +# ) + +# total_purchase_amount = PaymentAmountField( +# allow_zero=True, verbose_name=_("total purchase amount"), null=True +# ) + +# notes = models.TextField(verbose_name=_("notes"), blank=True, null=True) + +# @property +# def sale_description(self): +# return "Merchandise sale:" + ", ".join( +# [str(item.amount) + "x " + str(item.item) for item in self.sale_items.all()] +# ) + +# def get_absolute_url(self): +# return reverse("admin:merchandise_sales_change", args=[str(self.pk)]) + +# class Meta: +# verbose_name = _("merchandise sale") +# verbose_name_plural = _("merchandise sales") + +# def __str__(self): +# return _("Merchandise sale of {total_amount}").format( +# total_amount=self.total_amount +# ) + + +# class MerchandiseSaleItem(models.Model): +# """Describes a merchandise sale item.""" + +# sale = models.ForeignKey( +# MerchandiseSale, +# models.CASCADE, +# verbose_name=_("sale"), +# related_name="sale_items", +# blank=False, +# null=True, +# ) + +# item = models.ForeignKey( +# MerchandiseItem, +# models.PROTECT, +# verbose_name=_("item"), +# related_name="item_merchandise_sale_item_set", +# blank=True, +# null=True, +# ) + +# amount = models.PositiveSmallIntegerField( +# verbose_name=_("amount"), +# default=1, +# blank=False, +# null=True, +# ) + +# total = PaymentAmountField( +# verbose_name=_("total"), +# allow_zero=False, +# blank=False, +# null=True, +# ) + +# purchase_total = PaymentAmountField( +# verbose_name=_("purchase total"), +# allow_zero=False, +# blank=False, +# null=True, +# ) + +# def save( +# self, force_insert=False, force_update=False, using=None, update_fields=None +# ): +# if self.amount == 0: +# if self.pk: +# self.delete() +# else: +# return + +# self.total = self.item.price * self.amount +# self.purchase_total = self.item.purchase_price * self.amount + +# super().save(force_insert, force_update, using, update_fields) +# self.sale.save() + +# def __str__(self): +# return str(self.sale) + +# class Meta: +# verbose_name = _("sale item") +# verbose_name_plural = _("sale items") + +# def delete(self, using=None, keep_parents=False): +# super().delete(using, keep_parents) +# self.sale.save() diff --git a/website/merchandise/payables.py b/website/merchandise/payables.py index e48236d91..913be48e0 100644 --- a/website/merchandise/payables.py +++ b/website/merchandise/payables.py @@ -1,58 +1,58 @@ -from django.utils.functional import classproperty +# from django.utils.functional import classproperty -from merchandise.models import MerchandiseSale, MerchandiseSaleItem -from payments.payables import Payable, payables +# from merchandise.models import MerchandiseSale, MerchandiseSaleItem +# from payments.payables import Payable, payables -class MerchandiseSalePayable(Payable): - @property - def payment_amount(self): - return self.model.total_amount +# class MerchandiseSalePayable(Payable): +# @property +# def payment_amount(self): +# return self.model.total_amount - @property - def payment_topic(self): - return "Merchandise" +# @property +# def payment_topic(self): +# return "Merchandise" - @property - def payment_notes(self): - return f"{self.model.sale_description}" +# @property +# def payment_notes(self): +# return f"{self.model.sale_description}" - @property - def payment_payer(self): - return self.model.paid_by +# @property +# def payment_payer(self): +# return self.model.paid_by - @property - def paying_allowed(self): - return True +# @property +# def paying_allowed(self): +# return True - @property - def tpay_allowed(self): - return False +# @property +# def tpay_allowed(self): +# return False - @classproperty - def immutable_after_payment(self): - return True +# @classproperty +# def immutable_after_payment(self): +# return True - @classproperty - def immutable_foreign_key_models(self): - return {MerchandiseSaleItem: "sale"} +# @classproperty +# def immutable_foreign_key_models(self): +# return {MerchandiseSaleItem: "sale"} - @classproperty - def immutable_model_fields_after_payment(self): - return { - MerchandiseSale: [ - "items", - "sale_description", - "total_amount", - "total_purchase_amount", - "paid_by", - ], - MerchandiseSaleItem: ["item", "sale", "total", "purchase_total", "amount"], - } +# @classproperty +# def immutable_model_fields_after_payment(self): +# return { +# MerchandiseSale: [ +# "items", +# "sale_description", +# "total_amount", +# "total_purchase_amount", +# "paid_by", +# ], +# MerchandiseSaleItem: ["item", "sale", "total", "purchase_total", "amount"], +# } - def can_manage_payment(self, member): - return True +# def can_manage_payment(self, member): +# return True -def register(): - payables.register(MerchandiseSale, MerchandiseSalePayable) +# def register(): +# payables.register(MerchandiseSale, MerchandiseSalePayable) diff --git a/website/moneybirdsynchronization/admin.py b/website/moneybirdsynchronization/admin.py index 92ed06129..6946f7550 100644 --- a/website/moneybirdsynchronization/admin.py +++ b/website/moneybirdsynchronization/admin.py @@ -1,10 +1,9 @@ from django.contrib import admin from django.contrib.admin import RelatedOnlyFieldListFilter -from .models import ( +from .models import ( # MoneybirdMerchandiseSaleJournal, MoneybirdContact, MoneybirdExternalInvoice, - MoneybirdMerchandiseSaleJournal, MoneybirdPayment, ) @@ -107,11 +106,11 @@ def get_readonly_fields(self, request, obj=None): return ("payment",) -@admin.register(MoneybirdMerchandiseSaleJournal) -class MoneybirdMerchandiseSaleJournalAdmin(admin.ModelAdmin): - list_display = ("merchandise_sale", "moneybird_general_journal_document_id") +# @admin.register(MoneybirdMerchandiseSaleJournal) +# class MoneybirdMerchandiseSaleJournalAdmin(admin.ModelAdmin): +# list_display = ("merchandise_sale", "moneybird_general_journal_document_id") - readonly_fields = ( - "merchandise_sale", - "moneybird_general_journal_document_id", - ) +# readonly_fields = ( +# "merchandise_sale", +# "moneybird_general_journal_document_id", +# ) diff --git a/website/moneybirdsynchronization/migrations/0005_moneybirdmerchandisesalejournal.py b/website/moneybirdsynchronization/migrations/0005_moneybirdmerchandisesalejournal.py deleted file mode 100644 index 4dc9b5681..000000000 --- a/website/moneybirdsynchronization/migrations/0005_moneybirdmerchandisesalejournal.py +++ /dev/null @@ -1,61 +0,0 @@ -# Generated by Django 4.2.6 on 2023-10-18 13:00 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - ("merchandise", "0011_merchandisesale_merchandiseitem_purchase_price_and_more"), - ("moneybirdsynchronization", "0004_moneybirdproject"), - ] - - operations = [ - migrations.CreateModel( - name="MoneybirdMerchandiseSaleJournal", - 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", - ), - ), - ( - "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", - ), - ), - ( - "merchandise_sale", - models.OneToOneField( - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="moneybird_journal_merchandise_sale", - to="merchandise.merchandisesale", - verbose_name="merchandise sale", - ), - ), - ], - options={ - "verbose_name": "moneybird merchandise sale journal", - "verbose_name_plural": "moneybird merchandise sale journals", - }, - ), - ] diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index dc34203ec..a4741fa0d 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -11,7 +11,6 @@ from events.models import EventRegistration from members.models import Member -from merchandise.models import MerchandiseSale from moneybirdsynchronization.moneybird import get_moneybird_api_service from payments.models import BankAccount, Payment from payments.payables import payables @@ -42,8 +41,8 @@ def project_name_for_payable_model(obj) -> Optional[str]: return f"{obj.shift} [{start_date}]" if isinstance(obj, (Registration, Renewal)): return None - if isinstance(obj, MerchandiseSale): - return None + # if isinstance(obj, MerchandiseSale): + # return None raise ValueError(f"Unknown payable model {obj}") @@ -57,8 +56,8 @@ def date_for_payable_model(obj) -> Union[datetime.datetime, datetime.date]: return obj.shift.start if isinstance(obj, (Registration, Renewal)): return obj.created_at.date() - if isinstance(obj, MerchandiseSale): - return obj.created_at.date() + # if isinstance(obj, MerchandiseSale): + # return obj.created_at.date() raise ValueError(f"Unknown payable model {obj}") @@ -66,8 +65,8 @@ def date_for_payable_model(obj) -> Union[datetime.datetime, datetime.date]: def ledger_id_for_payable_model(obj) -> Optional[int]: if isinstance(obj, (Registration, Renewal)): return settings.MONEYBIRD_CONTRIBUTION_LEDGER_ID - if isinstance(obj, MerchandiseSale): - return settings.MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID + # if isinstance(obj, MerchandiseSale): + # return settings.MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID raise ValueError(f"Unknown payable model {obj}") @@ -379,61 +378,61 @@ class Meta: verbose_name_plural = _("moneybird payments") -class MoneybirdMerchandiseSaleJournal(models.Model): - merchandise_sale = models.OneToOneField( - MerchandiseSale, - on_delete=models.DO_NOTHING, - verbose_name=_("merchandise sale"), - related_name="moneybird_journal_merchandise_sale", - ) - - external_invoice = models.OneToOneField( - MoneybirdExternalInvoice, - on_delete=models.DO_NOTHING, - verbose_name=_("external invoice"), - related_name="moneybird_journal_external_invoice", - blank=True, - null=True, - ) - - moneybird_general_journal_document_id = models.CharField( - verbose_name=_("moneybird general journal document id"), - max_length=255, - blank=True, - null=True, - ) - - def __str__(self): - return f"Moneybird journal for {self.merchandise_sale}" - - def to_moneybird(self): - 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.merchandise_sale.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(self.merchandise_sale.total_purchase_amount), - "credit": "0", - "description": self.merchandise_sale.sale_description, - "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, - }, - "1": { - "ledger_account_id": merchandise_stock_ledger_id, - "debit": "0", - "credit": str(self.merchandise_sale.total_purchase_amount), - "description": self.merchandise_sale.sale_description, - "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, - }, - }, - } - } - - return data - - class Meta: - verbose_name = _("moneybird merchandise sale journal") - verbose_name_plural = _("moneybird merchandise sale journals") +# class MoneybirdMerchandiseSaleJournal(models.Model): +# merchandise_sale = models.OneToOneField( +# MerchandiseSale, +# on_delete=models.DO_NOTHING, +# verbose_name=_("merchandise sale"), +# related_name="moneybird_journal_merchandise_sale", +# ) + +# external_invoice = models.OneToOneField( +# MoneybirdExternalInvoice, +# on_delete=models.DO_NOTHING, +# verbose_name=_("external invoice"), +# related_name="moneybird_journal_external_invoice", +# blank=True, +# null=True, +# ) + +# moneybird_general_journal_document_id = models.CharField( +# verbose_name=_("moneybird general journal document id"), +# max_length=255, +# blank=True, +# null=True, +# ) + +# def __str__(self): +# return f"Moneybird journal for {self.merchandise_sale}" + +# def to_moneybird(self): +# 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.merchandise_sale.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(self.merchandise_sale.total_purchase_amount), +# "credit": "0", +# "description": self.merchandise_sale.sale_description, +# "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, +# }, +# "1": { +# "ledger_account_id": merchandise_stock_ledger_id, +# "debit": "0", +# "credit": str(self.merchandise_sale.total_purchase_amount), +# "description": self.merchandise_sale.sale_description, +# "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, +# }, +# }, +# } +# } + +# return data + +# class Meta: +# verbose_name = _("moneybird merchandise sale journal") +# verbose_name_plural = _("moneybird merchandise sale journals") diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 22f26fdc2..4fb9f98fe 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -12,10 +12,9 @@ from members.models import Member from moneybirdsynchronization.administration import Administration from moneybirdsynchronization.emails import send_sync_error -from moneybirdsynchronization.models import ( +from moneybirdsynchronization.models import ( # MoneybirdMerchandiseSaleJournal, MoneybirdContact, MoneybirdExternalInvoice, - MoneybirdMerchandiseSaleJournal, MoneybirdPayment, financial_account_id_for_payment_type, ) @@ -542,56 +541,56 @@ def process_thalia_pay_batch(batch): ) -def create_or_update_merchandise_sale(sale): - if not settings.MONEYBIRD_SYNC_ENABLED: - return None - - moneybird = get_moneybird_api_service() - external_invoice = create_or_update_external_invoice(sale) - - merchandise_sale_journal, _ = MoneybirdMerchandiseSaleJournal.objects.get_or_create( - merchandise_sale=sale - ) - merchandise_sale_journal.external_invoice = external_invoice - merchandise_sale_journal.save() - - 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.save() - - -def delete_merchandise_sale(sale): - if not settings.MONEYBIRD_SYNC_ENABLED: - return None - - try: - merchandise_sale_journal = MoneybirdMerchandiseSaleJournal.objects.get( - merchandise_sale=sale - ) - except MoneybirdMerchandiseSaleJournal.DoesNotExist: - return None - - if sale.payment is not None: - delete_moneybird_payment(sale.payment.moneybird_payment) - sale.payment.delete() - - if merchandise_sale_journal.external_invoice is not None: - delete_external_invoice(sale) - - 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 create_or_update_merchandise_sale(sale): +# if not settings.MONEYBIRD_SYNC_ENABLED: +# return None + +# moneybird = get_moneybird_api_service() +# external_invoice = create_or_update_external_invoice(sale) + +# merchandise_sale_journal, _ = MoneybirdMerchandiseSaleJournal.objects.get_or_create( +# merchandise_sale=sale +# ) +# merchandise_sale_journal.external_invoice = external_invoice +# merchandise_sale_journal.save() + +# 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.save() + + +# def delete_merchandise_sale(sale): +# if not settings.MONEYBIRD_SYNC_ENABLED: +# return None + +# try: +# merchandise_sale_journal = MoneybirdMerchandiseSaleJournal.objects.get( +# merchandise_sale=sale +# ) +# except MoneybirdMerchandiseSaleJournal.DoesNotExist: +# return None + +# if sale.payment is not None: +# delete_moneybird_payment(sale.payment.moneybird_payment) +# sale.payment.delete() + +# if merchandise_sale_journal.external_invoice is not None: +# delete_external_invoice(sale) + +# 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() diff --git a/website/moneybirdsynchronization/signals.py b/website/moneybirdsynchronization/signals.py index e849b1382..acf075f67 100644 --- a/website/moneybirdsynchronization/signals.py +++ b/website/moneybirdsynchronization/signals.py @@ -199,13 +199,13 @@ def post_processed_batch(sender, instance, **kwargs): logging.exception("Moneybird synchronization error: %s", e) -@suspendingreceiver( - post_delete, - sender="merchandise.MerchandiseSale", -) -def post_merchandise_sale_delete(sender, instance, **kwargs): - try: - services.delete_merchandise_sale(instance) - except Administration.Error as e: - send_sync_error(e, instance) - logging.exception("Moneybird synchronization error: %s", e) +# @suspendingreceiver( +# post_delete, +# sender="merchandise.MerchandiseSale", +# ) +# def post_merchandise_sale_delete(sender, instance, **kwargs): +# try: +# services.delete_merchandise_sale(instance) +# except Administration.Error as e: +# send_sync_error(e, instance) +# logging.exception("Moneybird synchronization error: %s", e) From c4aa4c5c1613dc7c883ae3b72a58b90f416ca726 Mon Sep 17 00:00:00 2001 From: movieminer Date: Thu, 26 Oct 2023 23:47:51 +0200 Subject: [PATCH 07/26] Continuing merch sales --- website/merchandise/admin.py | 213 +----------------- ...0011_remove_merchandiseitem_id_and_more.py | 3 +- website/merchandise/models.py | 147 ------------ website/merchandise/payables.py | 58 ----- website/moneybirdsynchronization/admin.py | 35 ++- .../0008_moneybirdmerchandisesalejournal.py | 63 ++++++ ...disesalejournal_needs_deletion_and_more.py | 28 +++ website/moneybirdsynchronization/models.py | 129 ++++++----- website/moneybirdsynchronization/services.py | 190 +++++++++++----- website/sales/admin/product_admin.py | 9 + website/sales/apps.py | 1 + website/sales/services.py | 40 ++++ website/sales/signals.py | 21 ++ website/sales/tasks.py | 8 + website/thaliawebsite/settings.py | 4 + 15 files changed, 407 insertions(+), 542 deletions(-) delete mode 100644 website/merchandise/payables.py create mode 100644 website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py create mode 100644 website/moneybirdsynchronization/migrations/0009_moneybirdmerchandisesalejournal_needs_deletion_and_more.py create mode 100644 website/sales/signals.py create mode 100644 website/sales/tasks.py diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index 19af9c765..eca1937b8 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -5,53 +5,6 @@ from .models import MerchandiseItem -# class MerchandiseSaleInline(admin.TabularInline): -# """Inline for merchandise sales.""" - -# model = MerchandiseSaleItem -# extra = 0 - -# fields = ( -# "item", -# "amount", -# "total", -# "purchase_total", -# ) -# autocomplete_fields = ("item",) -# readonly_fields = ( -# "total", -# "purchase_total", -# ) - -# def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): -# if not obj: -# return ( -# "total", -# "purchase_total", -# ) -# if obj.payment: -# return ( -# "item", -# "amount", -# "total", -# "purchase_total", -# ) -# return super().get_readonly_fields(request, obj) - -# def has_delete_permission(self, request, obj=None): -# if isinstance(obj, MerchandiseSale): -# if obj.payment: -# return False - -# return super().has_delete_permission(request, obj) - -# def has_add_permission(self, request, obj=None): -# if isinstance(obj, MerchandiseSale): -# if obj.payment: -# return False - -# return super().has_add_permission(request, obj) - @admin.register(MerchandiseItem) class MerchandiseItemAdmin(ModelAdmin): @@ -66,167 +19,5 @@ class MerchandiseItemAdmin(ModelAdmin): "image", ) search_fields = ("name", "description") - - -# @admin.register(MerchandiseSale) -# class MerchandiseSaleAdmin(admin.ModelAdmin): -# """Manage the merch payments.""" - -# inlines = [MerchandiseSaleInline] -# list_display = ( -# "created_at", -# "paid_by_link", -# "total_amount", -# "total_purchase_amount", -# "type", -# "payment_link", -# ) -# list_filter = ["type"] -# date_hierarchy = "created_at" -# fields = ( -# "created_at", -# "paid_by", -# "type", -# "payment", -# "total_amount", -# "total_purchase_amount", -# "notes", -# ) -# readonly_fields = ( -# "created_at", -# "paid_by", -# "total_amount", -# "total_purchase_amount", -# "type", -# "payment", -# "notes", -# ) -# search_fields = ( -# "items__item__name", -# "paid_by__username", -# "paid_by__first_name", -# "paid_by__last_name", -# "notes", -# "total_amount", -# "total_purchase_amount", -# ) -# ordering = ("-created_at",) -# autocomplete_fields = ("paid_by",) -# actions = [ -# "export_csv", -# ] - -# @staticmethod -# def _member_link(member: PaymentUser) -> str: -# return ( -# format_html( -# "{}", member.get_absolute_url(), member.get_full_name() -# ) -# if member -# else None -# ) - -# def paid_by_link(self, obj: MerchandiseSale) -> str: -# return self._member_link(obj.paid_by) - -# paid_by_link.admin_order_field = "paid_by" -# paid_by_link.short_description = _("paid by") - -# @staticmethod -# def _payment_link(payment: Payment) -> str: -# if payment: -# return format_html( -# "{}", payment.get_admin_url(), str(payment) -# ) - -# def payment_link(self, obj: MerchandiseSale) -> str: -# if obj.payment: -# return self._payment_link(obj.payment) -# return None - -# payment_link.admin_order_field = "payment" -# payment_link.short_description = _("payment") - -# def has_delete_permission(self, request, obj=None): -# if isinstance(obj, MerchandiseSale): -# if obj.payment.batch and obj.payment.batch.processed: -# return False -# if ( -# "merchandisesale/" in request.path -# and request.POST -# and request.POST.get("action") == "delete_selected" -# ): -# for sale_id in request.POST.getlist("_selected_action"): -# sale = MerchandiseSale.objects.get(id=sale_id) -# if sale.payment.batch and sale.payment.batch.processed: -# return False - -# return super().has_delete_permission(request, obj) - -# def get_readonly_fields(self, request: HttpRequest, obj: MerchandiseSale = None): -# if not obj: -# return "created_at", "total_amount", "total_purchase_amount", "payment" -# if obj.payment: -# return ( -# "created_at", -# "paid_by", -# "total_amount", -# "total_purchase_amount", -# "type", -# "payment", -# "notes", -# ) -# return super().get_readonly_fields(request, obj) - -# def save_related(self, request, form, formsets, change): -# obj = form.instance -# for formset in formsets: -# formset.save() -# obj.total_amount = sum([item.total for item in obj.sale_items.all()]) -# obj.total_purchase_amount = sum( -# [item.purchase_total for item in obj.sale_items.all()] -# ) -# obj.save() - -# obj.payment = payment_services.create_payment( -# model_payable=obj, processed_by=request.user, pay_type=obj.type -# ) -# obj.save() -# moneybird_services.create_or_update_merchandise_sale(obj) - -# super().save_related(request, form, formsets, change) - -# def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse: -# """Export a CSV of payments. - -# :param request: Request -# :param queryset: Items to be exported -# """ -# response = HttpResponse(content_type="text/csv") -# response["Content-Disposition"] = 'attachment;filename="merchandise_sales.csv"' -# writer = csv.writer(response) -# headers = [ -# _("created"), -# _("payer id"), -# _("payer name"), -# _("total_amount"), -# _("total_purchase_amount"), -# _("type"), -# _("notes"), -# ] -# writer.writerow([capfirst(x) for x in headers]) -# for sale in queryset: -# writer.writerow( -# [ -# sale.created_at, -# sale.paid_by.pk if sale.paid_by else "-", -# sale.paid_by.get_full_name() if sale.paid_by else "-", -# sale.total_amount, -# sale.total_purchase_amount, -# sale.get_type_display(), -# sale.notes, -# ] -# ) -# return response - -# export_csv.short_description = _("Export") + list_display = ("name", "price", "purchase_price") + list_filter = ("name", "price", "purchase_price") diff --git a/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py b/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py index ecf949b5d..b098677dc 100644 --- a/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py +++ b/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py @@ -6,6 +6,7 @@ itemlist = [] + class Migration(migrations.Migration): dependencies = [ ("sales", "0007_alter_productlistitem_options_and_more"), @@ -55,6 +56,6 @@ def create_merchandiseitems(apps, schema_editor): migrations.AddField( model_name="merchandiseitem", name="purchase_price", - field=models.DecimalField(decimal_places=2, default=0, max_digits=8), + field=models.DecimalField(decimal_places=2, max_digits=8), ), ] diff --git a/website/merchandise/models.py b/website/merchandise/models.py index 481e7f2c9..c856daaa6 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -62,150 +62,3 @@ def __str__(self): :rtype: str """ return str(self.name) - - -# class MerchandiseSale(models.Model): -# """Describes a merchandise sale.""" - -# objects = QueryablePropertiesManager() - -# id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - -# created_at = models.DateTimeField(_("created at"), default=timezone.now) - -# items = models.ManyToManyField( -# MerchandiseItem, -# through="MerchandiseSaleItem", -# verbose_name=_("items"), -# ) - -# paid_by = models.ForeignKey( -# Member, -# models.PROTECT, -# verbose_name=_("paid by"), -# related_name="merchandise_sale_set", -# blank=False, -# null=True, -# ) - -# CASH = "cash_payment" -# CARD = "card_payment" - -# PAYMENT_TYPE = ( -# (CASH, _("Cash payment")), -# (CARD, _("Card payment")), -# ) - -# type = models.CharField( -# verbose_name=_("type"), -# blank=False, -# null=False, -# max_length=20, -# choices=PAYMENT_TYPE, -# ) - -# payment = models.OneToOneField( -# Payment, -# models.CASCADE, -# verbose_name=_("payment"), -# related_name="merchandise_sale_set", -# blank=True, -# null=True, -# ) - -# total_amount = PaymentAmountField( -# allow_zero=True, verbose_name=_("total amount"), null=True -# ) - -# total_purchase_amount = PaymentAmountField( -# allow_zero=True, verbose_name=_("total purchase amount"), null=True -# ) - -# notes = models.TextField(verbose_name=_("notes"), blank=True, null=True) - -# @property -# def sale_description(self): -# return "Merchandise sale:" + ", ".join( -# [str(item.amount) + "x " + str(item.item) for item in self.sale_items.all()] -# ) - -# def get_absolute_url(self): -# return reverse("admin:merchandise_sales_change", args=[str(self.pk)]) - -# class Meta: -# verbose_name = _("merchandise sale") -# verbose_name_plural = _("merchandise sales") - -# def __str__(self): -# return _("Merchandise sale of {total_amount}").format( -# total_amount=self.total_amount -# ) - - -# class MerchandiseSaleItem(models.Model): -# """Describes a merchandise sale item.""" - -# sale = models.ForeignKey( -# MerchandiseSale, -# models.CASCADE, -# verbose_name=_("sale"), -# related_name="sale_items", -# blank=False, -# null=True, -# ) - -# item = models.ForeignKey( -# MerchandiseItem, -# models.PROTECT, -# verbose_name=_("item"), -# related_name="item_merchandise_sale_item_set", -# blank=True, -# null=True, -# ) - -# amount = models.PositiveSmallIntegerField( -# verbose_name=_("amount"), -# default=1, -# blank=False, -# null=True, -# ) - -# total = PaymentAmountField( -# verbose_name=_("total"), -# allow_zero=False, -# blank=False, -# null=True, -# ) - -# purchase_total = PaymentAmountField( -# verbose_name=_("purchase total"), -# allow_zero=False, -# blank=False, -# null=True, -# ) - -# def save( -# self, force_insert=False, force_update=False, using=None, update_fields=None -# ): -# if self.amount == 0: -# if self.pk: -# self.delete() -# else: -# return - -# self.total = self.item.price * self.amount -# self.purchase_total = self.item.purchase_price * self.amount - -# super().save(force_insert, force_update, using, update_fields) -# self.sale.save() - -# def __str__(self): -# return str(self.sale) - -# class Meta: -# verbose_name = _("sale item") -# verbose_name_plural = _("sale items") - -# def delete(self, using=None, keep_parents=False): -# super().delete(using, keep_parents) -# self.sale.save() diff --git a/website/merchandise/payables.py b/website/merchandise/payables.py deleted file mode 100644 index 913be48e0..000000000 --- a/website/merchandise/payables.py +++ /dev/null @@ -1,58 +0,0 @@ -# from django.utils.functional import classproperty - -# from merchandise.models import MerchandiseSale, MerchandiseSaleItem -# from payments.payables import Payable, payables - - -# class MerchandiseSalePayable(Payable): -# @property -# def payment_amount(self): -# return self.model.total_amount - -# @property -# def payment_topic(self): -# return "Merchandise" - -# @property -# def payment_notes(self): -# return f"{self.model.sale_description}" - -# @property -# def payment_payer(self): -# return self.model.paid_by - -# @property -# def paying_allowed(self): -# return True - -# @property -# def tpay_allowed(self): -# return False - -# @classproperty -# def immutable_after_payment(self): -# return True - -# @classproperty -# def immutable_foreign_key_models(self): -# return {MerchandiseSaleItem: "sale"} - -# @classproperty -# def immutable_model_fields_after_payment(self): -# return { -# MerchandiseSale: [ -# "items", -# "sale_description", -# "total_amount", -# "total_purchase_amount", -# "paid_by", -# ], -# MerchandiseSaleItem: ["item", "sale", "total", "purchase_total", "amount"], -# } - -# def can_manage_payment(self, member): -# return True - - -# def register(): -# payables.register(MerchandiseSale, MerchandiseSalePayable) diff --git a/website/moneybirdsynchronization/admin.py b/website/moneybirdsynchronization/admin.py index 6946f7550..8551b381d 100644 --- a/website/moneybirdsynchronization/admin.py +++ b/website/moneybirdsynchronization/admin.py @@ -1,9 +1,10 @@ from django.contrib import admin from django.contrib.admin import RelatedOnlyFieldListFilter -from .models import ( # MoneybirdMerchandiseSaleJournal, +from .models import ( MoneybirdContact, MoneybirdExternalInvoice, + MoneybirdMerchandiseSaleJournal, MoneybirdPayment, ) @@ -106,11 +107,29 @@ def get_readonly_fields(self, request, obj=None): return ("payment",) -# @admin.register(MoneybirdMerchandiseSaleJournal) -# class MoneybirdMerchandiseSaleJournalAdmin(admin.ModelAdmin): -# list_display = ("merchandise_sale", "moneybird_general_journal_document_id") +@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", + "needs_synchronization", + "needs_deletion", + ) -# readonly_fields = ( -# "merchandise_sale", -# "moneybird_general_journal_document_id", -# ) + fields = ( + "order", + "moneybird_general_journal_document_id", + "external_invoice", + ) + + list_filter = ( + "needs_synchronization", + "needs_deletion", + ) diff --git a/website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py b/website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py new file mode 100644 index 000000000..c12787914 --- /dev/null +++ b/website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.6 on 2023-10-26 20:44 + +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="MoneybirdMerchandiseSaleJournal", + 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", + ), + ), + ( + "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 merchandise sale journal", + "verbose_name_plural": "moneybird merchandise sale journals", + }, + ), + ] diff --git a/website/moneybirdsynchronization/migrations/0009_moneybirdmerchandisesalejournal_needs_deletion_and_more.py b/website/moneybirdsynchronization/migrations/0009_moneybirdmerchandisesalejournal_needs_deletion_and_more.py new file mode 100644 index 000000000..e927cb3de --- /dev/null +++ b/website/moneybirdsynchronization/migrations/0009_moneybirdmerchandisesalejournal_needs_deletion_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.6 on 2023-10-26 21:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("moneybirdsynchronization", "0008_moneybirdmerchandisesalejournal"), + ] + + operations = [ + migrations.AddField( + model_name="moneybirdmerchandisesalejournal", + name="needs_deletion", + field=models.BooleanField( + default=False, + help_text="Indicates that the journal has to be deleted from moneybird.", + ), + ), + migrations.AddField( + model_name="moneybirdmerchandisesalejournal", + name="needs_synchronization", + field=models.BooleanField( + default=True, + help_text="Indicates that the journal has to be synchronized (again).", + ), + ), + ] diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index a4741fa0d..1028fef69 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -378,61 +378,74 @@ class Meta: verbose_name_plural = _("moneybird payments") -# class MoneybirdMerchandiseSaleJournal(models.Model): -# merchandise_sale = models.OneToOneField( -# MerchandiseSale, -# on_delete=models.DO_NOTHING, -# verbose_name=_("merchandise sale"), -# related_name="moneybird_journal_merchandise_sale", -# ) - -# external_invoice = models.OneToOneField( -# MoneybirdExternalInvoice, -# on_delete=models.DO_NOTHING, -# verbose_name=_("external invoice"), -# related_name="moneybird_journal_external_invoice", -# blank=True, -# null=True, -# ) - -# moneybird_general_journal_document_id = models.CharField( -# verbose_name=_("moneybird general journal document id"), -# max_length=255, -# blank=True, -# null=True, -# ) - -# def __str__(self): -# return f"Moneybird journal for {self.merchandise_sale}" - -# def to_moneybird(self): -# 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.merchandise_sale.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(self.merchandise_sale.total_purchase_amount), -# "credit": "0", -# "description": self.merchandise_sale.sale_description, -# "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, -# }, -# "1": { -# "ledger_account_id": merchandise_stock_ledger_id, -# "debit": "0", -# "credit": str(self.merchandise_sale.total_purchase_amount), -# "description": self.merchandise_sale.sale_description, -# "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, -# }, -# }, -# } -# } - -# return data - -# class Meta: -# verbose_name = _("moneybird merchandise sale journal") -# verbose_name_plural = _("moneybird merchandise sale journals") +class MoneybirdMerchandiseSaleJournal(models.Model): + order = models.OneToOneField( + Order, + on_delete=models.CASCADE, + verbose_name=_("order"), + ) + + moneybird_general_journal_document_id = models.CharField( + verbose_name=_("moneybird general journal document id"), + max_length=255, + blank=True, + null=True, + ) + + external_invoice = models.OneToOneField( + MoneybirdExternalInvoice, + on_delete=models.DO_NOTHING, + verbose_name=_("external invoice"), + related_name="moneybird_journal_external_invoice", + 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.", + ) + + def __str__(self): + return f"Moneybird journal for {self.order}" + + def to_moneybird(self): + items = self.order.items.all() + total_purchase_amount = sum(item.purchase_price for item 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, + }, + "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, + }, + }, + } + } + + return data + + class Meta: + verbose_name = _("moneybird merchandise sale journal") + verbose_name_plural = _("moneybird merchandise sale journals") diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 4fb9f98fe..4c9ef8259 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -12,9 +12,10 @@ from members.models import Member from moneybirdsynchronization.administration import Administration from moneybirdsynchronization.emails import send_sync_error -from moneybirdsynchronization.models import ( # MoneybirdMerchandiseSaleJournal, +from moneybirdsynchronization.models import ( MoneybirdContact, MoneybirdExternalInvoice, + MoneybirdMerchandiseSaleJournal, MoneybirdPayment, financial_account_id_for_payment_type, ) @@ -166,6 +167,58 @@ def delete_external_invoice(obj): external_invoice.delete() +def create_or_update_merchandise_sale(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() + + 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.save() + + # Mark the invoice as not outdated anymore only after everything has succeeded. + external_invoice.needs_synchronization = False + external_invoice.save() + + +def delete_merchandise_sale(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: @@ -181,15 +234,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_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() @@ -217,6 +273,28 @@ def _delete_invoices(): send_sync_error(e, invoice) +def _delete_journals(): + """Delete the journals that have been marked for deletion from moneybird.""" + journals = MoneybirdMerchandiseSaleJournal.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( @@ -236,6 +314,25 @@ def _sync_outdated_invoices(): logger.exception("Payable object for outdated invoice does not exist.") +def _sync_outdated_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(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. @@ -323,6 +420,36 @@ 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, + shift__name__icontains="Merchandise sales", + ).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(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( @@ -539,58 +666,3 @@ def process_thalia_pay_batch(batch): } } ) - - -# def create_or_update_merchandise_sale(sale): -# if not settings.MONEYBIRD_SYNC_ENABLED: -# return None - -# moneybird = get_moneybird_api_service() -# external_invoice = create_or_update_external_invoice(sale) - -# merchandise_sale_journal, _ = MoneybirdMerchandiseSaleJournal.objects.get_or_create( -# merchandise_sale=sale -# ) -# merchandise_sale_journal.external_invoice = external_invoice -# merchandise_sale_journal.save() - -# 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.save() - - -# def delete_merchandise_sale(sale): -# if not settings.MONEYBIRD_SYNC_ENABLED: -# return None - -# try: -# merchandise_sale_journal = MoneybirdMerchandiseSaleJournal.objects.get( -# merchandise_sale=sale -# ) -# except MoneybirdMerchandiseSaleJournal.DoesNotExist: -# return None - -# if sale.payment is not None: -# delete_moneybird_payment(sale.payment.moneybird_payment) -# sale.payment.delete() - -# if merchandise_sale_journal.external_invoice is not None: -# delete_external_invoice(sale) - -# 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() diff --git a/website/sales/admin/product_admin.py b/website/sales/admin/product_admin.py index 0ff66b5b8..ce134d62a 100644 --- a/website/sales/admin/product_admin.py +++ b/website/sales/admin/product_admin.py @@ -1,5 +1,10 @@ +from typing import Any + from django.contrib import admin from django.contrib.admin import register +from django.db.models import Q +from django.db.models.query import QuerySet +from django.http.request import HttpRequest from sales.models.product import Product, ProductList, ProductListItem @@ -19,3 +24,7 @@ class ProductListAdmin(admin.ModelAdmin): inlines = [ ProductListItemInline, ] + + def get_queryset(self, request: HttpRequest) -> QuerySet[Any]: + queryset = super().get_queryset(request) + return queryset.filter(~Q(name="Merchandise Product List")) diff --git a/website/sales/apps.py b/website/sales/apps.py index f58719e19..5603cbc9b 100644 --- a/website/sales/apps.py +++ b/website/sales/apps.py @@ -5,6 +5,7 @@ class SalesConfig(AppConfig): name = "sales" def ready(self): + from . import signals # noqa: F401 from .payables import register register() diff --git a/website/sales/services.py b/website/sales/services.py index 696dcc3cd..b3beca98c 100644 --- a/website/sales/services.py +++ b/website/sales/services.py @@ -1,6 +1,11 @@ +import datetime + from django.utils import timezone +from activemembers.models import Board from sales.models.order import Order +from sales.models.product import ProductList +from sales.models.shift import Shift def is_adult(member): @@ -33,3 +38,38 @@ def execute_data_minimisation(dry_run=False): if not dry_run: queryset.update(payer=None) return queryset.all() + + +def create_daily_merchandise_sale_shift(): + today = timezone.now().date() + merchandise_product_list = ProductList.objects.get_or_create( + name="Merchandise Product List" + )[0] + active_board = Board.objects.filter(since__lte=today, until__gte=today) + + shift = Shift.objects.create( + title="Merchandise sales " + today.strftime("%Y-%m-%d"), + start=timezone.datetime.combine(today, datetime.time(0, 0, 0)), + 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(): + yesterday = timezone.now().date() - timezone.timedelta(days=1) + shift = Shift.objects.filter( + name="Merchandise sales " + yesterday.strftime("%Y-%m-%d") + ) + if shift: + if shift.num_orders == 0: + shift.delete() + else: + shift.locked = True + shift.save() + + +def renew_merchandise_sale_shift(): + lock_merchandise_sale_shift() + create_daily_merchandise_sale_shift() diff --git a/website/sales/signals.py b/website/sales/signals.py new file mode 100644 index 000000000..d85807f9e --- /dev/null +++ b/website/sales/signals.py @@ -0,0 +1,21 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from merchandise.models import MerchandiseItem + +from .models.product import ProductList + + +@receiver(post_save, sender=MerchandiseItem) +def update_product_list(sender, instance, **kwargs): + product_list = ProductList.objects.get_or_create(name="Merchandise Product List")[0] + product_list_products = product_list.products.all() + merchandise_items = MerchandiseItem.objects.all() + + for merchandise_item in merchandise_items: + if merchandise_item not in product_list_products: + product_list.product_items.create( + product=merchandise_item, price=merchandise_item.price + ) + + product_list.save() diff --git a/website/sales/tasks.py b/website/sales/tasks.py new file mode 100644 index 000000000..12bfa221b --- /dev/null +++ b/website/sales/tasks.py @@ -0,0 +1,8 @@ +from celery import shared_task + +from . import services + + +@shared_task +def renew_merchandise_sale_shift(): + services.renew_merchandise_sale_shift() diff --git a/website/thaliawebsite/settings.py b/website/thaliawebsite/settings.py index 58ec21b7a..7c18ee475 100644 --- a/website/thaliawebsite/settings.py +++ b/website/thaliawebsite/settings.py @@ -344,6 +344,10 @@ def from_env( # https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html CELERY_BEAT_SCHEDULE = { + "renew_merchandise_sale_shift": { + "task": "sales.tasks.renew_merchandise_sale_shift", + "schedule": crontab(minute=0, hour=0), + }, "synchronize_mailinglists": { "task": "mailinglists.tasks.sync_mail", "schedule": crontab(minute=30), From 393cbeb113949df0e9fd5a972dc505a628afed5f Mon Sep 17 00:00:00 2001 From: movieminer Date: Fri, 27 Oct 2023 00:44:43 +0200 Subject: [PATCH 08/26] Continuing merch sales --- website/moneybirdsynchronization/models.py | 21 ++++++++++------- website/moneybirdsynchronization/services.py | 24 +++++++++----------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 1028fef69..60c3412db 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -11,6 +11,7 @@ from events.models import EventRegistration from members.models import Member +from merchandise.models import MerchandiseItem from moneybirdsynchronization.moneybird import get_moneybird_api_service from payments.models import BankAccount, Payment from payments.payables import payables @@ -41,8 +42,8 @@ def project_name_for_payable_model(obj) -> Optional[str]: return f"{obj.shift} [{start_date}]" if isinstance(obj, (Registration, Renewal)): return None - # if isinstance(obj, MerchandiseSale): - # return None + if isinstance(obj, Order): + return None raise ValueError(f"Unknown payable model {obj}") @@ -56,8 +57,8 @@ def date_for_payable_model(obj) -> Union[datetime.datetime, datetime.date]: return obj.shift.start if isinstance(obj, (Registration, Renewal)): return obj.created_at.date() - # if isinstance(obj, MerchandiseSale): - # return obj.created_at.date() + if isinstance(obj, Order): + return obj.created_at.date() raise ValueError(f"Unknown payable model {obj}") @@ -65,8 +66,8 @@ def date_for_payable_model(obj) -> Union[datetime.datetime, datetime.date]: def ledger_id_for_payable_model(obj) -> Optional[int]: if isinstance(obj, (Registration, Renewal)): return settings.MONEYBIRD_CONTRIBUTION_LEDGER_ID - # if isinstance(obj, MerchandiseSale): - # return settings.MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID + if isinstance(obj, Order): + return settings.MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID raise ValueError(f"Unknown payable model {obj}") @@ -415,8 +416,12 @@ def __str__(self): return f"Moneybird journal for {self.order}" def to_moneybird(self): - items = self.order.items.all() - total_purchase_amount = sum(item.purchase_price for item in items) + items = self.order.order_items.all() + total_purchase_amount = sum( + MerchandiseItem.objects.get(name=i.product.product.name).purchase_price + * 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 diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 4c9ef8259..f9d40718d 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -180,21 +180,19 @@ def create_or_update_merchandise_sale(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(), + moneybird.delete_general_journal_document( + merchandise_sale_journal.moneybird_general_journal_document_id ) - 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.save() + response = moneybird.create_general_journal_document( + merchandise_sale_journal.to_moneybird(), + ) + merchandise_sale_journal.moneybird_general_journal_document_id = response["id"] + merchandise_sale_journal.save() - # Mark the invoice as not outdated anymore only after everything has succeeded. - external_invoice.needs_synchronization = False - external_invoice.save() + merchandise_sale_journal.needs_synchronization = False + merchandise_sale_journal.save() def delete_merchandise_sale(obj): @@ -425,7 +423,7 @@ def _sync_merchandise_sales(): merchandise_sales = Order.objects.filter( shift__start__date__gte=settings.MONEYBIRD_START_DATE, payment__isnull=False, - shift__name__icontains="Merchandise sales", + shift__title__icontains="Merchandise sales", ).exclude( Exists( MoneybirdMerchandiseSaleJournal.objects.filter( From 38598820ac6ff2e0cfa2feca6665260d4422c124 Mon Sep 17 00:00:00 2001 From: movieminer Date: Mon, 30 Oct 2023 15:41:04 +0100 Subject: [PATCH 09/26] Small changes --- ...0011_remove_merchandiseitem_id_and_more.py | 6 ++-- website/moneybirdsynchronization/admin.py | 6 ++-- .../0008_moneybirdmerchandisesalejournal.py | 20 +++++++++++-- ...disesalejournal_needs_deletion_and_more.py | 28 ------------------- website/moneybirdsynchronization/models.py | 6 ++-- website/moneybirdsynchronization/services.py | 22 +++++++-------- website/sales/services.py | 11 +++----- 7 files changed, 42 insertions(+), 57 deletions(-) delete mode 100644 website/moneybirdsynchronization/migrations/0009_moneybirdmerchandisesalejournal_needs_deletion_and_more.py diff --git a/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py b/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py index b098677dc..ec0bc2686 100644 --- a/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py +++ b/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py @@ -18,14 +18,16 @@ def store_and_delete_merchandiseitems(apps, schema_editor): itemlist = list(MerchandiseItem.objects.all()) MerchandiseItem.objects.all().delete() + # This does not seem to work yet def create_merchandiseitems(apps, schema_editor): MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") for item in itemlist: MerchandiseItem.objects.create( + name=item.name, price=item.price, description=item.description, image=item.image, - purchase_price=item.purchase_price, + purchase_price=0, ) operations = [ @@ -52,10 +54,10 @@ def create_merchandiseitems(apps, schema_editor): ), preserve_default=False, ), - migrations.RunPython(create_merchandiseitems), migrations.AddField( model_name="merchandiseitem", name="purchase_price", field=models.DecimalField(decimal_places=2, max_digits=8), ), + migrations.RunPython(create_merchandiseitems), ] diff --git a/website/moneybirdsynchronization/admin.py b/website/moneybirdsynchronization/admin.py index 8551b381d..248e4e6fb 100644 --- a/website/moneybirdsynchronization/admin.py +++ b/website/moneybirdsynchronization/admin.py @@ -4,7 +4,7 @@ from .models import ( MoneybirdContact, MoneybirdExternalInvoice, - MoneybirdMerchandiseSaleJournal, + MoneybirdGeneralJournalDocument, MoneybirdPayment, ) @@ -107,8 +107,8 @@ def get_readonly_fields(self, request, obj=None): return ("payment",) -@admin.register(MoneybirdMerchandiseSaleJournal) -class MoneybirdMerchandiseSaleJournalAdmin(admin.ModelAdmin): +@admin.register(MoneybirdGeneralJournalDocument) +class MoneybirdGeneralJournalDocumentAdmin(admin.ModelAdmin): list_display = ( "order", "moneybird_general_journal_document_id", diff --git a/website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py b/website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py index c12787914..59f9a0a98 100644 --- a/website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py +++ b/website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="MoneybirdMerchandiseSaleJournal", + name="MoneybirdGeneralJournalDocument", fields=[ ( "id", @@ -54,10 +54,24 @@ class Migration(migrations.Migration): verbose_name="order", ), ), + ( + "needs_deletion", + models.BooleanField( + default=False, + help_text="Indicates that the journal has to be deleted from moneybird.", + ), + ), + ( + "needs_synchronization", + models.BooleanField( + default=True, + help_text="Indicates that the journal has to be synchronized (again).", + ), + ), ], options={ - "verbose_name": "moneybird merchandise sale journal", - "verbose_name_plural": "moneybird merchandise sale journals", + "verbose_name": "moneybird general journal document", + "verbose_name_plural": "moneybird general journal documents", }, ), ] diff --git a/website/moneybirdsynchronization/migrations/0009_moneybirdmerchandisesalejournal_needs_deletion_and_more.py b/website/moneybirdsynchronization/migrations/0009_moneybirdmerchandisesalejournal_needs_deletion_and_more.py deleted file mode 100644 index e927cb3de..000000000 --- a/website/moneybirdsynchronization/migrations/0009_moneybirdmerchandisesalejournal_needs_deletion_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.6 on 2023-10-26 21:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("moneybirdsynchronization", "0008_moneybirdmerchandisesalejournal"), - ] - - operations = [ - migrations.AddField( - model_name="moneybirdmerchandisesalejournal", - name="needs_deletion", - field=models.BooleanField( - default=False, - help_text="Indicates that the journal has to be deleted from moneybird.", - ), - ), - migrations.AddField( - model_name="moneybirdmerchandisesalejournal", - name="needs_synchronization", - field=models.BooleanField( - default=True, - help_text="Indicates that the journal has to be synchronized (again).", - ), - ), - ] diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 60c3412db..9058ab4bd 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -379,7 +379,7 @@ class Meta: verbose_name_plural = _("moneybird payments") -class MoneybirdMerchandiseSaleJournal(models.Model): +class MoneybirdGeneralJournalDocument(models.Model): order = models.OneToOneField( Order, on_delete=models.CASCADE, @@ -452,5 +452,5 @@ def to_moneybird(self): return data class Meta: - verbose_name = _("moneybird merchandise sale journal") - verbose_name_plural = _("moneybird merchandise sale journals") + verbose_name = _("moneybird general journal document") + verbose_name_plural = _("moneybird general journal documents") diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index f9d40718d..138f6e351 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -15,7 +15,7 @@ from moneybirdsynchronization.models import ( MoneybirdContact, MoneybirdExternalInvoice, - MoneybirdMerchandiseSaleJournal, + MoneybirdGeneralJournalDocument, MoneybirdPayment, financial_account_id_for_payment_type, ) @@ -167,14 +167,14 @@ def delete_external_invoice(obj): external_invoice.delete() -def create_or_update_merchandise_sale(obj): +def create_or_update_general_journal_document(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( + merchandise_sale_journal, _ = MoneybirdGeneralJournalDocument.objects.get_or_create( order=obj ) merchandise_sale_journal.external_invoice = external_invoice @@ -195,15 +195,15 @@ def create_or_update_merchandise_sale(obj): merchandise_sale_journal.save() -def delete_merchandise_sale(obj): +def delete_general_journal_document(obj): if not settings.MONEYBIRD_SYNC_ENABLED: return None try: - merchandise_sale_journal = MoneybirdMerchandiseSaleJournal.objects.get( + merchandise_sale_journal = MoneybirdGeneralJournalDocument.objects.get( order=obj ) - except MoneybirdMerchandiseSaleJournal.DoesNotExist: + except MoneybirdGeneralJournalDocument.DoesNotExist: return None if merchandise_sale_journal.moneybird_general_journal_document_id is None: @@ -273,7 +273,7 @@ def _delete_invoices(): def _delete_journals(): """Delete the journals that have been marked for deletion from moneybird.""" - journals = MoneybirdMerchandiseSaleJournal.objects.filter(needs_deletion=True) + journals = MoneybirdGeneralJournalDocument.objects.filter(needs_deletion=True) if not journals.exists(): return @@ -314,7 +314,7 @@ def _sync_outdated_invoices(): def _sync_outdated_journals(): """Resynchronize all journals that have been marked as outdated.""" - journals = MoneybirdMerchandiseSaleJournal.objects.filter( + journals = MoneybirdGeneralJournalDocument.objects.filter( needs_synchronization=True, needs_deletion=False ).order_by("order__pk") @@ -323,7 +323,7 @@ def _sync_outdated_journals(): for journal in journals: try: instance = journal.order - create_or_update_merchandise_sale(instance) + create_or_update_general_journal_document(instance) except Administration.Error as e: logger.exception("Moneybird synchronization error: %s", e) send_sync_error(e, instance) @@ -426,7 +426,7 @@ def _sync_merchandise_sales(): shift__title__icontains="Merchandise sales", ).exclude( Exists( - MoneybirdMerchandiseSaleJournal.objects.filter( + MoneybirdGeneralJournalDocument.objects.filter( order=OuterRef("pk"), ) ) @@ -442,7 +442,7 @@ def _sync_merchandise_sales(): for instance in merchandise_sales: try: - create_or_update_merchandise_sale(instance) + create_or_update_general_journal_document(instance) except Administration.Error as e: logger.exception("Moneybird synchronization error: %s", e) send_sync_error(e, instance) diff --git a/website/sales/services.py b/website/sales/services.py index b3beca98c..97ea032fb 100644 --- a/website/sales/services.py +++ b/website/sales/services.py @@ -48,8 +48,8 @@ def create_daily_merchandise_sale_shift(): active_board = Board.objects.filter(since__lte=today, until__gte=today) shift = Shift.objects.create( - title="Merchandise sales " + today.strftime("%Y-%m-%d"), - start=timezone.datetime.combine(today, datetime.time(0, 0, 0)), + title="Merchandise sales", + start=timezone.now(), end=timezone.datetime.combine(today, datetime.time(23, 59, 59)), product_list=merchandise_product_list, ) @@ -58,11 +58,8 @@ def create_daily_merchandise_sale_shift(): def lock_merchandise_sale_shift(): - yesterday = timezone.now().date() - timezone.timedelta(days=1) - shift = Shift.objects.filter( - name="Merchandise sales " + yesterday.strftime("%Y-%m-%d") - ) - if shift: + shifts = Shift.objects.filter(title="Merchandise sales").all() + for shift in shifts: if shift.num_orders == 0: shift.delete() else: From 97d2aa2113d55fb6133ec6cb456c541a390070cf Mon Sep 17 00:00:00 2001 From: movieminer Date: Mon, 30 Oct 2023 15:49:59 +0100 Subject: [PATCH 10/26] Fix migrations --- ...d_and_more.py => 0012_remove_merchandiseitem_id_and_more.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename website/merchandise/migrations/{0011_remove_merchandiseitem_id_and_more.py => 0012_remove_merchandiseitem_id_and_more.py} (96%) diff --git a/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py b/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py similarity index 96% rename from website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py rename to website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py index ec0bc2686..13c15f0b4 100644 --- a/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py +++ b/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ("sales", "0007_alter_productlistitem_options_and_more"), - ("merchandise", "0010_alter_merchandiseitem_image"), + ("merchandise", "0011_alter_merchandiseitem_image"), ] def store_and_delete_merchandiseitems(apps, schema_editor): From f3e58f32b6876e4ea0ff1726479fff53f6599706 Mon Sep 17 00:00:00 2001 From: movieminer Date: Mon, 30 Oct 2023 15:49:59 +0100 Subject: [PATCH 11/26] Removed comment --- ...y => 0012_remove_merchandiseitem_id_and_more.py} | 2 +- website/moneybirdsynchronization/signals.py | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) rename website/merchandise/migrations/{0011_remove_merchandiseitem_id_and_more.py => 0012_remove_merchandiseitem_id_and_more.py} (96%) diff --git a/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py b/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py similarity index 96% rename from website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py rename to website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py index ec0bc2686..13c15f0b4 100644 --- a/website/merchandise/migrations/0011_remove_merchandiseitem_id_and_more.py +++ b/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ("sales", "0007_alter_productlistitem_options_and_more"), - ("merchandise", "0010_alter_merchandiseitem_image"), + ("merchandise", "0011_alter_merchandiseitem_image"), ] def store_and_delete_merchandiseitems(apps, schema_editor): diff --git a/website/moneybirdsynchronization/signals.py b/website/moneybirdsynchronization/signals.py index acf075f67..66f54da5c 100644 --- a/website/moneybirdsynchronization/signals.py +++ b/website/moneybirdsynchronization/signals.py @@ -196,16 +196,3 @@ def post_processed_batch(sender, instance, **kwargs): except Administration.Error as e: logger.exception("Moneybird synchronization error: %s", e) send_sync_error(e, instance) - logging.exception("Moneybird synchronization error: %s", e) - - -# @suspendingreceiver( -# post_delete, -# sender="merchandise.MerchandiseSale", -# ) -# def post_merchandise_sale_delete(sender, instance, **kwargs): -# try: -# services.delete_merchandise_sale(instance) -# except Administration.Error as e: -# send_sync_error(e, instance) -# logging.exception("Moneybird synchronization error: %s", e) From c76f0854416855f1855a82da63a84ecd2affd168 Mon Sep 17 00:00:00 2001 From: movieminer Date: Tue, 31 Oct 2023 16:47:42 +0100 Subject: [PATCH 12/26] Some small changes wrt classes and names --- website/merchandise/admin.py | 6 +-- ...0012_remove_merchandiseitem_id_and_more.py | 34 +++++++------- website/merchandise/models.py | 2 +- website/moneybirdsynchronization/admin.py | 6 +-- ...neybirdgeneraljournaldocument_and_more.py} | 47 +++++++++++++------ website/moneybirdsynchronization/models.py | 34 +++++++------- website/moneybirdsynchronization/services.py | 25 +++++----- website/sales/admin/product_admin.py | 9 ---- website/sales/signals.py | 4 +- 9 files changed, 88 insertions(+), 79 deletions(-) rename website/moneybirdsynchronization/migrations/{0008_moneybirdmerchandisesalejournal.py => 0008_moneybirdgeneraljournaldocument_and_more.py} (78%) diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index eca1937b8..916fc78cd 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -14,10 +14,10 @@ class MerchandiseItemAdmin(ModelAdmin): fields = ( "name", "price", - "purchase_price", + "stock_value", "description", "image", ) search_fields = ("name", "description") - list_display = ("name", "price", "purchase_price") - list_filter = ("name", "price", "purchase_price") + list_display = ("name", "price", "stock_value") + list_filter = ("name", "price", "stock_value") diff --git a/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py b/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py index 13c15f0b4..1e133801a 100644 --- a/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py +++ b/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py @@ -6,6 +6,22 @@ itemlist = [] +def store_and_delete_merchandiseitems(apps, schema_editor): + MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") + itemlist = list(MerchandiseItem.objects.all()) + MerchandiseItem.objects.all().delete() + +# This does not seem to work yet +def create_merchandiseitems(apps, schema_editor): + MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") + for item in itemlist: + MerchandiseItem.objects.create( + name=item.name, + price=item.price, + description=item.description, + image=item.image, + purchase_price=0, + ) class Migration(migrations.Migration): dependencies = [ @@ -13,22 +29,6 @@ class Migration(migrations.Migration): ("merchandise", "0011_alter_merchandiseitem_image"), ] - def store_and_delete_merchandiseitems(apps, schema_editor): - MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") - itemlist = list(MerchandiseItem.objects.all()) - MerchandiseItem.objects.all().delete() - - # This does not seem to work yet - def create_merchandiseitems(apps, schema_editor): - MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") - for item in itemlist: - MerchandiseItem.objects.create( - name=item.name, - price=item.price, - description=item.description, - image=item.image, - purchase_price=0, - ) operations = [ migrations.RunPython(store_and_delete_merchandiseitems), @@ -56,7 +56,7 @@ def create_merchandiseitems(apps, schema_editor): ), migrations.AddField( model_name="merchandiseitem", - name="purchase_price", + name="stock_value", field=models.DecimalField(decimal_places=2, max_digits=8), ), migrations.RunPython(create_merchandiseitems), diff --git a/website/merchandise/models.py b/website/merchandise/models.py index 0b2b1c837..7c35c6d7f 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -23,7 +23,7 @@ class MerchandiseItem(Product): ) #: Purchase price of the merchandise item - purchase_price = models.DecimalField( + stock_value = models.DecimalField( max_digits=8, decimal_places=2, ) diff --git a/website/moneybirdsynchronization/admin.py b/website/moneybirdsynchronization/admin.py index 248e4e6fb..8551b381d 100644 --- a/website/moneybirdsynchronization/admin.py +++ b/website/moneybirdsynchronization/admin.py @@ -4,7 +4,7 @@ from .models import ( MoneybirdContact, MoneybirdExternalInvoice, - MoneybirdGeneralJournalDocument, + MoneybirdMerchandiseSaleJournal, MoneybirdPayment, ) @@ -107,8 +107,8 @@ def get_readonly_fields(self, request, obj=None): return ("payment",) -@admin.register(MoneybirdGeneralJournalDocument) -class MoneybirdGeneralJournalDocumentAdmin(admin.ModelAdmin): +@admin.register(MoneybirdMerchandiseSaleJournal) +class MoneybirdMerchandiseSaleJournalAdmin(admin.ModelAdmin): list_display = ( "order", "moneybird_general_journal_document_id", diff --git a/website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py b/website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py similarity index 78% rename from website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py rename to website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py index 59f9a0a98..47d61fd8c 100644 --- a/website/moneybirdsynchronization/migrations/0008_moneybirdmerchandisesalejournal.py +++ b/website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.6 on 2023-10-26 20:44 +# Generated by Django 4.2.6 on 2023-10-31 15:03 from django.db import migrations, models import django.db.models.deletion @@ -35,6 +35,36 @@ class Migration(migrations.Migration): 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.", + ), + ), + ], + ), + 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( @@ -54,24 +84,11 @@ class Migration(migrations.Migration): verbose_name="order", ), ), - ( - "needs_deletion", - models.BooleanField( - default=False, - help_text="Indicates that the journal has to be deleted from moneybird.", - ), - ), - ( - "needs_synchronization", - models.BooleanField( - default=True, - help_text="Indicates that the journal has to be synchronized (again).", - ), - ), ], 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 63c6d563d..71688e79b 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -400,12 +400,6 @@ class Meta: class MoneybirdGeneralJournalDocument(models.Model): - order = models.OneToOneField( - Order, - on_delete=models.CASCADE, - verbose_name=_("order"), - ) - moneybird_general_journal_document_id = models.CharField( verbose_name=_("moneybird general journal document id"), max_length=255, @@ -413,15 +407,6 @@ class MoneybirdGeneralJournalDocument(models.Model): null=True, ) - external_invoice = models.OneToOneField( - MoneybirdExternalInvoice, - on_delete=models.DO_NOTHING, - verbose_name=_("external invoice"), - related_name="moneybird_journal_external_invoice", - 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).", @@ -435,10 +420,27 @@ class MoneybirdGeneralJournalDocument(models.Model): 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( - MerchandiseItem.objects.get(name=i.product.product.name).purchase_price + MerchandiseItem.objects.get(name=i.product.product.name).stock_value * i.amount for i in items ) diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 138f6e351..0d8e2e904 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -16,6 +16,7 @@ MoneybirdContact, MoneybirdExternalInvoice, MoneybirdGeneralJournalDocument, + MoneybirdMerchandiseSaleJournal, MoneybirdPayment, financial_account_id_for_payment_type, ) @@ -167,14 +168,14 @@ def delete_external_invoice(obj): external_invoice.delete() -def create_or_update_general_journal_document(obj): +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, _ = MoneybirdGeneralJournalDocument.objects.get_or_create( + merchandise_sale_journal, _ = MoneybirdMerchandiseSaleJournal.objects.get_or_create( order=obj ) merchandise_sale_journal.external_invoice = external_invoice @@ -195,15 +196,15 @@ def create_or_update_general_journal_document(obj): merchandise_sale_journal.save() -def delete_general_journal_document(obj): +def delete_merchandise_sale_journal(obj): if not settings.MONEYBIRD_SYNC_ENABLED: return None try: - merchandise_sale_journal = MoneybirdGeneralJournalDocument.objects.get( + merchandise_sale_journal = MoneybirdMerchandiseSaleJournal.objects.get( order=obj ) - except MoneybirdGeneralJournalDocument.DoesNotExist: + except MoneybirdMerchandiseSaleJournal.DoesNotExist: return None if merchandise_sale_journal.moneybird_general_journal_document_id is None: @@ -238,7 +239,7 @@ def synchronize_moneybird(): # Resynchronize outdated invoices and journals. _sync_outdated_invoices() - _sync_outdated_journals() + _sync_outdated_merchandise_sale_journals() # Push all invoices and journals to moneybird. _sync_food_orders() @@ -312,9 +313,9 @@ def _sync_outdated_invoices(): logger.exception("Payable object for outdated invoice does not exist.") -def _sync_outdated_journals(): +def _sync_outdated_merchandise_sale_journals(): """Resynchronize all journals that have been marked as outdated.""" - journals = MoneybirdGeneralJournalDocument.objects.filter( + journals = MoneybirdMerchandiseSaleJournal.objects.filter( needs_synchronization=True, needs_deletion=False ).order_by("order__pk") @@ -323,7 +324,7 @@ def _sync_outdated_journals(): for journal in journals: try: instance = journal.order - create_or_update_general_journal_document(instance) + create_or_update_merchandise_sale_journal(instance) except Administration.Error as e: logger.exception("Moneybird synchronization error: %s", e) send_sync_error(e, instance) @@ -423,10 +424,10 @@ def _sync_merchandise_sales(): merchandise_sales = Order.objects.filter( shift__start__date__gte=settings.MONEYBIRD_START_DATE, payment__isnull=False, - shift__title__icontains="Merchandise sales", + shift__title="Merchandise", ).exclude( Exists( - MoneybirdGeneralJournalDocument.objects.filter( + MoneybirdMerchandiseSaleJournal.objects.filter( order=OuterRef("pk"), ) ) @@ -442,7 +443,7 @@ def _sync_merchandise_sales(): for instance in merchandise_sales: try: - create_or_update_general_journal_document(instance) + create_or_update_merchandise_sale_journal(instance) except Administration.Error as e: logger.exception("Moneybird synchronization error: %s", e) send_sync_error(e, instance) diff --git a/website/sales/admin/product_admin.py b/website/sales/admin/product_admin.py index ce134d62a..0ff66b5b8 100644 --- a/website/sales/admin/product_admin.py +++ b/website/sales/admin/product_admin.py @@ -1,10 +1,5 @@ -from typing import Any - from django.contrib import admin from django.contrib.admin import register -from django.db.models import Q -from django.db.models.query import QuerySet -from django.http.request import HttpRequest from sales.models.product import Product, ProductList, ProductListItem @@ -24,7 +19,3 @@ class ProductListAdmin(admin.ModelAdmin): inlines = [ ProductListItemInline, ] - - def get_queryset(self, request: HttpRequest) -> QuerySet[Any]: - queryset = super().get_queryset(request) - return queryset.filter(~Q(name="Merchandise Product List")) diff --git a/website/sales/signals.py b/website/sales/signals.py index d85807f9e..8026285fe 100644 --- a/website/sales/signals.py +++ b/website/sales/signals.py @@ -8,7 +8,7 @@ @receiver(post_save, sender=MerchandiseItem) def update_product_list(sender, instance, **kwargs): - product_list = ProductList.objects.get_or_create(name="Merchandise Product List")[0] + product_list = ProductList.objects.get_or_create(name="Merchandise")[0] product_list_products = product_list.products.all() merchandise_items = MerchandiseItem.objects.all() @@ -17,5 +17,3 @@ def update_product_list(sender, instance, **kwargs): product_list.product_items.create( product=merchandise_item, price=merchandise_item.price ) - - product_list.save() From 9a8b60b759ede60abc197d0f3ce51c4962f21966 Mon Sep 17 00:00:00 2001 From: movieminer Date: Tue, 31 Oct 2023 16:52:05 +0100 Subject: [PATCH 13/26] Small fix --- website/moneybirdsynchronization/services.py | 2 +- website/sales/services.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 0d8e2e904..b906676b2 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -424,7 +424,7 @@ def _sync_merchandise_sales(): merchandise_sales = Order.objects.filter( shift__start__date__gte=settings.MONEYBIRD_START_DATE, payment__isnull=False, - shift__title="Merchandise", + shift__title="Merchandise sales", ).exclude( Exists( MoneybirdMerchandiseSaleJournal.objects.filter( diff --git a/website/sales/services.py b/website/sales/services.py index 97ea032fb..3c3759b79 100644 --- a/website/sales/services.py +++ b/website/sales/services.py @@ -42,9 +42,7 @@ def execute_data_minimisation(dry_run=False): def create_daily_merchandise_sale_shift(): today = timezone.now().date() - merchandise_product_list = ProductList.objects.get_or_create( - name="Merchandise Product List" - )[0] + merchandise_product_list = ProductList.objects.get_or_create(name="Merchandise")[0] active_board = Board.objects.filter(since__lte=today, until__gte=today) shift = Shift.objects.create( From 4df93b997a76724514d6f5806819b2d36b3f48f1 Mon Sep 17 00:00:00 2001 From: movieminer Date: Wed, 1 Nov 2023 10:38:24 +0100 Subject: [PATCH 14/26] Added attribute id --- website/merchandise/models.py | 1 - ...oneybirdgeneraljournaldocument_and_more.py | 20 +++++++++++++++- website/moneybirdsynchronization/models.py | 24 +++++++++++++++++++ website/moneybirdsynchronization/services.py | 22 +++++++++++------ 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/website/merchandise/models.py b/website/merchandise/models.py index 88280028f..e2361ce89 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -5,7 +5,6 @@ from thumbnails.fields import ImageField from sales.models.product import Product -from thaliawebsite.storage.backend import get_public_storage from utils.media.services import get_upload_to_function _merchandise_photo_upload_to = get_upload_to_function("merchandise") diff --git a/website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py b/website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py index 47d61fd8c..7c9efbd17 100644 --- a/website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py +++ b/website/moneybirdsynchronization/migrations/0008_moneybirdgeneraljournaldocument_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.6 on 2023-10-31 15:03 +# Generated by Django 4.2.6 on 2023-11-01 09:33 from django.db import migrations, models import django.db.models.deletion @@ -49,6 +49,24 @@ class Migration(migrations.Migration): 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( diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 71688e79b..ff0e2a810 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -417,6 +417,20 @@ class MoneybirdGeneralJournalDocument(models.Model): 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 __str__(self): return f"Moneybird journal for {self.order}" @@ -471,6 +485,16 @@ def to_moneybird(self): } } + 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: diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index b906676b2..09bc63f27 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -183,14 +183,22 @@ def create_or_update_merchandise_sale_journal(obj): # 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.delete_general_journal_document( - 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(), ) - response = moneybird.create_general_journal_document( - merchandise_sale_journal.to_moneybird(), - ) - merchandise_sale_journal.moneybird_general_journal_document_id = response["id"] - merchandise_sale_journal.save() + 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[ + "details" + ][0]["id"] + merchandise_sale_journal.moneybird_details_credit_attribute_id = response[ + "details" + ][1]["id"] merchandise_sale_journal.needs_synchronization = False merchandise_sale_journal.save() From 28f853f36bfa0feb4d9711713d33b7fd9b77c862 Mon Sep 17 00:00:00 2001 From: movieminer Date: Wed, 1 Nov 2023 10:42:32 +0100 Subject: [PATCH 15/26] correct api call for attr id --- website/moneybirdsynchronization/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 09bc63f27..233ac25ac 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -194,10 +194,10 @@ def create_or_update_merchandise_sale_journal(obj): merchandise_sale_journal.moneybird_general_journal_document_id = response["id"] merchandise_sale_journal.moneybird_details_debit_attribute_id = response[ - "details" + "general_journal_document_entries_attributes" ][0]["id"] merchandise_sale_journal.moneybird_details_credit_attribute_id = response[ - "details" + "general_journal_document_entries_attributes" ][1]["id"] merchandise_sale_journal.needs_synchronization = False From a95af92196ccba2a6c48e678e341cd22322e1659 Mon Sep 17 00:00:00 2001 From: movieminer Date: Wed, 1 Nov 2023 11:36:03 +0100 Subject: [PATCH 16/26] Fixed migration --- ...0012_remove_merchandiseitem_id_and_more.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py b/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py index 1e133801a..3d6e6d887 100644 --- a/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py +++ b/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py @@ -6,30 +6,39 @@ itemlist = [] + def store_and_delete_merchandiseitems(apps, schema_editor): - MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") - itemlist = list(MerchandiseItem.objects.all()) - MerchandiseItem.objects.all().delete() + MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") + global itemlist + itemlist = list(MerchandiseItem.objects.all()) + MerchandiseItem.objects.all().delete() + # This does not seem to work yet def create_merchandiseitems(apps, schema_editor): MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") + Product = apps.get_model("sales", "Product") + for item in itemlist: - MerchandiseItem.objects.create( + product = Product.objects.create( name=item.name, + ) + + MerchandiseItem.objects.create( + product_ptr=product, price=item.price, description=item.description, image=item.image, - purchase_price=0, + stock_value=0, ) + class Migration(migrations.Migration): dependencies = [ ("sales", "0007_alter_productlistitem_options_and_more"), ("merchandise", "0011_alter_merchandiseitem_image"), ] - operations = [ migrations.RunPython(store_and_delete_merchandiseitems), migrations.RemoveField( From 8f3fee0b653af92d1a5bcac30c1c41df57842491 Mon Sep 17 00:00:00 2001 From: movieminer Date: Wed, 1 Nov 2023 12:05:45 +0100 Subject: [PATCH 17/26] Small fixes --- website/moneybirdsynchronization/admin.py | 4 ++++ website/moneybirdsynchronization/services.py | 4 ++-- website/sales/services.py | 17 ++++++++++++++++- website/sales/signals.py | 14 +++----------- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/website/moneybirdsynchronization/admin.py b/website/moneybirdsynchronization/admin.py index 8551b381d..ba8b13def 100644 --- a/website/moneybirdsynchronization/admin.py +++ b/website/moneybirdsynchronization/admin.py @@ -119,6 +119,8 @@ class MoneybirdMerchandiseSaleJournalAdmin(admin.ModelAdmin): readonly_fields = ( "order", "moneybird_general_journal_document_id", + "moneybird_details_debit_attribute_id", + "moneybird_details_credit_attribute_id", "needs_synchronization", "needs_deletion", ) @@ -126,6 +128,8 @@ class MoneybirdMerchandiseSaleJournalAdmin(admin.ModelAdmin): fields = ( "order", "moneybird_general_journal_document_id", + "moneybird_details_debit_attribute_id", + "moneybird_details_credit_attribute_id", "external_invoice", ) diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 233ac25ac..7e76c5933 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -194,10 +194,10 @@ def create_or_update_merchandise_sale_journal(obj): merchandise_sale_journal.moneybird_general_journal_document_id = response["id"] merchandise_sale_journal.moneybird_details_debit_attribute_id = response[ - "general_journal_document_entries_attributes" + "general_journal_document_entries" ][0]["id"] merchandise_sale_journal.moneybird_details_credit_attribute_id = response[ - "general_journal_document_entries_attributes" + "general_journal_document_entries" ][1]["id"] merchandise_sale_journal.needs_synchronization = False diff --git a/website/sales/services.py b/website/sales/services.py index 3c3759b79..085c06899 100644 --- a/website/sales/services.py +++ b/website/sales/services.py @@ -3,6 +3,7 @@ from django.utils import timezone from activemembers.models import Board +from merchandise.models import MerchandiseItem from sales.models.order import Order from sales.models.product import ProductList from sales.models.shift import Shift @@ -40,9 +41,23 @@ def execute_data_minimisation(dry_run=False): return queryset.all() +def update_merchandise_product_list(): + product_list = ProductList.objects.get_or_create(name="Merchandise")[0] + product_list_products = product_list.products.all() + merchandise_items = MerchandiseItem.objects.all() + + for merchandise_item in merchandise_items: + if merchandise_item not in product_list_products: + product_list.product_items.create( + product=merchandise_item, price=merchandise_item.price + ) + + return product_list + + def create_daily_merchandise_sale_shift(): today = timezone.now().date() - merchandise_product_list = ProductList.objects.get_or_create(name="Merchandise")[0] + merchandise_product_list = update_merchandise_product_list() active_board = Board.objects.filter(since__lte=today, until__gte=today) shift = Shift.objects.create( diff --git a/website/sales/signals.py b/website/sales/signals.py index 8026285fe..5fdcd9be3 100644 --- a/website/sales/signals.py +++ b/website/sales/signals.py @@ -3,17 +3,9 @@ from merchandise.models import MerchandiseItem -from .models.product import ProductList +from .services import update_merchandise_product_list @receiver(post_save, sender=MerchandiseItem) -def update_product_list(sender, instance, **kwargs): - product_list = ProductList.objects.get_or_create(name="Merchandise")[0] - product_list_products = product_list.products.all() - merchandise_items = MerchandiseItem.objects.all() - - for merchandise_item in merchandise_items: - if merchandise_item not in product_list_products: - product_list.product_items.create( - product=merchandise_item, price=merchandise_item.price - ) +def update_merchandise_product_list_on_save(sender, instance, **kwargs): + update_merchandise_product_list() From 0868f73686278a121fdf7bb2af123fe0400ef6ec Mon Sep 17 00:00:00 2001 From: movieminer Date: Mon, 6 Nov 2023 16:37:26 +0100 Subject: [PATCH 18/26] Add merchandise item syncing to Moneybird --- website/moneybirdsynchronization/models.py | 3 +++ website/moneybirdsynchronization/services.py | 5 +++-- website/sales/services.py | 12 +++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index ff0e2a810..720dfb876 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -431,6 +431,9 @@ class MoneybirdGeneralJournalDocument(models.Model): null=True, ) + def to_moneybird(self): + raise NotImplementedError + def __str__(self): return f"Moneybird journal for {self.order}" diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 7e76c5933..9379ea5ca 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -10,6 +10,7 @@ from events.models import EventRegistration from members.models import Member +from merchandise.models import MerchandiseItem from moneybirdsynchronization.administration import Administration from moneybirdsynchronization.emails import send_sync_error from moneybirdsynchronization.models import ( @@ -432,7 +433,7 @@ def _sync_merchandise_sales(): merchandise_sales = Order.objects.filter( shift__start__date__gte=settings.MONEYBIRD_START_DATE, payment__isnull=False, - shift__title="Merchandise sales", + items__product__merchandiseitem__in=MerchandiseItem.objects.all(), ).exclude( Exists( MoneybirdMerchandiseSaleJournal.objects.filter( @@ -440,7 +441,7 @@ def _sync_merchandise_sales(): ) ) ) - + print(merchandise_sales) if not merchandise_sales.exists(): return diff --git a/website/sales/services.py b/website/sales/services.py index 085c06899..879373bb6 100644 --- a/website/sales/services.py +++ b/website/sales/services.py @@ -1,5 +1,6 @@ import datetime +from django.db.models import Q from django.utils import timezone from activemembers.models import Board @@ -46,11 +47,12 @@ def update_merchandise_product_list(): product_list_products = product_list.products.all() merchandise_items = MerchandiseItem.objects.all() - for merchandise_item in merchandise_items: - if merchandise_item not in product_list_products: - product_list.product_items.create( - product=merchandise_item, price=merchandise_item.price - ) + for merchandise_item in merchandise_items.filter( + ~Q(productlistitem__product__in=product_list_products) + ): + product_list.product_items.create( + product=merchandise_item, price=merchandise_item.price + ) return product_list From 8a8b28fcdfccba386d7bf0f265d80b9306b838ae Mon Sep 17 00:00:00 2001 From: movieminer Date: Mon, 6 Nov 2023 16:43:33 +0100 Subject: [PATCH 19/26] Remove unused import --- website/moneybirdsynchronization/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index a18ccc917..5a048fa31 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _ from events.models import EventRegistration -from members.models import Member, Membership +from members.models import Member from merchandise.models import MerchandiseItem from moneybirdsynchronization.moneybird import get_moneybird_api_service from payments.models import BankAccount, Payment From f75feb378697e4799bc3278447a514a6900d25c1 Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Tue, 7 Nov 2023 09:16:56 +0100 Subject: [PATCH 20/26] Rewrite to FKs --- website/merchandise/admin.py | 16 +++-- ...0012_remove_merchandiseitem_id_and_more.py | 72 ------------------- website/merchandise/models.py | 31 +++++--- 3 files changed, 32 insertions(+), 87 deletions(-) delete mode 100644 website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index 916fc78cd..03cc2b4bd 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -3,7 +3,14 @@ from django.contrib import admin from django.contrib.admin import ModelAdmin -from .models import MerchandiseItem +from .models import MerchandiseItem, MerchandiseProduct + + +class MerchandiseProductInline(admin.TabularInline): + """Inline admin interface for the merchandise products.""" + + model = MerchandiseProduct + extra = 0 @admin.register(MerchandiseItem) @@ -14,10 +21,11 @@ class MerchandiseItemAdmin(ModelAdmin): fields = ( "name", "price", - "stock_value", "description", "image", ) search_fields = ("name", "description") - list_display = ("name", "price", "stock_value") - list_filter = ("name", "price", "stock_value") + list_display = ("name", "price") + list_filter = ("name", "price") + + inlines = [MerchandiseProductInline] diff --git a/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py b/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py deleted file mode 100644 index 3d6e6d887..000000000 --- a/website/merchandise/migrations/0012_remove_merchandiseitem_id_and_more.py +++ /dev/null @@ -1,72 +0,0 @@ -# Generated by Django 4.2.6 on 2023-10-26 14:52 - -from django.db import migrations, models -import django.db.models.deletion - - -itemlist = [] - - -def store_and_delete_merchandiseitems(apps, schema_editor): - MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") - global itemlist - itemlist = list(MerchandiseItem.objects.all()) - MerchandiseItem.objects.all().delete() - - -# This does not seem to work yet -def create_merchandiseitems(apps, schema_editor): - MerchandiseItem = apps.get_model("merchandise", "MerchandiseItem") - Product = apps.get_model("sales", "Product") - - for item in itemlist: - product = Product.objects.create( - name=item.name, - ) - - MerchandiseItem.objects.create( - product_ptr=product, - price=item.price, - description=item.description, - image=item.image, - stock_value=0, - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("sales", "0007_alter_productlistitem_options_and_more"), - ("merchandise", "0011_alter_merchandiseitem_image"), - ] - - operations = [ - migrations.RunPython(store_and_delete_merchandiseitems), - migrations.RemoveField( - model_name="merchandiseitem", - name="id", - ), - migrations.RemoveField( - model_name="merchandiseitem", - name="name", - ), - migrations.AddField( - model_name="merchandiseitem", - name="product_ptr", - field=models.OneToOneField( - auto_created=True, - default="", - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="sales.product", - ), - preserve_default=False, - ), - migrations.AddField( - model_name="merchandiseitem", - name="stock_value", - field=models.DecimalField(decimal_places=2, max_digits=8), - ), - migrations.RunPython(create_merchandiseitems), - ] diff --git a/website/merchandise/models.py b/website/merchandise/models.py index e2361ce89..7316acd66 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -4,29 +4,23 @@ 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") -class MerchandiseItem(Product): +class MerchandiseItem(models.Model): """Merchandise items. This model describes merchandise items. """ - #: Price of the merchandise item - price = models.DecimalField( - max_digits=8, - decimal_places=2, - ) + name = models.CharField(max_length=200) - #: Purchase price of the merchandise item - stock_value = models.DecimalField( - max_digits=8, - decimal_places=2, - ) + #: Price of the merchandise item + price = PaymentAmountField() #: Description of the merchandise item description = models.TextField() @@ -65,3 +59,18 @@ 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() + + def __str__(self): + """Give the name of the merchandise product in the currently active locale.""" + return f"{self.name} ({self.merchandise_item})" From 540dc55da13e95ed0b0a491e7702a8f34406d36b Mon Sep 17 00:00:00 2001 From: Dirk Doesburg Date: Tue, 7 Nov 2023 13:38:18 +0100 Subject: [PATCH 21/26] Move changes to merchandise --- website/merchandise/apps.py | 3 ++ website/merchandise/services.py | 49 +++++++++++++++++++++ website/{sales => merchandise}/signals.py | 0 website/{sales => merchandise}/tasks.py | 3 +- website/sales/apps.py | 1 - website/sales/services.py | 52 ----------------------- website/thaliawebsite/settings.py | 2 +- 7 files changed, 55 insertions(+), 55 deletions(-) create mode 100644 website/merchandise/services.py rename website/{sales => merchandise}/signals.py (100%) rename website/{sales => merchandise}/tasks.py (53%) 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/services.py b/website/merchandise/services.py new file mode 100644 index 000000000..249e4187a --- /dev/null +++ b/website/merchandise/services.py @@ -0,0 +1,49 @@ +import datetime + +from django.db.models import Q +from django.utils import timezone + +from activemembers.models import Board +from merchandise.models import MerchandiseItem +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] + product_list_products = product_list.products.all() + merchandise_items = MerchandiseItem.objects.all() + + for merchandise_item in merchandise_items.filter( + ~Q(productlistitem__product__in=product_list_products) + ): + product_list.product_items.create( + product=merchandise_item, price=merchandise_item.price + ) + + 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/sales/signals.py b/website/merchandise/signals.py similarity index 100% rename from website/sales/signals.py rename to website/merchandise/signals.py diff --git a/website/sales/tasks.py b/website/merchandise/tasks.py similarity index 53% rename from website/sales/tasks.py rename to website/merchandise/tasks.py index 12bfa221b..146635fcf 100644 --- a/website/sales/tasks.py +++ b/website/merchandise/tasks.py @@ -5,4 +5,5 @@ @shared_task def renew_merchandise_sale_shift(): - services.renew_merchandise_sale_shift() + services.lock_merchandise_sale_shift() + services.create_daily_merchandise_sale_shift() diff --git a/website/sales/apps.py b/website/sales/apps.py index 5603cbc9b..f58719e19 100644 --- a/website/sales/apps.py +++ b/website/sales/apps.py @@ -5,7 +5,6 @@ class SalesConfig(AppConfig): name = "sales" def ready(self): - from . import signals # noqa: F401 from .payables import register register() diff --git a/website/sales/services.py b/website/sales/services.py index 879373bb6..696dcc3cd 100644 --- a/website/sales/services.py +++ b/website/sales/services.py @@ -1,13 +1,6 @@ -import datetime - -from django.db.models import Q from django.utils import timezone -from activemembers.models import Board -from merchandise.models import MerchandiseItem from sales.models.order import Order -from sales.models.product import ProductList -from sales.models.shift import Shift def is_adult(member): @@ -40,48 +33,3 @@ def execute_data_minimisation(dry_run=False): if not dry_run: queryset.update(payer=None) return queryset.all() - - -def update_merchandise_product_list(): - product_list = ProductList.objects.get_or_create(name="Merchandise")[0] - product_list_products = product_list.products.all() - merchandise_items = MerchandiseItem.objects.all() - - for merchandise_item in merchandise_items.filter( - ~Q(productlistitem__product__in=product_list_products) - ): - product_list.product_items.create( - product=merchandise_item, price=merchandise_item.price - ) - - 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() - - -def renew_merchandise_sale_shift(): - lock_merchandise_sale_shift() - create_daily_merchandise_sale_shift() diff --git a/website/thaliawebsite/settings.py b/website/thaliawebsite/settings.py index c775953de..de0e976a7 100644 --- a/website/thaliawebsite/settings.py +++ b/website/thaliawebsite/settings.py @@ -350,7 +350,7 @@ def from_env( # https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html CELERY_BEAT_SCHEDULE = { "renew_merchandise_sale_shift": { - "task": "sales.tasks.renew_merchandise_sale_shift", + "task": "merchandise.tasks.renew_merchandise_sale_shift", "schedule": crontab(minute=0, hour=0), }, "synchronize_mailinglists": { From 73fe1ceba5e830a29936f7b463f47d32a67b523b Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 12 Nov 2023 22:49:29 +0100 Subject: [PATCH 22/26] Fix admin --- website/merchandise/admin.py | 79 +++++++++++++++++++ ...erchandiseitem_price_merchandiseproduct.py | 56 +++++++++++++ website/merchandise/models.py | 8 +- website/merchandise/services.py | 18 ++--- website/merchandise/signals.py | 3 +- 5 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py diff --git a/website/merchandise/admin.py b/website/merchandise/admin.py index 03cc2b4bd..a5cc6d291 100644 --- a/website/merchandise/admin.py +++ b/website/merchandise/admin.py @@ -2,6 +2,11 @@ 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 @@ -12,6 +17,11 @@ class MerchandiseProductInline(admin.TabularInline): model = MerchandiseProduct extra = 0 + fields = ( + "name", + "stock_value", + ) + @admin.register(MerchandiseItem) class MerchandiseItemAdmin(ModelAdmin): @@ -29,3 +39,72 @@ class MerchandiseItemAdmin(ModelAdmin): 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/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py b/website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py new file mode 100644 index 000000000..53cd371bd --- /dev/null +++ b/website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.6 on 2023-11-12 21:08 + +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, + 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, + 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 7316acd66..ef84fd36e 100644 --- a/website/merchandise/models.py +++ b/website/merchandise/models.py @@ -20,7 +20,9 @@ class MerchandiseItem(models.Model): name = models.CharField(max_length=200) #: Price of the merchandise item - price = PaymentAmountField() + price = PaymentAmountField( + help_text="Current sales price of the merchandise item per piece (incl. VAT)." + ) #: Description of the merchandise item description = models.TextField() @@ -69,7 +71,9 @@ class MerchandiseProduct(Product): on_delete=models.CASCADE, ) - stock_value = PaymentAmountField() + 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.""" diff --git a/website/merchandise/services.py b/website/merchandise/services.py index 249e4187a..6ff7971b9 100644 --- a/website/merchandise/services.py +++ b/website/merchandise/services.py @@ -1,25 +1,21 @@ import datetime -from django.db.models import Q from django.utils import timezone from activemembers.models import Board -from merchandise.models import MerchandiseItem +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] - product_list_products = product_list.products.all() - merchandise_items = MerchandiseItem.objects.all() - - for merchandise_item in merchandise_items.filter( - ~Q(productlistitem__product__in=product_list_products) - ): - product_list.product_items.create( - product=merchandise_item, price=merchandise_item.price - ) + merchandise_products = MerchandiseProduct.objects.all() + + for merchandise_product in merchandise_products: + item, _ = product_list.product_items.get_or_create(product=merchandise_product) + item.price = merchandise_product.merchandise_item.price + item.save() return product_list diff --git a/website/merchandise/signals.py b/website/merchandise/signals.py index 5fdcd9be3..04061c7bc 100644 --- a/website/merchandise/signals.py +++ b/website/merchandise/signals.py @@ -1,11 +1,12 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from merchandise.models import MerchandiseItem +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() From 864f33aead028aef26b14bb0ecff940c108da213 Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 12 Nov 2023 22:51:10 +0100 Subject: [PATCH 23/26] Fix admin --- .../0012_alter_merchandiseitem_price_merchandiseproduct.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py b/website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py index 53cd371bd..dff56d289 100644 --- a/website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py +++ b/website/merchandise/migrations/0012_alter_merchandiseitem_price_merchandiseproduct.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.6 on 2023-11-12 21:08 +# Generated by Django 4.2.6 on 2023-11-12 21:51 from django.db import migrations, models import django.db.models.deletion @@ -17,6 +17,7 @@ class Migration(migrations.Migration): 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], ), @@ -39,6 +40,7 @@ class Migration(migrations.Migration): "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], ), From 2ce043305fa9166e4955679b05ad00332ffb7a33 Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 12 Nov 2023 22:56:34 +0100 Subject: [PATCH 24/26] Fix moneybird --- website/moneybirdsynchronization/models.py | 5 +---- website/moneybirdsynchronization/services.py | 5 ++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 5a048fa31..4ae35f7d8 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -11,7 +11,6 @@ from events.models import EventRegistration from members.models import Member -from merchandise.models import MerchandiseItem from moneybirdsynchronization.moneybird import get_moneybird_api_service from payments.models import BankAccount, Payment from payments.payables import payables @@ -465,9 +464,7 @@ class MoneybirdMerchandiseSaleJournal(MoneybirdGeneralJournalDocument): def to_moneybird(self): items = self.order.order_items.all() total_purchase_amount = sum( - MerchandiseItem.objects.get(name=i.product.product.name).stock_value - * i.amount - for i in items + i.merchandiseproduct.stock_value or 0 * i.amount for i in items ) merchandise_stock_ledger_id = settings.MONEYBIRD_MERCHANDISE_STOCK_LEDGER_ID diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py index 9379ea5ca..b123f47e5 100644 --- a/website/moneybirdsynchronization/services.py +++ b/website/moneybirdsynchronization/services.py @@ -10,7 +10,7 @@ from events.models import EventRegistration from members.models import Member -from merchandise.models import MerchandiseItem +from merchandise.models import MerchandiseProduct from moneybirdsynchronization.administration import Administration from moneybirdsynchronization.emails import send_sync_error from moneybirdsynchronization.models import ( @@ -433,7 +433,7 @@ def _sync_merchandise_sales(): merchandise_sales = Order.objects.filter( shift__start__date__gte=settings.MONEYBIRD_START_DATE, payment__isnull=False, - items__product__merchandiseitem__in=MerchandiseItem.objects.all(), + items__product__merchandiseproduct__in=MerchandiseProduct.objects.all(), ).exclude( Exists( MoneybirdMerchandiseSaleJournal.objects.filter( @@ -441,7 +441,6 @@ def _sync_merchandise_sales(): ) ) ) - print(merchandise_sales) if not merchandise_sales.exists(): return From 67509f0122fe326a1731ad225bc1bd730ebd4c57 Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 12 Nov 2023 23:44:56 +0100 Subject: [PATCH 25/26] Fix bugs --- website/merchandise/services.py | 5 ++++- website/moneybirdsynchronization/models.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/website/merchandise/services.py b/website/merchandise/services.py index 6ff7971b9..1c888a718 100644 --- a/website/merchandise/services.py +++ b/website/merchandise/services.py @@ -13,7 +13,10 @@ def update_merchandise_product_list(): merchandise_products = MerchandiseProduct.objects.all() for merchandise_product in merchandise_products: - item, _ = product_list.product_items.get_or_create(product=merchandise_product) + 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() diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 4ae35f7d8..453e1f6c2 100644 --- a/website/moneybirdsynchronization/models.py +++ b/website/moneybirdsynchronization/models.py @@ -464,7 +464,8 @@ class MoneybirdMerchandiseSaleJournal(MoneybirdGeneralJournalDocument): def to_moneybird(self): items = self.order.order_items.all() total_purchase_amount = sum( - i.merchandiseproduct.stock_value or 0 * i.amount for i in items + i.product.product.merchandiseproduct.stock_value or 0 * i.amount + for i in items ) merchandise_stock_ledger_id = settings.MONEYBIRD_MERCHANDISE_STOCK_LEDGER_ID @@ -480,14 +481,18 @@ def to_moneybird(self): "debit": str(total_purchase_amount), "credit": "0", "description": self.order.payment.notes, - "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id, + "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, + "contact_id": self.external_invoice.payable.payment_payer.moneybird_contact.moneybird_id + if self.external_invoice.payable.payment_payer + else None, }, }, } From 7b337edeb3ac196a67d1f3f8d7e0b6a4b01d36d3 Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sat, 25 Nov 2023 14:30:39 +0100 Subject: [PATCH 26/26] Sync individual items --- website/moneybirdsynchronization/models.py | 165 +++++++++++++-------- 1 file changed, 107 insertions(+), 58 deletions(-) diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py index 453e1f6c2..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,14 +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 - if isinstance(obj, Order): - 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]: @@ -53,10 +72,8 @@ 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 - if isinstance(obj, (Registration, Renewal)): return obj.created_at.date() - if isinstance(obj, Order): + if isinstance(obj, (Registration, Renewal)): return obj.created_at.date() raise ValueError(f"Unknown payable model {obj}") @@ -68,15 +85,15 @@ def period_for_payable_model(obj) -> Optional[str]: # 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')}" - if isinstance(obj, Order): - return settings.MONEYBIRD_MERCHANDISE_SALES_LEDGER_ID - - raise ValueError(f"Unknown payable model {obj}") + 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 @@ -86,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"), @@ -279,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,), @@ -306,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_account_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