diff --git a/src/backoffice/templates/reimbursement_list_backoffice.html b/src/backoffice/templates/reimbursement_list_backoffice.html index 039404817..92e793a35 100644 --- a/src/backoffice/templates/reimbursement_list_backoffice.html +++ b/src/backoffice/templates/reimbursement_list_backoffice.html @@ -10,12 +10,12 @@
This view shows all existing reimbursements for {{ camp.title }}. Users have to create their own reimbursements when they are done adding expenses. The user will be asked for a bank account when creating the reimbursement.
- Backoffice + Backoffice
{% include 'includes/reimbursement_list_panel.html' %}- Backoffice + Backoffice
{% endblock content %} diff --git a/src/backoffice/views/economy.py b/src/backoffice/views/economy.py index f9b09b3e8..969e6f19a 100644 --- a/src/backoffice/views/economy.py +++ b/src/backoffice/views/economy.py @@ -160,21 +160,21 @@ def get_context_data(self, *args, **kwargs): context["expenses"] = Expense.objects.filter( camp=self.camp, creditor__chain=self.get_object(), - ).prefetch_related("responsible_team", "user", "creditor") + ).prefetch_related("user", "creditor") context["revenues"] = Revenue.objects.filter( camp=self.camp, debtor__chain=self.get_object(), - ).prefetch_related("responsible_team", "user", "debtor") + ).prefetch_related("user", "debtor") # Include past years expenses and revenues for the Chain in context as separate querysets context["past_expenses"] = Expense.objects.filter( camp__camp__lt=self.camp.camp, creditor__chain=self.get_object(), - ).prefetch_related("responsible_team", "user", "creditor") + ).prefetch_related("user", "creditor") context["past_revenues"] = Revenue.objects.filter( camp__camp__lt=self.camp.camp, debtor__chain=self.get_object(), - ).prefetch_related("responsible_team", "user", "debtor") + ).prefetch_related("user", "debtor") return context @@ -187,10 +187,10 @@ class CredebtorDetailView(CampViewMixin, EconomyTeamPermissionMixin, DetailView) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) context["expenses"] = ( - self.get_object().expenses.filter(camp=self.camp).prefetch_related("responsible_team", "user", "creditor") + self.get_object().expenses.filter(camp=self.camp).prefetch_related("user", "creditor") ) context["revenues"] = ( - self.get_object().revenues.filter(camp=self.camp).prefetch_related("responsible_team", "user", "debtor") + self.get_object().revenues.filter(camp=self.camp).prefetch_related("user", "debtor") ) return context @@ -209,7 +209,6 @@ def get_queryset(self, **kwargs): return queryset.exclude(approved__isnull=True).prefetch_related( "creditor", "user", - "responsible_team", ) def get_context_data(self, **kwargs): @@ -221,7 +220,6 @@ def get_context_data(self, **kwargs): ).prefetch_related( "creditor", "user", - "responsible_team", ) return context @@ -275,12 +273,13 @@ class ReimbursementUpdateView( ): model = Reimbursement template_name = "reimbursement_form.html" - fields = ["notes", "paid"] + fields = ["notes"] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["expenses"] = self.object.expenses.filter(paid_by_bornhack=False) - context["total_amount"] = context["expenses"].aggregate(Sum("amount")) + context["expenses"] = self.object.covered_expenses.all() + context["revenues"] = self.object.covered_revenues.all() + context["total_amount"] = self.object.amount context["reimbursement_user"] = self.object.reimbursement_user context["cancelurl"] = reverse( "backoffice:reimbursement_list", @@ -288,6 +287,22 @@ def get_context_data(self, **kwargs): ) return context + def form_valid(self, form): + """Backoffice has two submit buttons in this form, 'Just Save', and 'Mark as Paid'.""" + if "paid" in form.data: + # mark as paid button was pressed + reimbursement = form.save() + reimbursement.mark_as_paid() + messages.success(self.request, "Reimbursement marked as paid, related expenses and revenues payment_status set accordingly") + elif "save" in form.data: + reimbursement = form.save() + messages.success(self.request, "Reimbursement notes updated") + else: + messages.error(self.request, "Unknown submit action") + return redirect( + reverse("backoffice:expense_list", kwargs={"camp_slug": self.camp.slug}), + ) + def get_success_url(self): return reverse( "backoffice:reimbursement_detail", @@ -299,7 +314,7 @@ class ReimbursementDeleteView(CampViewMixin, EconomyTeamPermissionMixin, DeleteV model = Reimbursement template_name = "reimbursement_delete.html" - def get(self, request, *args, **kwargs): + def dispatch(self, request, *args, **kwargs): if self.get_object().paid: messages.error( request, @@ -312,7 +327,7 @@ def get(self, request, *args, **kwargs): ), ) # continue with the request - return super().get(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def get_success_url(self): messages.success( @@ -339,7 +354,6 @@ def get_queryset(self, **kwargs): return queryset.exclude(approved__isnull=True).prefetch_related( "debtor", "user", - "responsible_team", ) def get_context_data(self, **kwargs): diff --git a/src/bornhack/environment_settings.py.dist b/src/bornhack/environment_settings.py.dist index 31ffbbb04..8cc582edd 100644 --- a/src/bornhack/environment_settings.py.dist +++ b/src/bornhack/environment_settings.py.dist @@ -13,17 +13,14 @@ DATABASES = { 'HOST': '{{ django_postgres_host }}', # comment this out for non-tls connection to postgres 'OPTIONS': {'sslmode': 'verify-full', 'sslrootcert': 'system'}, + # always use transactions + 'ATOMIC_REQUESTS': True, }, } DEBUG={{ django_debug }} DEBUG_TOOLBAR_ENABLED={{ django_debug_toolbar_enabled }} - -# start redirecting to the next camp instead of the previous camp after -# this much of the time between the camps has passed -CAMP_REDIRECT_PERCENT=15 - ### changes below here are only needed for production # email settings diff --git a/src/economy/admin.py b/src/economy/admin.py index 7aff9cf45..bdf50bd1a 100644 --- a/src/economy/admin.py +++ b/src/economy/admin.py @@ -68,7 +68,6 @@ class ExpenseAdmin(admin.ModelAdmin): "camp", "creditor__chain", "creditor", - "responsible_team", "approved", "user", ] @@ -79,7 +78,6 @@ class ExpenseAdmin(admin.ModelAdmin): "amount", "camp", "creditor", - "responsible_team", "approved", "reimbursement", ] @@ -109,14 +107,13 @@ def reject_revenues(modeladmin, request, queryset) -> None: @admin.register(Revenue) class RevenueAdmin(admin.ModelAdmin): - list_filter = ["camp", "responsible_team", "approved", "user"] + list_filter = ["camp", "approved", "user"] list_display = [ "user", "description", "invoice_date", "amount", "camp", - "responsible_team", "approved", ] search_fields = ["description", "amount", "user"] diff --git a/src/economy/factories.py b/src/economy/factories.py index 0c0cd50f7..c98eeb97a 100644 --- a/src/economy/factories.py +++ b/src/economy/factories.py @@ -549,9 +549,9 @@ class Meta: color=random.choice(["#ff0000", "#00ff00", "#0000ff"]), ) invoice_date = factory.Faker("date") - responsible_team = factory.Faker("random_element", elements=Team.objects.all()) approved = factory.Faker("random_element", elements=[True, True, False, None]) notes = factory.Faker("text") + payment_status = factory.Faker("random_element", elements=["PAID_IN_NETBANK", "PAID_WITH_TYKLINGS_MASTERCARD", "PAID_WITH_AHFS_MASTERCARD", "PAID_NEEDS_REIMBURSEMENT"]) class RevenueFactory(factory.django.DjangoModelFactory): @@ -571,6 +571,6 @@ class Meta: color=random.choice(["#ff0000", "#00ff00", "#0000ff"]), ) invoice_date = factory.Faker("date") - responsible_team = factory.Faker("random_element", elements=Team.objects.all()) approved = factory.Faker("random_element", elements=[True, True, False, None]) notes = factory.Faker("text") + payment_status = factory.Faker("random_element", elements=["PAID_IN_NETBANK", "PAID_TO_TYKLINGS_MASTERCARD", "PAID_TO_AHFS_MASTERCARD", "PAID_NEEDS_REDISBURSEMENT"]) diff --git a/src/economy/forms.py b/src/economy/forms.py index 5143a3649..8a7b41426 100644 --- a/src/economy/forms.py +++ b/src/economy/forms.py @@ -9,12 +9,8 @@ from .models import Revenue -class CleanInvoiceForm(forms.ModelForm): - """We have to define this form explicitly because we want our ImageField to accept PDF files as well as images, - and we cannot change the clean_* methods with an autogenerated form from inside views.py. - """ - - invoice = forms.FileField() +class CleanInvoiceMixin: + """We want our ImageFields to accept PDF files as well as images.""" def clean_invoice(self): # get the uploaded file from cleaned_data @@ -38,44 +34,95 @@ def clean_invoice(self): return uploaded_file -class ExpenseCreateForm(CleanInvoiceForm): +class ExpenseUpdateForm(forms.ModelForm): class Meta: model = Expense fields = [ "description", "amount", + "payment_status", "invoice_date", - "invoice", - "paid_by_bornhack", - "responsible_team", ] + def __init__(self, *args, **kwargs): + """Remove some choices.""" + super().__init__(*args, **kwargs) + # TODO: this is a subset of the choices in the model, + # find a way to keep this more DRY + self.fields["payment_status"].choices = [ + ( + "Paid by BornHack", + ( + ("PAID_WITH_TYKLINGS_MASTERCARD", "Expense was paid with Tyklings BornHack Mastercard"), + ("PAID_WITH_AHFS_MASTERCARD", "Expense was paid with ahfs BornHack Mastercard"), + ("PAID_WITH_VIDIRS_MASTERCARD", "Expense was paid with Vidirs BornHack Mastercard"), + ("PAID_IN_NETBANK", "Expense was paid with bank transfer from BornHacks netbank"), + ("PAID_WITH_BORNHACKS_CASH", "Expense was paid with BornHacks cash"), + ), + ), + ( + "Paid by Participant", + (("PAID_NEEDS_REIMBURSEMENT", "Expense was paid by me, I need a reimbursement"),), + ), + ( + "Unpaid", + (("UNPAID_NEEDS_PAYMENT", "Expense is unpaid"),), + ), + ] + + +class ExpenseCreateForm(ExpenseUpdateForm, CleanInvoiceMixin): + invoice = forms.FileField() -class ExpenseUpdateForm(forms.ModelForm): class Meta: model = Expense fields = [ "description", "amount", + "payment_status", "invoice_date", - "paid_by_bornhack", - "responsible_team", + "invoice", ] -class RevenueCreateForm(CleanInvoiceForm): +######### REVENUE ############################### + + +class RevenueUpdateForm(forms.ModelForm): class Meta: model = Revenue - fields = [ - "description", - "amount", - "invoice_date", - "invoice", - "responsible_team", + fields = ["description", "amount", "payment_status", "invoice_date"] + + def __init__(self, *args, **kwargs): + """Remove some choices.""" + super().__init__(*args, **kwargs) + # TODO: this is a subset of the choices in the model, + # find a way to keep this more DRY + self.fields["payment_status"].choices = [ + ( + "Paid to BornHack", + ( + ("PAID_TO_TYKLINGS_MASTERCARD", "Revenue was credited to Tyklings BornHack Mastercard"), + ("PAID_TO_AHFS_MASTERCARD", "Revenue was credited to ahfs BornHack Mastercard"), + ("PAID_TO_VIDIRS_MASTERCARD", "Revenue was credited to Vidirs BornHack Mastercard"), + ("PAID_IN_NETBANK", "Revenue was transferred to a BornHack bank account"), + ("PAID_IN_CASH", "Revenue was paid to BornHack with cash"), + ), + ), + ( + "Paid to Participant", + (("PAID_NEEDS_REDISBURSEMENT", "Revenue has been paid out to me, a redisbursement is needed"),), + ), + ( + "Unpaid", + (("UNPAID_NEEDS_PAYMENT", "Revenue is unpaid"),), + ), ] -class RevenueUpdateForm(forms.ModelForm): +class RevenueCreateForm(RevenueUpdateForm, CleanInvoiceMixin): + invoice = forms.FileField() + class Meta: model = Revenue - fields = ["description", "amount", "invoice_date", "responsible_team"] + fields = ["description", "amount", "payment_status", "invoice_date", "invoice"] diff --git a/src/economy/migrations/0047_remove_expense_responsible_team_and_more.py b/src/economy/migrations/0047_remove_expense_responsible_team_and_more.py new file mode 100644 index 000000000..6114c6e11 --- /dev/null +++ b/src/economy/migrations/0047_remove_expense_responsible_team_and_more.py @@ -0,0 +1,135 @@ +# Generated by Django 4.2.21 on 2025-12-17 20:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("economy", "0046_remove_posreport_bank_responsible_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="expense", + name="responsible_team", + ), + migrations.RemoveField( + model_name="revenue", + name="invoice_fk", + ), + migrations.RemoveField( + model_name="revenue", + name="responsible_team", + ), + migrations.AddField( + model_name="expense", + name="created_for_reimbursement", + field=models.BooleanField( + default=False, help_text="True if this expense was created to pay back a reimbursement" + ), + ), + migrations.AddField( + model_name="expense", + name="payment_status", + field=models.CharField( + choices=[ + ("", "Unknown"), + ( + "Paid by BornHack", + ( + ("PAID_WITH_TYKLINGS_MASTERCARD", "Expense was paid with Tyklings BornHack Mastercard"), + ("PAID_WITH_AHFS_MASTERCARD", "Expense was paid with ahfs BornHack Mastercard"), + ("PAID_WITH_VIDIRS_MASTERCARD", "Expense was paid with Vidirs BornHack Mastercard"), + ("PAID_IN_NETBANK", "Expense was paid with bank transfer from BornHacks netbank"), + ("PAID_WITH_BORNHACKS_CASH", "Expense was paid with BornHacks cash"), + ("PAID_LEGACY", "Expense was paid by BornHack before we started tracking payment status"), + ), + ), + ( + "Paid by Participant", + ( + ("PAID_NEEDS_REIMBURSEMENT", "Expense was paid by me, I need a reimbursement"), + ("PAID_AND_REIMBURSED", "Expense has been reimbursed to the volunteer"), + ), + ), + ("Unpaid", (("UNPAID_NEEDS_PAYMENT", "Expense is unpaid"),)), + ], + help_text="Payment status for this expense.", + default="", + ), + ), + migrations.AddField( + model_name="revenue", + name="created_for_reimbursement", + field=models.BooleanField( + default=False, help_text="True if this revenue was created to settle a reimbursement" + ), + ), + migrations.AddField( + model_name="revenue", + name="payment_status", + field=models.CharField( + choices=[ + ("", "Unknown"), + ( + "Paid to BornHack", + ( + ("PAID_TO_TYKLINGS_MASTERCARD", "Revenue was credited to Tyklings BornHack Mastercard"), + ("PAID_TO_AHFS_MASTERCARD", "Revenue was credited to ahfs BornHack Mastercard"), + ("PAID_TO_VIDIRS_MASTERCARD", "Revenue was credited to Vidirs BornHack Mastercard"), + ("PAID_IN_NETBANK", "Revenue was transferred to a BornHack bank account"), + ("PAID_IN_CASH", "Revenue was paid to BornHack with cash"), + ("PAID_LEGACY", "Revenue was paid to BornHack before we started tracking payment status"), + ), + ), + ( + "Paid to Participant", + ( + ( + "PAID_NEEDS_REDISBURSEMENT", + "Revenue has been paid out to me, a redisbursement is needed", + ), + ("PAID_AND_REDISBURSED", "Revenue has been received from the volunteer"), + ), + ), + ("Unpaid", (("UNPAID_NEEDS_PAYMENT", "Revenue is unpaid"),)), + ], + help_text="Payment status for this revenue.", + default="", + ), + ), + migrations.AddField( + model_name="revenue", + name="reimbursement", + field=models.ForeignKey( + blank=True, + help_text="The reimbursement for this revenue, if any. This is a dual-purpose field. If revenue.created_for_reimbursement is True then revenue.reimbursement references the reimbursement which this revenue was created to handle. If revenue.created_for_reimbursement is False then revenue.reimbursement references the reimbursement which redisbursed this revenue.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="revenues", + to="economy.reimbursement", + ), + ), + migrations.AlterField( + model_name="expense", + name="reimbursement", + field=models.ForeignKey( + blank=True, + help_text="The reimbursement for this expense, if any. This is a dual-purpose field. If expense.created_for_reimbursement is True then expense.reimbursement references the reimbursement which this expense was created to handle. If expense.created_for_reimbursement is False then expense.reimbursement references the reimbursement which reimbursed this expense.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="expenses", + to="economy.reimbursement", + ), + ), + migrations.AlterField( + model_name="reimbursement", + name="bank_account", + field=models.TextField( + blank=True, + help_text="The bank account where you want the payment of this reimbursement transferred to. For transfers outside Denmark please include IBAN and BIC. Bank account is only needed if the reimbursement amout is > 0 DKK (meaning BornHack owes you money).", + null=True, + ), + ), + ] diff --git a/src/economy/migrations/0048_migrate_exp_rev_reimb.py b/src/economy/migrations/0048_migrate_exp_rev_reimb.py new file mode 100644 index 000000000..72b99b06e --- /dev/null +++ b/src/economy/migrations/0048_migrate_exp_rev_reimb.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.21 on 2025-12-17 20:05 + +from django.db import migrations + +def migrate_expenses(apps, schema_editor) -> None: + Expense = apps.get_model("economy", "Expense") + # unreimbursed participant-paid expenses + Expense.objects.filter(paid_by_bornhack=False, reimbursement__isnull=True).update( + payment_status="PAID_NEEDS_REIMBURSEMENT" + ) + # reimbursed participant-paid expenses + Expense.objects.filter(paid_by_bornhack=False, reimbursement__isnull=False).update( + payment_status="PAID_AND_REIMBURSED" + ) + # regular expenses + Expense.objects.filter(paid_by_bornhack=True, reimbursement__isnull=True).update(payment_status="PAID_LEGACY") + # reimbursement expenses + Expense.objects.filter(paid_by_bornhack=True, reimbursement__isnull=False).update( + payment_status="PAID_IN_NETBANK", created_for_reimbursement=True + ) + + +def migrate_revenues(apps, schema_editor) -> None: + Revenue = apps.get_model("economy", "Revenue") + # update all revenues + Revenue.objects.all().update(payment_status="PAID_LEGACY") + + + +class Migration(migrations.Migration): + + dependencies = [ + ('economy', '0047_remove_expense_responsible_team_and_more'), + ] + + operations = [ + migrations.RunPython(migrate_expenses), + migrations.RunPython(migrate_revenues), + ] diff --git a/src/economy/migrations/0049_alter_reimbursement_paid.py b/src/economy/migrations/0049_alter_reimbursement_paid.py new file mode 100644 index 000000000..516d6ef27 --- /dev/null +++ b/src/economy/migrations/0049_alter_reimbursement_paid.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-12-18 18:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('economy', '0048_migrate_exp_rev_reimb'), + ] + + operations = [ + migrations.AlterField( + model_name='reimbursement', + name='paid', + field=models.BooleanField(default=False, help_text='Check when this reimbursement has been paid to/from the user. Do not check until the bank transfer has been received/fully approved'), + ), + ] diff --git a/src/economy/models.py b/src/economy/models.py index 55cae00fa..ffe83e62c 100644 --- a/src/economy/models.py +++ b/src/economy/models.py @@ -234,22 +234,6 @@ class Revenue(ExportModelOperationsMixin("revenue"), CampRelatedModel, UUIDModel help_text="The invoice date for this Revenue. This must match the invoice date on the documentation uploaded below. Format is YYYY-MM-DD.", ) - invoice_fk = models.ForeignKey( - "shop.Invoice", - on_delete=models.PROTECT, - related_name="revenues", - help_text="The Invoice object to which this Revenue object relates. Can be None if this revenue does not have a related BornHack Invoice.", - blank=True, - null=True, - ) - - responsible_team = models.ForeignKey( - "teams.Team", - on_delete=models.PROTECT, - related_name="revenues", - help_text="The team to which this revenue belongs. When in doubt pick the Economy team.", - ) - approved = models.BooleanField( blank=True, null=True, @@ -262,6 +246,56 @@ class Revenue(ExportModelOperationsMixin("revenue"), CampRelatedModel, UUIDModel help_text="Economy Team notes for this revenue. Only visible to the Economy team and the submitting user.", ) + PAYMENT_STATUS_CHOICES = [ + ("", "Unknown"), + ( + "Paid to BornHack", + ( + ("PAID_TO_TYKLINGS_MASTERCARD", "Revenue was credited to Tyklings BornHack Mastercard"), + ("PAID_TO_AHFS_MASTERCARD", "Revenue was credited to ahfs BornHack Mastercard"), + ("PAID_TO_VIDIRS_MASTERCARD", "Revenue was credited to Vidirs BornHack Mastercard"), + ("PAID_IN_NETBANK", "Revenue was transferred to a BornHack bank account"), + ("PAID_IN_CASH", "Revenue was paid to BornHack with cash"), + ("PAID_LEGACY", "Revenue was paid to BornHack before we started tracking payment status"), + ), + ), + ( + "Paid to Participant", + ( + ("PAID_NEEDS_REDISBURSEMENT", "Revenue has been paid out to me, a redisbursement is needed"), + ("PAID_AND_REDISBURSED", "Revenue has been received from the volunteer"), + ), + ), + ( + "Unpaid", + (("UNPAID_NEEDS_PAYMENT", "Revenue is unpaid"),), + ), + ] + payment_status = models.CharField( + choices=PAYMENT_STATUS_CHOICES, + help_text="Payment status for this revenue.", + default="", + ) + + reimbursement = models.ForeignKey( + "economy.Reimbursement", + on_delete=models.SET_NULL, # do not CASCADE here, just set to None if reimbursement is deleted + related_name="revenues", + null=True, + blank=True, + help_text=( + "The reimbursement for this revenue, if any. This is a dual-purpose field. " + "If revenue.created_for_reimbursement is True then revenue.reimbursement references the reimbursement " + "which this revenue was created to handle. If revenue.created_for_reimbursement is False then " + "revenue.reimbursement references the reimbursement which redisbursed this revenue." + ), + ) + + created_for_reimbursement = models.BooleanField( + default=False, + help_text="True if this revenue was created to settle a reimbursement", + ) + def clean(self) -> None: if self.amount < 0: raise ValidationError("Amount of a Revenue object can not be negative") @@ -361,6 +395,37 @@ class Expense(ExportModelOperationsMixin("expense"), CampRelatedModel, UUIDModel help_text="Leave checked if this expense was paid by BornHack. Uncheck if you need a reimbursement for this expense.", ) + PAYMENT_STATUS_CHOICES = [ + ("", "Unknown"), + ( + "Paid by BornHack", + ( + ("PAID_WITH_TYKLINGS_MASTERCARD", "Expense was paid with Tyklings BornHack Mastercard"), + ("PAID_WITH_AHFS_MASTERCARD", "Expense was paid with ahfs BornHack Mastercard"), + ("PAID_WITH_VIDIRS_MASTERCARD", "Expense was paid with Vidirs BornHack Mastercard"), + ("PAID_IN_NETBANK", "Expense was paid with bank transfer from BornHacks netbank"), + ("PAID_WITH_BORNHACKS_CASH", "Expense was paid with BornHacks cash"), + ("PAID_LEGACY", "Expense was paid by BornHack before we started tracking payment status"), + ), + ), + ( + "Paid by Participant", + ( + ("PAID_NEEDS_REIMBURSEMENT", "Expense was paid by me, I need a reimbursement"), + ("PAID_AND_REIMBURSED", "Expense has been reimbursed to the volunteer"), + ), + ), + ( + "Unpaid", + (("UNPAID_NEEDS_PAYMENT", "Expense is unpaid"),), + ), + ] + payment_status = models.CharField( + choices=PAYMENT_STATUS_CHOICES, + help_text="Payment status for this expense.", + default="", + ) + invoice = models.ImageField( help_text="The invoice for this expense. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted.", upload_to="expenses/", @@ -370,13 +435,6 @@ class Expense(ExportModelOperationsMixin("expense"), CampRelatedModel, UUIDModel help_text="The invoice date for this Expense. This must match the invoice date on the documentation uploaded below. Format is YYYY-MM-DD.", ) - responsible_team = models.ForeignKey( - "teams.Team", - on_delete=models.PROTECT, - related_name="expenses", - help_text="The team to which this Expense belongs. A team lead will need to approve the expense. When in doubt pick the Orga team.", - ) - approved = models.BooleanField( blank=True, null=True, @@ -390,7 +448,17 @@ class Expense(ExportModelOperationsMixin("expense"), CampRelatedModel, UUIDModel related_name="expenses", null=True, blank=True, - help_text="The reimbursement for this expense, if any. This is a dual-purpose field. If expense.paid_by_bornhack is true then expense.reimbursement references the reimbursement which this expense is created to cover. If expense.paid_by_bornhack is false then expense.reimbursement references the reimbursement which reimbursed this expense.", + help_text=( + "The reimbursement for this expense, if any. This is a dual-purpose field. " + "If expense.created_for_reimbursement is True then expense.reimbursement references the reimbursement " + "which this expense was created to handle. If expense.created_for_reimbursement is False then " + "expense.reimbursement references the reimbursement which reimbursed this expense." + ), + ) + + created_for_reimbursement = models.BooleanField( + default=False, + help_text="True if this expense was created to pay back a reimbursement", ) notes = models.TextField( @@ -459,7 +527,7 @@ def reject(self, request) -> None: messages.success(request, f"Expense {self.pk} rejected") def __str__(self) -> str: - return f"{self.responsible_team.name} Team - {self.amount} DKK - {self.creditor.name} - {self.description}" + return f"{self.amount} DKK - {self.creditor.name} - {self.description}" class Reimbursement( @@ -467,7 +535,7 @@ class Reimbursement( CampRelatedModel, UUIDModel, ): - """A reimbursement covers one or more expenses.""" + """A reimbursement covers one or more expenses and revenues.""" camp = models.ForeignKey( "camps.Camp", @@ -491,7 +559,9 @@ class Reimbursement( ) bank_account = models.TextField( - help_text="The bank account where you want the payment of this reimbursement transferred to. For transfers outside Denmark please include IBAN and BIC.", + null=True, + blank=True, + help_text="The bank account where you want the payment of this reimbursement transferred to. For transfers outside Denmark please include IBAN and BIC. Bank account is only needed if the reimbursement amout is > 0 DKK (meaning BornHack owes you money).", ) notes = models.TextField( @@ -501,7 +571,7 @@ class Reimbursement( paid = models.BooleanField( default=False, - help_text="Check when this reimbursement has been paid to the user. Do not check until the bank transfer has been fully approved.", + help_text="Check when this reimbursement has been paid to/from the user. Do not check until the bank transfer has been received/fully approved", ) def get_backoffice_url(self): @@ -513,28 +583,48 @@ def get_backoffice_url(self): @property def covered_expenses(self): """Returns a queryset of all expenses covered by this reimbursement. Excludes the expense which paid back the reimbursement.""" - return self.expenses.filter(paid_by_bornhack=False) + return self.expenses.filter(created_for_reimbursement=False) + + @property + def covered_revenues(self): + """Returns a queryset of all revenues covered by this reimbursement. Excludes the revenue which paid back the reimbursement (if any).""" + return self.revenues.filter(created_for_reimbursement=False) @property def amount(self): - """The total amount for a reimbursement is calculated by adding up the amounts for all the related expenses.""" - return self.expenses.filter(paid_by_bornhack=False).aggregate( - models.Sum("amount"), + """The total amount for a reimbursement is calculated by adding up the amounts for all the related expenses and substracting all the related revenues.""" + expenses_total = self.expenses.filter(created_for_reimbursement=False).aggregate( + models.Sum("amount", default=0) )["amount__sum"] + revenues_total = self.revenues.filter(created_for_reimbursement=False).aggregate( + models.Sum("amount", default=0) + )["amount__sum"] + return expenses_total - revenues_total @property def payback_expense(self): - """Return the expense created to pay back this reimbursement.""" - if self.expenses.filter(paid_by_bornhack=True).exists(): - return self.expenses.get(paid_by_bornhack=True) + """Return the expense created to settle this reimbursement (if any).""" + if self.expenses.filter(created_for_reimbursement=True).exists(): + return self.expenses.get(created_for_reimbursement=True) + return None + + @property + def payback_revenue(self): + """Return the revenue created to settle this reimbursement (if any).""" + if self.revenues.filter(created_for_reimbursement=True).exists(): + return self.revenues.get(created_for_reimbursement=True) return None def create_payback_expense(self): - """Create the expense to pay back this reimbursement.""" + """Create the expense to settle this reimbursement.""" if self.payback_expense: # we already have an expense created, just return that one return self.payback_expense + if self.amount < 0: + # we need a payback revenue for this, not a payback expense + return False + # we need an economy team to do this if not self.camp.economy_team: return False @@ -545,8 +635,7 @@ def create_payback_expense(self): expense.user = self.user expense.amount = self.amount expense.description = f"Payment of reimbursement {self.pk} to {self.reimbursement_user}" - expense.paid_by_bornhack = True - expense.responsible_team = self.camp.economy_team + expense.payment_status = "UNPAID_NEEDS_PAYMENT" expense.approved = True expense.reimbursement = self expense.invoice_date = self.created @@ -560,9 +649,56 @@ def create_payback_expense(self): ), ), ) + expense.created_for_reimbursement = True expense.save() return expense + def create_payback_revenue(self): + """Create the revenue to settle this reimbursement.""" + if self.payback_revenue: + # we already have an revenue created, just return that one + return self.payback_revenue + + if self.amount > 0: + # we need a payback expense for this, not a payback revenue + return False + + # we need an economy team to do this + if not self.camp.economy_team: + return False + + # create the revenue + revenue = Revenue( + camp=self.camp, + user=self.user, + amount=self.amount * -1, + description=f"Payment of reimbursement {self.pk} from {self.reimbursement_user}", + payment_status="UNPAID_NEEDS_PAYMENT", + approved=True, + reimbursement=self, + invoice_date=self.created, + debtor=Credebtor.objects.get(name="Reimbursement"), + created_for_reimbursement=True, + ) + revenue.invoice.save( + "na.jpg", + File( + open( + os.path.join(settings.BASE_DIR, "static_src/img/na.jpg"), + "rb", + ), + ), + ) + revenue.save() + return revenue + + def mark_as_paid(self) -> None: + """Mark reimbursement as paid and related expenses+revenues as reimbursement done.""" + self.paid = True + self.save() + self.expenses.update(payment_status="PAID_AND_REIMBURSED") + self.revenues.update(payment_status="PAID_AND_REIMBURSED") + ################################## # Point of Sale diff --git a/src/economy/templates/includes/expense_detail_panel.html b/src/economy/templates/includes/expense_detail_panel.html index 8034228e1..8d542b81f 100644 --- a/src/economy/templates/includes/expense_detail_panel.html +++ b/src/economy/templates/includes/expense_detail_panel.html @@ -25,12 +25,12 @@| Reimbursement User | -{{ reimbursement.reimbursement_user.profile.get_name }} | +Reimbursement ID | +{{ reimbursement.pk }} |
|---|---|---|---|
| Created By | -{{ reimbursement.user.profile.get_name }} | +Reimbursement User | +{{ reimbursement.reimbursement_user.profile.get_name }} |
| Total Amount | -{{ reimbursement.amount }} DKK | +{{ reimbursement.amount }} DKK + Note: a negative number here means {% if request.resolver_match.app_name == "backoffice" %}the participant owes{% else %}you owe{% endif %} BornHack money, while a positive number means that BornHack owes {% if request.resolver_match.app_name == "backoffice" %}the participant{% else %}you{% endif %} money. + |
|
| Bank Account | @@ -30,19 +32,41 @@ {% if request.resolver_match.app_name == "backoffice" %}|||
| Payback Expense | + {% if reimbursement.payback_expense %}{{ reimbursement.payback_expense.pk }} | + {% else %} +N/A | + {% endif %} +|
| Payback Revenue | + {% if reimbursement.payback_revenue %} +{{ reimbursement.payback_revenue.pk }} | + {% else %} +N/A | + {% endif %}|
| Created | {{ reimbursement.created }} by {{ reimbursement.user }} | ||
| Expenses covered by this Reimbursement | {% include 'includes/expense_list_panel.html' with expense_list=reimbursement.covered_expenses.all %} | ||
| Revenues covered by this Reimbursement | ++ {% include 'includes/revenue_list_panel.html' with revenue_list=reimbursement.covered_revenues.all %} + | + {% endif %} +
| ID | @@ -8,43 +8,61 @@Camp | {% endif %}Invoice Date | - {% if request.resolver_match.app_name == "backoffice" %} -Created By | - {% endif %} +Created By | +Payment Status | Debtor | Amount | Description | -Responsible Team | -Approved | + {% if not reimbursement %} +Approved | +Reimbursement | + {% endif %}Actions | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ revenue.pk }} | +{{ revenue.pk }} | + + {# camp identifier if needed #} {% if past_camps %}{{ revenue.camp.title }} | {% endif %} +{{ revenue.invoice_date }} | - {% if request.resolver_match.app_name == "backoffice" %} -{{ revenue.user.profile.get_name }} | - {% endif %} +{{ revenue.user.profile.get_name }} | +{{ revenue.get_payment_status_display }} | {{ revenue.debtor }} | {{ revenue.amount }} DKK | {{ revenue.description }} | -{{ revenue.responsible_team.name }} Team | -{{ revenue.approval_status }} | + + {# to save space do not show approval status and reimbursement link in reimbursements #} + {% if not reimbursement %} +{{ revenue.approval_status }} | ++ {% if revenue.reimbursement and not revenue.created_for_reimbursement %} + {% if request.resolver_match.app_name == "backoffice" %} + Show + {% else %} + Show + {% endif %} + {% else %} + N/A + {% endif %} + | + {% endif %} + + {# actions #}
- {% if request.resolver_match.app_name == "backoffice" %}
- Details
+ Details
Invoice
+ {% if request.resolver_match.app_name == "backoffice" %}
{% if not camp.read_only %}
Update
{% endif %}
{% else %}
- Details
{% if not camp.read_only and not revenue.approved %}
Update
Delete
diff --git a/src/economy/templates/reimbursement_detail.html b/src/economy/templates/reimbursement_detail.html
index 69ac112a0..9da0ac440 100644
--- a/src/economy/templates/reimbursement_detail.html
+++ b/src/economy/templates/reimbursement_detail.html
@@ -12,7 +12,6 @@
{% endblock content %}
diff --git a/src/economy/urls.py b/src/economy/urls.py
index 2399eda0f..0f9fa4dd4 100644
--- a/src/economy/urls.py
+++ b/src/economy/urls.py
@@ -18,7 +18,6 @@
from .views import ReimbursementDeleteView
from .views import ReimbursementDetailView
from .views import ReimbursementListView
-from .views import ReimbursementUpdateView
from .views import RevenueCreateView
from .views import RevenueDeleteView
from .views import RevenueDetailView
@@ -130,11 +129,6 @@
ReimbursementDetailView.as_view(),
name="reimbursement_detail",
),
- path(
- "update/",
- ReimbursementUpdateView.as_view(),
- name="reimbursement_update",
- ),
path(
"delete/",
ReimbursementDeleteView.as_view(),
diff --git a/src/economy/views.py b/src/economy/views.py
index da67d290a..06ad877f0 100644
--- a/src/economy/views.py
+++ b/src/economy/views.py
@@ -7,6 +7,7 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Prefetch
+from django.core.exceptions import ValidationError
from django.db.models import Q
from django.db.models import Sum
from django.http import HttpResponse
@@ -273,11 +274,8 @@ class ExpenseCreateView(
form_class = ExpenseCreateForm
def get_context_data(self, **kwargs):
- """Do not show teams that are not part of the current camp in the dropdown."""
+ """Set creditor."""
context = super().get_context_data(**kwargs)
- context["form"].fields["responsible_team"].queryset = Team.objects.filter(
- camp=self.camp,
- )
context["creditor"] = self.credebtor
return context
@@ -302,7 +300,7 @@ def form_valid(self, form):
),
text_template="emails/expense_awaiting_approval_email.txt",
formatdict={"expense": expense},
- subject=f"New {expense.camp.title} expense for {expense.responsible_team.name} Team is awaiting approval",
+ subject=f"New {expense.camp.title} expense is awaiting approval",
to_recipients=[settings.ECONOMYTEAM_EMAIL],
)
@@ -332,9 +330,6 @@ def dispatch(self, request, *args, **kwargs):
def get_context_data(self, **kwargs):
"""Do not show teams that are not part of the current camp in the dropdown."""
context = super().get_context_data(**kwargs)
- context["form"].fields["responsible_team"].queryset = Team.objects.filter(
- camp=self.camp,
- )
context["creditor"] = self.get_object().creditor
return context
@@ -418,31 +413,35 @@ class ReimbursementCreateView(CampViewMixin, ExpensePermissionMixin, CreateView)
template_name = "reimbursement_form.html"
fields = ["bank_account"]
- def get(self, request, *args, **kwargs):
- """Check if this user has any approved and un-reimbursed expenses."""
- if not request.user.expenses.filter(
+ def dispatch(self, request, *args, **kwargs):
+ """Get any approved and un-reimbursed expenses and revenues, or return error."""
+ self.expenses = request.user.expenses.filter(
reimbursement__isnull=True,
approved=True,
- paid_by_bornhack=False,
- ):
+ payment_status="PAID_NEEDS_REIMBURSEMENT",
+ )
+ self.revenues = request.user.revenues.filter(
+ reimbursement__isnull=True,
+ approved=True,
+ payment_status="PAID_NEEDS_REDISBURSEMENT",
+ )
+ if not self.expenses and not self.revenues:
messages.error(
request,
- "You have no approved and unreimbursed expenses!",
+ "You have no approved and unreimbursed expenses or revenues!",
)
return redirect(
reverse("economy:dashboard", kwargs={"camp_slug": self.camp.slug}),
)
- return super().get(request, *args, **kwargs)
+ return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- context["expenses"] = Expense.objects.filter(
- user=self.request.user,
- approved=True,
- reimbursement__isnull=True,
- paid_by_bornhack=False,
- )
- context["total_amount"] = context["expenses"].aggregate(Sum("amount"))
+ context["expenses"] = self.expenses
+ context["revenues"] = self.revenues
+ context["total_expense_amount"] = self.expenses.aggregate(Sum("amount"))["amount__sum"] or 0
+ context["total_revenue_amount"] = self.revenues.aggregate(Sum("amount"))["amount__sum"] or 0
+ context["total_amount"] = context["total_expense_amount"] - context["total_revenue_amount"]
context["reimbursement_user"] = self.request.user
context["cancelurl"] = reverse(
"economy:reimbursement_list",
@@ -452,26 +451,49 @@ def get_context_data(self, **kwargs):
def form_valid(self, form):
"""Set user and camp for the Reimbursement before saving."""
+ # do we have an Economy team for this camp?
+ if not self.camp.economy_team:
+ messages.error(self.request, "No economy team found")
+ return redirect(
+ reverse("economy:dashboard", kwargs={"camp_slug": self.camp.slug}),
+ )
+
# get the expenses for this user
expenses = Expense.objects.filter(
user=self.request.user,
approved=True,
reimbursement__isnull=True,
- paid_by_bornhack=False,
+ payment_status="PAID_NEEDS_REIMBURSEMENT",
)
- if not expenses:
- messages.error(self.request, "No expenses found")
- return redirect(
- reverse("economy:dashboard", kwargs={"camp_slug": self.camp.slug}),
- )
+ expenses_total = expenses.aggregate(Sum("amount"))["amount__sum"] or 0
- # do we have an Economy team for this camp?
- if not self.camp.economy_team:
- messages.error(self.request, "No economy team found")
+ # get the revenues for this user
+ revenues = Revenue.objects.filter(
+ user=self.request.user,
+ approved=True,
+ reimbursement__isnull=True,
+ payment_status="PAID_NEEDS_REDISBURSEMENT",
+ )
+ revenues_total = revenues.aggregate(Sum("amount"))["amount__sum"] or 0
+ if not expenses and not revenues:
+ messages.error(self.request, "No approved unhandled expenses or revenues found")
return redirect(
reverse("economy:dashboard", kwargs={"camp_slug": self.camp.slug}),
)
+ # calculate the reimbursement total
+ reimbursement_total = expenses_total - revenues_total
+ if reimbursement_total > 0:
+ # make sure there is a bank account
+ if not form.cleaned_data["bank_account"]:
+ form.add_error(
+ "bank_account",
+ ValidationError(
+ "Bank account is required when the reimbursement total is > 0",
+ ),
+ )
+ return super().form_invalid(form)
+
# create reimbursement in database
reimbursement = form.save(commit=False)
reimbursement.reimbursement_user = self.request.user
@@ -484,14 +506,34 @@ def form_valid(self, form):
expense.reimbursement = reimbursement
expense.save()
- # create payback expense for this reimbursement
- reimbursement.create_payback_expense()
+ # add all revenues to reimbursement
+ for revenue in revenues:
+ revenue.reimbursement = reimbursement
+ revenue.save()
messages.success(
self.request,
- f"Reimbursement {reimbursement.pk} has been created. It will be paid by the economy team to the specified bank account.",
+ f"Reimbursement {reimbursement.pk} has been created.",
)
+ if reimbursement.amount > 0:
+ # create payback expense for this reimbursement
+ reimbursement.create_payback_expense()
+ messages.success(
+ self.request,
+ "Your money will be transferred to the specified bank account soon.",
+ )
+ elif reimbursement.amount < 0:
+ # create payback revenue for this reimbursement
+ reimbursement.create_payback_revenue()
+ messages.success(
+ self.request,
+ "It will be paid by the economy team to the specified bank account.",
+ )
+ else:
+ # The two even out what now
+ pass
+
# send an email to the economy team
add_outgoing_email(
responsible_team=Team.objects.get(
@@ -512,34 +554,6 @@ def form_valid(self, form):
)
-class ReimbursementUpdateView(
- CampViewMixin,
- ReimbursementPermissionMixin,
- ReimbursementUnpaidMixin,
- UpdateView,
-):
- model = Reimbursement
- template_name = "reimbursement_form.html"
- fields = ["bank_account"]
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["expenses"] = self.object.expenses.filter(paid_by_bornhack=False)
- context["total_amount"] = context["expenses"].aggregate(Sum("amount"))
- context["reimbursement_user"] = self.request.user
- context["cancelurl"] = reverse(
- "economy:reimbursement_list",
- kwargs={"camp_slug": self.camp.slug},
- )
- return context
-
- def get_success_url(self):
- return reverse(
- "economy:reimbursement_detail",
- kwargs={"camp_slug": self.camp.slug, "pk": self.get_object().pk},
- )
-
-
class ReimbursementDeleteView(
CampViewMixin,
ReimbursementPermissionMixin,
@@ -592,9 +606,6 @@ class RevenueCreateView(
def get_context_data(self, **kwargs):
"""Do not show teams that are not part of the current camp in the dropdown."""
context = super().get_context_data(**kwargs)
- context["form"].fields["responsible_team"].queryset = Team.objects.filter(
- camp=self.camp,
- )
context["debtor"] = self.credebtor
return context
@@ -619,7 +630,7 @@ def form_valid(self, form):
),
text_template="emails/revenue_awaiting_approval_email.txt",
formatdict={"revenue": revenue},
- subject=f"New {revenue.camp.title} revenue for {revenue.responsible_team.name} Team is awaiting approval",
+ subject=f"New {revenue.camp.title} revenue is awaiting approval",
to_recipients=[settings.ECONOMYTEAM_EMAIL],
)
@@ -646,14 +657,6 @@ def dispatch(self, request, *args, **kwargs):
)
return response
- def get_context_data(self, **kwargs):
- """Do not show teams that are not part of the current camp in the dropdown."""
- context = super().get_context_data(**kwargs)
- context["form"].fields["responsible_team"].queryset = Team.objects.filter(
- camp=self.camp,
- )
- return context
-
def get_success_url(self):
messages.success(
self.request,
diff --git a/src/utils/bootstrap/base.py b/src/utils/bootstrap/base.py
index 679cd659e..d9d0074f3 100644
--- a/src/utils/bootstrap/base.py
+++ b/src/utils/bootstrap/base.py
@@ -45,6 +45,7 @@
from economy.models import Chain
from economy.models import Credebtor
from economy.models import Expense
+from economy.models import Revenue
from economy.models import Pos
from economy.models import Reimbursement
from events.factories import EventProposalFactory
@@ -2048,8 +2049,7 @@ def create_prize_ticket(self, camp: Camp, ticket_types: dict) -> None:
def create_camp_expenses(self, camp: Camp) -> None:
"""Create camp expenses."""
self.output(f"Creating expenses for {camp}...")
- for team in Team.objects.filter(camp=camp):
- ExpenseFactory.create_batch(10, camp=camp, responsible_team=team)
+ ExpenseFactory.create_batch(200, camp=camp)
def create_camp_reimbursements(self, camp: Camp) -> None:
"""Create camp reimbursements."""
@@ -2058,7 +2058,7 @@ def create_camp_reimbursements(self, camp: Camp) -> None:
id__in=Expense.objects.filter(
camp=camp,
reimbursement__isnull=True,
- paid_by_bornhack=False,
+ payment_status="PAID_NEEDS_REIMBURSEMENT",
approved=True,
)
.values_list("user", flat=True)
@@ -2069,8 +2069,16 @@ def create_camp_reimbursements(self, camp: Camp) -> None:
user=user,
approved=True,
reimbursement__isnull=True,
- paid_by_bornhack=False,
+ payment_status="PAID_NEEDS_REIMBURSEMENT",
)
+ revenues = Revenue.objects.filter(
+ user=user,
+ approved=True,
+ reimbursement__isnull=True,
+ payment_status="PAID_NEEDS_REDISBURSEMENT",
+ )
+ if not expenses and not revenues:
+ continue
reimbursement = Reimbursement.objects.create(
camp=camp,
user=user,
@@ -2080,12 +2088,14 @@ def create_camp_reimbursements(self, camp: Camp) -> None:
paid=random.choice([True, True, False]), # noqa: S311
)
expenses.update(reimbursement=reimbursement)
- reimbursement.create_payback_expense()
+ revenues.update(reimbursement=reimbursement)
+ if reimbursement.paid:
+ reimbursement.mark_as_paid()
def create_camp_revenues(self, camp: Camp) -> None:
"""Method for creating revenue."""
self.output(f"Creating revenues for {camp}...")
- RevenueFactory.create_batch(20, camp=camp)
+ RevenueFactory.create_batch(200, camp=camp)
def add_team_permissions(self, camp: Camp) -> None:
"""Assign member permissions to the team groups for this camp."""
Reimbursement Details
{% if not reimbursement.paid %}
- Update Reimbursement
Delete Reimbursement
{% endif %}
diff --git a/src/economy/templates/reimbursement_form.html b/src/economy/templates/reimbursement_form.html
index 191263df0..f675e8aec 100644
--- a/src/economy/templates/reimbursement_form.html
+++ b/src/economy/templates/reimbursement_form.html
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
+{% load bornhack %}
{% load django_bootstrap5 %}
{% block title %}
@@ -6,39 +7,42 @@
{% endblock %}
{% block content %}
-
+ {% if request.resolver_match.url_name == "reimbursement_create" %}Create{% else %}Update{% endif %} Reimbursement
-
+ This reimbursement covers the following approved expenses:+{% if request.resolver_match.url_name == "reimbursement_create" %}Create{% else %}Update{% endif %} Reimbursement
-
-
+ {% if expenses %}
+
+ {% endif %}
- Expenses in this Reimbursement:+
+ {% include 'includes/expense_list_panel.html' with expense_list=expenses %}
+
+ The total amount for this reimbursement will be {{ total_amount.amount__sum }} DKK + {% if revenues %} +
+
+ {% endif %}
-
+ Revenues in this Reimbursement:+
+ {% include 'includes/revenue_list_panel.html' with revenue_list=revenues %}
+
+ The total amount for this reimbursement will be {{ total_amount }} DKK. |