From c2ba715c480e70c7db9c0dc4d60ae4aefa3649f8 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Thu, 18 Dec 2025 19:53:51 +0100 Subject: [PATCH 01/13] WIP expense makeover --- .../reimbursement_list_backoffice.html | 4 +- src/backoffice/views/economy.py | 15 +- src/economy/admin.py | 5 +- src/economy/factories.py | 8 +- src/economy/forms.py | 91 ++++++-- ...posreport_bank_responsible_end_and_more.py | 61 +++-- .../0045_populate_bank_and_pos_responsible.py | 4 +- ...ove_posreport_bank_responsible_and_more.py | 12 +- ...emove_expense_responsible_team_and_more.py | 61 +++++ .../migrations/0048_migrate_exp_rev_reimb.py | 39 ++++ .../0049_alter_reimbursement_paid.py | 18 ++ src/economy/models.py | 216 ++++++++++++++---- .../includes/expense_detail_panel.html | 29 ++- .../includes/expense_list_panel.html | 18 +- .../includes/reimbursement_detail_panel.html | 36 ++- .../includes/reimbursement_list_panel.html | 1 - .../includes/revenue_detail_panel.html | 28 ++- .../includes/revenue_list_panel.html | 49 +++- .../templates/reimbursement_detail.html | 1 - src/economy/templates/reimbursement_form.html | 39 +++- src/economy/urls.py | 6 - src/economy/views.py | 191 ++++++++-------- src/utils/bootstrap/base.py | 22 +- 23 files changed, 693 insertions(+), 261 deletions(-) create mode 100644 src/economy/migrations/0047_remove_expense_responsible_team_and_more.py create mode 100644 src/economy/migrations/0048_migrate_exp_rev_reimb.py create mode 100644 src/economy/migrations/0049_alter_reimbursement_paid.py 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 @@

BackOffice: Reimbursements for {{ camp.title }}

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..5bc35b4f9 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 @@ -339,7 +337,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/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 e67d0481a..8f1f04d30 100644 --- a/src/economy/factories.py +++ b/src/economy/factories.py @@ -6,10 +6,10 @@ import faker from django.contrib.auth.models import User from django.utils import timezone -from utils.slugs import unique_slugify from camps.models import Camp from teams.models import Team +from utils.slugs import unique_slugify from .models import Bank from .models import BankAccount @@ -32,6 +32,7 @@ fake = faker.Faker() + class BankFactory(factory.django.DjangoModelFactory): class Meta: model = Bank @@ -530,6 +531,7 @@ class Meta: address = factory.Faker("address", locale="dk_DK") notes = factory.Faker("text") + class ExpenseFactory(factory.django.DjangoModelFactory): """Factory for creating expense data.""" @@ -548,9 +550,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): @@ -570,6 +572,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/0044_posreport_bank_responsible_end_and_more.py b/src/economy/migrations/0044_posreport_bank_responsible_end_and_more.py index d8d7dfd18..911a20192 100644 --- a/src/economy/migrations/0044_posreport_bank_responsible_end_and_more.py +++ b/src/economy/migrations/0044_posreport_bank_responsible_end_and_more.py @@ -1,36 +1,65 @@ # Generated by Django 4.2.21 on 2025-06-09 18:13 +from __future__ import annotations -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion +from django.conf import settings +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('economy', '0043_coinifysettlement'), + ("economy", "0043_coinifysettlement"), ] operations = [ migrations.AddField( - model_name='posreport', - name='bank_responsible_end', - field=models.ForeignKey(blank=True, help_text='The banker responsible for the day end count of this PosReport', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pos_report_banker_end', to=settings.AUTH_USER_MODEL), + model_name="posreport", + name="bank_responsible_end", + field=models.ForeignKey( + blank=True, + help_text="The banker responsible for the day end count of this PosReport", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="pos_report_banker_end", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='posreport', - name='bank_responsible_start', - field=models.ForeignKey(blank=True, help_text='The banker responsible for the day start count of this PosReport', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pos_report_banker_start', to=settings.AUTH_USER_MODEL), + model_name="posreport", + name="bank_responsible_start", + field=models.ForeignKey( + blank=True, + help_text="The banker responsible for the day start count of this PosReport", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="pos_report_banker_start", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='posreport', - name='pos_responsible_end', - field=models.ForeignKey(blank=True, help_text='The pos responsible for the day end count of this PosReport', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pos_report_pos_end', to=settings.AUTH_USER_MODEL), + model_name="posreport", + name="pos_responsible_end", + field=models.ForeignKey( + blank=True, + help_text="The pos responsible for the day end count of this PosReport", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="pos_report_pos_end", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='posreport', - name='pos_responsible_start', - field=models.ForeignKey(blank=True, help_text='The pos responsible for the day start count of this PosReport', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pos_report_pos_start', to=settings.AUTH_USER_MODEL), + model_name="posreport", + name="pos_responsible_start", + field=models.ForeignKey( + blank=True, + help_text="The pos responsible for the day start count of this PosReport", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="pos_report_pos_start", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/src/economy/migrations/0045_populate_bank_and_pos_responsible.py b/src/economy/migrations/0045_populate_bank_and_pos_responsible.py index a7723d04f..97ad14bd9 100644 --- a/src/economy/migrations/0045_populate_bank_and_pos_responsible.py +++ b/src/economy/migrations/0045_populate_bank_and_pos_responsible.py @@ -1,4 +1,5 @@ # Generated by Django 4.2.21 on 2025-06-09 18:13 +from __future__ import annotations from django.db import migrations @@ -14,9 +15,8 @@ def populate_pos_people(apps, schema_editor) -> None: class Migration(migrations.Migration): - dependencies = [ - ('economy', '0044_posreport_bank_responsible_end_and_more'), + ("economy", "0044_posreport_bank_responsible_end_and_more"), ] operations = [ diff --git a/src/economy/migrations/0046_remove_posreport_bank_responsible_and_more.py b/src/economy/migrations/0046_remove_posreport_bank_responsible_and_more.py index 898a47e6d..a17b3058b 100644 --- a/src/economy/migrations/0046_remove_posreport_bank_responsible_and_more.py +++ b/src/economy/migrations/0046_remove_posreport_bank_responsible_and_more.py @@ -1,21 +1,21 @@ # Generated by Django 4.2.21 on 2025-06-09 18:59 +from __future__ import annotations from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('economy', '0045_populate_bank_and_pos_responsible'), + ("economy", "0045_populate_bank_and_pos_responsible"), ] operations = [ migrations.RemoveField( - model_name='posreport', - name='bank_responsible', + model_name="posreport", + name="bank_responsible", ), migrations.RemoveField( - model_name='posreport', - name='pos_responsible', + model_name="posreport", + name="pos_responsible", ), ] 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..ea4858a3e --- /dev/null +++ b/src/economy/migrations/0047_remove_expense_responsible_team_and_more.py @@ -0,0 +1,61 @@ +# 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=[('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.', null=True), + ), + 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=[('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.', null=True), + ), + 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 727207f98..4476d2586 100644 --- a/src/economy/models.py +++ b/src/economy/models.py @@ -238,22 +238,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, @@ -266,6 +250,55 @@ 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 = [ + ( + "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( + null=True, + choices=PAYMENT_STATUS_CHOICES, + help_text="Payment status for this revenue.", + ) + + 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") @@ -365,6 +398,36 @@ 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 = [ + ( + "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( + null=True, + choices=PAYMENT_STATUS_CHOICES, + help_text="Payment status for this expense.", + ) + 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/", @@ -374,13 +437,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, @@ -394,7 +450,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( @@ -463,7 +529,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( @@ -471,7 +537,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", @@ -495,7 +561,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( @@ -505,7 +573,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): @@ -517,28 +585,52 @@ 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"), - )["amount__sum"] + """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"))[ + "amount__sum" + ] + revenues_total = self.revenues.filter(created_for_reimbursement=False).aggregate(models.Sum("amount"))[ + "amount__sum" + ] + if expenses_total is None: + expenses_total = 0 + if revenues_total is None: + revenues_total = 0 + 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 @@ -549,8 +641,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 @@ -564,9 +655,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 c525a3447..81d5601e5 100644 --- a/src/economy/templates/includes/expense_detail_panel.html +++ b/src/economy/templates/includes/expense_detail_panel.html @@ -26,10 +26,6 @@ Description {{ expense.description }} - - Paid by BornHack? - This expense was paid by {% if expense.paid_by_bornhack %}BornHack{% else %}{{ expense.user.profile.get_name }}, and will be reimbursed.{% endif %} - Filename {{ expense.invoice }} @@ -38,20 +34,21 @@ Approved? {{ expense.approval_status }} - {% if not expense.paid_by_bornhack %} - - Reimbursement? - - {% if expense.reimbursement %} - {% if request.resolver_match.app_name == "backoffice" %} - {{ expense.reimbursement.pk }} - {% else %} - {{ expense.reimbursement.pk }} - {% endif %} + {% if expense.payment_status == "PAID_NEEDS_REIMBURSEMENT" or expense.payment_status == "PAID_AND_REIMBURSED" %} + + Reimbursement? + + {% if expense.reimbursement %} + {% if request.resolver_match.app_name == "backoffice" %} + {{ expense.reimbursement.pk }} {% else %} - N/A + {{ expense.reimbursement.pk }} {% endif %} - + {% else %} + N/A + {% endif %} + + {% endif %} Invoice diff --git a/src/economy/templates/includes/expense_list_panel.html b/src/economy/templates/includes/expense_list_panel.html index 31767e7b7..545405c78 100644 --- a/src/economy/templates/includes/expense_list_panel.html +++ b/src/economy/templates/includes/expense_list_panel.html @@ -10,7 +10,7 @@ Invoice Date Created By {% if not reimbursement %} - Paid by + Payment Status {% endif %} Creditor Amount @@ -25,23 +25,26 @@ {% for expense in expense_list %} - {% if request.resolver_match.app_name == "backoffice" %} - {{ expense.pk }} - {% else %} - {{ expense.pk }} - {% endif %} + {{ expense.pk }} + + {# camp identifier if needed #} {% if past_camps %} {{ expense.camp.title }} {% endif %} + {{ expense.invoice_date }} {{ expense.user.profile.get_name }} + + {# to save space do not show payment status column in reimbursements #} {% if not reimbursement %} - {% if expense.paid_by_bornhack %}BornHack{% else %}{{ expense.user.profile.get_name }}{% endif %} + {{ expense.payment_status }} {% endif %} + {{ expense.creditor.name }} {{ expense.amount }} DKK {{ expense.description }} + {# to save space do not show approval status and reimbursement link in reimbursements #} {% if not reimbursement %} {{ expense.approval_status }} @@ -57,6 +60,7 @@ {% endif %} + {# actions #}
Details diff --git a/src/economy/templates/includes/reimbursement_detail_panel.html b/src/economy/templates/includes/reimbursement_detail_panel.html index 3b2f05963..8c9686854 100644 --- a/src/economy/templates/includes/reimbursement_detail_panel.html +++ b/src/economy/templates/includes/reimbursement_detail_panel.html @@ -1,19 +1,21 @@ {% load bornhack %}
-
Reimbursement Details for {{ reimbursement.pk }} for {{ reimbursement.reimbursement_user }}
+
Reimbursement {{ reimbursement.pk }} Details
- - + + - - + + - + @@ -30,19 +32,41 @@ {% if request.resolver_match.app_name == "backoffice" %} + {% if reimbursement.payback_expense %} + {% else %} + + {% endif %} + + + + {% if reimbursement.payback_revenue %} + + {% else %} + + {% endif %} {% endif %} + {% if reimbursement.covered_expenses.exists %} + {% endif %} + {% if reimbursement.covered_revenues.exists %} + + + + {% endif %} +
Reimbursement User{{ reimbursement.reimbursement_user }}Reimbursement ID{{ reimbursement.pk }}
Created By{{ reimbursement.user }}Reimbursement User{{ reimbursement.reimbursement_user.profile.get_name }}
Total Amount{{ reimbursement.amount }} DKK{{ reimbursement.amount }} DKK
+Note: a negative number here means you owe BornHack money, while a positive number means that BornHack owes you money. +
Bank Account
Payback Expense{{ reimbursement.payback_expense.pk }}N/A
Payback Revenue{{ reimbursement.payback_revenue.pk }}N/A
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 %} +
diff --git a/src/economy/templates/includes/reimbursement_list_panel.html b/src/economy/templates/includes/reimbursement_list_panel.html index 2d1d9a07a..4b88c228e 100644 --- a/src/economy/templates/includes/reimbursement_list_panel.html +++ b/src/economy/templates/includes/reimbursement_list_panel.html @@ -42,7 +42,6 @@ {% else %} Details {% if not reim.paid %} - Update Delete {% endif %} {% endif %} diff --git a/src/economy/templates/includes/revenue_detail_panel.html b/src/economy/templates/includes/revenue_detail_panel.html index f80d9bc63..872bcd327 100644 --- a/src/economy/templates/includes/revenue_detail_panel.html +++ b/src/economy/templates/includes/revenue_detail_panel.html @@ -6,10 +6,6 @@ Created By {{ revenue.user }} - - Amount - {{ revenue.amount }} DKK - Chain {{ revenue.debtor.chain.name }} @@ -18,10 +14,18 @@ Creditor {{ revenue.debtor.name }} + + Amount + {{ revenue.amount }} DKK + Invoice Date {{ revenue.invoice_date }} + + Payment Status + {{ revenue.payment_status }} + Description {{ revenue.description }} @@ -34,6 +38,22 @@ Approved? {{ revenue.approval_status }} + {% if revenue.payment_status == "PAID_NEEDS_REDISBURSEMENT" or revenue.payment_status == "PAID_AND_REDISBURSED" %} + + Reimbursement? + + {% if revenue.reimbursement %} + {% if request.resolver_match.app_name == "backoffice" %} + {{ revenue.reimbursement.pk }} + {% else %} + {{ revenue.reimbursement.pk }} + {% endif %} + {% else %} + N/A + {% endif %} + + + {% endif %} Invoice diff --git a/src/economy/templates/includes/revenue_list_panel.html b/src/economy/templates/includes/revenue_list_panel.html index 1ce389265..bf18459c4 100644 --- a/src/economy/templates/includes/revenue_list_panel.html +++ b/src/economy/templates/includes/revenue_list_panel.html @@ -1,6 +1,6 @@ {% load bornhack %} {% if revenue_list %} - +
@@ -9,38 +9,67 @@ {% endif %} + {% if not reimbursement %} + + {% endif %} - - + {% if not reimbursement %} + + + {% endif %} {% for revenue in revenue_list %} - + + + {# camp identifier if needed #} {% if past_camps %} {% endif %} + - + + + {# to save space do not show payment status column in reimbursements #} + {% if not reimbursement %} + + {% endif %} + - - + + {# to save space do not show approval status and reimbursement link in reimbursements #} + {% if not reimbursement %} + + + {% endif %} + + {# actions #} - {% if not reimbursement %} - - {% endif %} + @@ -34,12 +32,7 @@ - - {# to save space do not show payment status column in reimbursements #} - {% if not reimbursement %} - - {% endif %} - + diff --git a/src/economy/templates/includes/revenue_list_panel.html b/src/economy/templates/includes/revenue_list_panel.html index bf18459c4..27155a981 100644 --- a/src/economy/templates/includes/revenue_list_panel.html +++ b/src/economy/templates/includes/revenue_list_panel.html @@ -9,9 +9,7 @@ {% endif %} - {% if not reimbursement %} - - {% endif %} + @@ -34,12 +32,7 @@ - - {# to save space do not show payment status column in reimbursements #} - {% if not reimbursement %} - - {% endif %} - + diff --git a/src/economy/templates/reimbursement_form.html b/src/economy/templates/reimbursement_form.html index cf2ee6dbc..8a9dd2a12 100644 --- a/src/economy/templates/reimbursement_form.html +++ b/src/economy/templates/reimbursement_form.html @@ -8,64 +8,37 @@ {% block content %}

{% if request.resolver_match.url_name == "reimbursement_create" %}Create{% else %}Update{% endif %} Reimbursement

+
- {% if expenses %} -

This reimbursement covers the following expenses:

+ {% if object.expenses %} +

Expenses in this Reimbursement:

-
IDInvoice Date Created ByPayment StatusDebtor Amount DescriptionResponsible TeamApprovedApprovedReimbursementActions
{{ revenue.pk }}{{ revenue.pk }}{{ revenue.camp.title }}{{ revenue.invoice_date }}{{ revenue.user.email }}{{ revenue.user.profile.get_name }}{{ revenue.payment_status }}{{ revenue.debtor }} {{ revenue.amount }} DKK {{ revenue.description }}{{ revenue.responsible_team.name }} Team{{ revenue.approval_status }}{{ 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 %} +
- {% 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 @@

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..cf2ee6dbc 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 %} @@ -7,8 +8,9 @@ {% block content %}

{% if request.resolver_match.url_name == "reimbursement_create" %}Create{% else %}Update{% endif %} Reimbursement

-
-

This reimbursement covers the following approved expenses:

+
+ {% if expenses %} +

This reimbursement covers the following expenses:

@@ -16,24 +18,49 @@

This reimbursement covers the following approved expense

- {% for expense in expenses %} - + - {% endfor %}
Description Amount InvoiceResponsible Team
{{ expense.description }}{{ expense.amount }}{{ expense.amount|currency }} {{ expense.invoice }}{{ expense.responsible_team }} Team
+ {% endif %} + + {% if revenues %} +
+

This reimbursement covers the following revenues:

+
+ + + + + + + + + + {% for revenue in revenues %} + + + + + + {% endfor %} + +
DescriptionAmountInvoice
{{ revenue.description }}{{ revenue.amount|currency }}{{ revenue.invoice }}
+
+
+ {% endif %} -

The total amount for this reimbursement will be {{ total_amount.amount__sum }} DKK

+

The total amount for this reimbursement will be {{ total_amount }} DKK. Note: a negative number here means you owe BornHack money, while a positive number means that BornHack owes you money.

{% csrf_token %} 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 0fce098d3..3db9ff538 100644 --- a/src/economy/views.py +++ b/src/economy/views.py @@ -6,9 +6,8 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import OuterRef -from django.db.models import Q from django.db.models import Prefetch +from django.db.models import Q from django.db.models import Sum from django.http import HttpResponse from django.http import HttpResponseRedirect @@ -67,8 +66,8 @@ def get_context_data(self, **kwargs): ).count() reimbursement_total = 0 for reimbursement in Reimbursement.objects.filter( - reimbursement_user=self.request.user, - camp=self.camp, + reimbursement_user=self.request.user, + camp=self.camp, ): reimbursement_total += reimbursement.amount context["reimbursement_total"] = reimbursement_total @@ -158,18 +157,14 @@ class ChainListView(CampViewMixin, RaisePermissionRequiredMixin, ListView): permission_required = "camps.expense_create_permission" def get_queryset(self): - queryset = (Chain.objects - .filter(credebtors__expenses__camp=self.camp) - .order_by('name')) + queryset = Chain.objects.filter(credebtors__expenses__camp=self.camp).order_by("name") return queryset def get_context_data(self, **kwargs): """Add chains with expenses in past years""" context = super().get_context_data(**kwargs) - context['past_year_chains'] = (Chain.objects - .filter(~Q(credebtors__expenses__camp=self.camp)) - .order_by('name')) + context["past_year_chains"] = Chain.objects.filter(~Q(credebtors__expenses__camp=self.camp)).order_by("name") return context @@ -219,23 +214,27 @@ class CredebtorListView( permission_required = "camps.expense_create_permission" def get_queryset(self): - expenses=Expense.objects.filter(camp=self.camp) - revenues=Revenue.objects.filter(camp=self.camp) - return (Credebtor.objects.filter( - chain=self.chain - ).prefetch_related( - Prefetch( - 'expenses', - queryset=expenses, - to_attr="current_expenses", + expenses = Expense.objects.filter(camp=self.camp) + revenues = Revenue.objects.filter(camp=self.camp) + return ( + Credebtor.objects.filter( + chain=self.chain, ) - ).prefetch_related( - Prefetch( - 'revenues', - queryset=revenues, - to_attr="current_revenues", + .prefetch_related( + Prefetch( + "expenses", + queryset=expenses, + to_attr="current_expenses", + ), ) - )) + .prefetch_related( + Prefetch( + "revenues", + queryset=revenues, + to_attr="current_revenues", + ), + ) + ) def get_context_data(self, **kwargs): """Add chain to context.""" @@ -274,11 +273,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 and fixup choices for payment method.""" context = super().get_context_data(**kwargs) - context["form"].fields["responsible_team"].queryset = Team.objects.filter( - camp=self.camp, - ) context["creditor"] = self.credebtor return context @@ -303,7 +299,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], ) @@ -333,9 +329,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 @@ -419,31 +412,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( - reimbursement__isnull=True, - approved=True, - paid_by_bornhack=False, - ): + 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, + 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", @@ -458,10 +455,20 @@ def form_valid(self, form): 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") + expenses_total = expenses.aggregate(Sum("amount"))["amount__sum"] + + # 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"] + 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}), ) @@ -473,6 +480,19 @@ def form_valid(self, form): 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 @@ -485,14 +505,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( @@ -513,34 +553,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, @@ -593,9 +605,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 @@ -620,7 +629,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], ) @@ -647,14 +656,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 47da1f1c8..8d3e9e32d 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 @@ -2108,8 +2109,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.""" @@ -2118,7 +2118,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) @@ -2129,8 +2129,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, @@ -2140,12 +2148,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.""" From e6caf9f93696c9986eb6912afc0555a3fde7bc48 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Thu, 18 Dec 2025 22:03:18 +0100 Subject: [PATCH 02/13] default to 0 --- src/economy/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/economy/views.py b/src/economy/views.py index 3db9ff538..dfd2240fd 100644 --- a/src/economy/views.py +++ b/src/economy/views.py @@ -457,7 +457,7 @@ def form_valid(self, form): reimbursement__isnull=True, payment_status="PAID_NEEDS_REIMBURSEMENT", ) - expenses_total = expenses.aggregate(Sum("amount"))["amount__sum"] + expenses_total = expenses.aggregate(Sum("amount"))["amount__sum"] or 0 # get the revenues for this user revenues = Revenue.objects.filter( @@ -466,7 +466,7 @@ def form_valid(self, form): reimbursement__isnull=True, payment_status="PAID_NEEDS_REDISBURSEMENT", ) - revenues_total = revenues.aggregate(Sum("amount"))["amount__sum"] + 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( From 2832463d3959b7ed9f3e8a6522a01121096a0b7e Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Thu, 18 Dec 2025 22:50:31 +0100 Subject: [PATCH 03/13] this and that --- src/backoffice/views/economy.py | 19 ++++- .../includes/expense_list_panel.html | 11 +-- .../includes/revenue_list_panel.html | 11 +-- src/economy/templates/reimbursement_form.html | 69 ++++++------------- 4 files changed, 42 insertions(+), 68 deletions(-) diff --git a/src/backoffice/views/economy.py b/src/backoffice/views/economy.py index 5bc35b4f9..f68143e4b 100644 --- a/src/backoffice/views/economy.py +++ b/src/backoffice/views/economy.py @@ -273,12 +273,12 @@ 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["total_amount"] = self.object.amount context["reimbursement_user"] = self.object.reimbursement_user context["cancelurl"] = reverse( "backoffice:reimbursement_list", @@ -286,6 +286,21 @@ def get_context_data(self, **kwargs): ) return context + def form_valid(self, form): + """We have two submit buttons in this form, 'Just Save', and 'Mark as Paid'.""" + reimbursement = form.save() + if "paid" in form.data: + # mark as paid button was pressed + 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: + messages.success(self.request, "Expense 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", diff --git a/src/economy/templates/includes/expense_list_panel.html b/src/economy/templates/includes/expense_list_panel.html index 545405c78..69bc7a2c8 100644 --- a/src/economy/templates/includes/expense_list_panel.html +++ b/src/economy/templates/includes/expense_list_panel.html @@ -9,9 +9,7 @@ {% endif %}
Invoice Date Created ByPayment StatusPayment Status Creditor Amount Description{{ expense.invoice_date }} {{ expense.user.profile.get_name }}{{ expense.payment_status }}{{ expense.payment_status }} {{ expense.creditor.name }} {{ expense.amount }} DKK {{ expense.description }}Invoice Date Created ByPayment StatusPayment Status Debtor Amount Description{{ revenue.invoice_date }} {{ revenue.user.profile.get_name }}{{ revenue.payment_status }}{{ revenue.payment_status }} {{ revenue.debtor }} {{ revenue.amount }} DKK {{ revenue.description }}
- - - - - - - - - {% for expense in expenses %} - - - - - - {% endfor %} - -
DescriptionAmountInvoice
{{ expense.description }}{{ expense.amount|currency }}{{ expense.invoice }}
+ {% include 'includes/expense_list_panel.html' with expense_list=reimbursement.covered_expenses.all %}
{% endif %} - {% if revenues %} + {% if object.revenues %}
-

This reimbursement covers the following revenues:

+

Revenues in this Reimbursement:

- - - - - - - - - - {% for revenue in revenues %} - - - - - - {% endfor %} - -
DescriptionAmountInvoice
{{ revenue.description }}{{ revenue.amount|currency }}{{ revenue.invoice }}
+ {% include 'includes/revenue_list_panel.html' with revenue_list=reimbursement.covered_revenues.all %}
{% endif %} -

The total amount for this reimbursement will be {{ total_amount }} DKK. Note: a negative number here means you owe BornHack money, while a positive number means that BornHack owes you money.

- - - {% csrf_token %} - {% bootstrap_form form %} - - Cancel - +
+

Reimbursement:

+
+

The total amount for this reimbursement will be {{ total_amount }} DKK.
+ Note: a negative number here means you owe BornHack money, while a positive number means that BornHack owes you money.

+
+ {% csrf_token %} + {% bootstrap_form form %} + + + Cancel +
+
+
{% endblock content %} From 58017cde1dfff5a39cd1a3c2d23cf0294e051ba5 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Thu, 18 Dec 2025 23:05:34 +0100 Subject: [PATCH 04/13] formatting and text --- src/economy/templates/includes/reimbursement_detail_panel.html | 2 +- src/economy/templates/reimbursement_form.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/economy/templates/includes/reimbursement_detail_panel.html b/src/economy/templates/includes/reimbursement_detail_panel.html index 8c9686854..7b871e96a 100644 --- a/src/economy/templates/includes/reimbursement_detail_panel.html +++ b/src/economy/templates/includes/reimbursement_detail_panel.html @@ -14,7 +14,7 @@ Total Amount {{ reimbursement.amount }} DKK
-Note: a negative number here means you owe BornHack money, while a positive number means that BornHack owes you money. + 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. diff --git a/src/economy/templates/reimbursement_form.html b/src/economy/templates/reimbursement_form.html index 8a9dd2a12..26c01ffd8 100644 --- a/src/economy/templates/reimbursement_form.html +++ b/src/economy/templates/reimbursement_form.html @@ -19,7 +19,7 @@

Expenses in this Reimbursement:

{% endif %} {% if object.revenues %} -
+

Revenues in this Reimbursement:

{% include 'includes/revenue_list_panel.html' with revenue_list=reimbursement.covered_revenues.all %} From 75e0f1f0e30750fbc4198f518300a1b661eccd60 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Fri, 19 Dec 2025 09:24:30 +0100 Subject: [PATCH 05/13] fiddle a bit more --- src/backoffice/views/economy.py | 14 +++--- src/economy/templates/reimbursement_form.html | 48 ++++++++++--------- src/economy/views.py | 1 + 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/backoffice/views/economy.py b/src/backoffice/views/economy.py index f68143e4b..969e6f19a 100644 --- a/src/backoffice/views/economy.py +++ b/src/backoffice/views/economy.py @@ -277,7 +277,8 @@ class ReimbursementUpdateView( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["expenses"] = self.object.expenses.filter(paid_by_bornhack=False) + 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( @@ -287,14 +288,15 @@ def get_context_data(self, **kwargs): return context def form_valid(self, form): - """We have two submit buttons in this form, 'Just Save', and 'Mark as Paid'.""" - reimbursement = form.save() + """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: - messages.success(self.request, "Expense updated") + reimbursement = form.save() + messages.success(self.request, "Reimbursement notes updated") else: messages.error(self.request, "Unknown submit action") return redirect( @@ -312,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, @@ -325,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( diff --git a/src/economy/templates/reimbursement_form.html b/src/economy/templates/reimbursement_form.html index 26c01ffd8..f675e8aec 100644 --- a/src/economy/templates/reimbursement_form.html +++ b/src/economy/templates/reimbursement_form.html @@ -7,36 +7,40 @@ {% endblock %} {% block content %} -

{% if request.resolver_match.url_name == "reimbursement_create" %}Create{% else %}Update{% endif %} Reimbursement

- -
- {% if object.expenses %} -

Expenses in this Reimbursement:

+
+

{% if request.resolver_match.url_name == "reimbursement_create" %}Create{% else %}Update{% endif %} Reimbursement

- {% include 'includes/expense_list_panel.html' with expense_list=reimbursement.covered_expenses.all %} -
-
- {% endif %} +
+ {% if expenses %} +

Expenses in this Reimbursement:

+
+ {% include 'includes/expense_list_panel.html' with expense_list=expenses %} +
+
+ {% endif %} - {% if object.revenues %} -
-

Revenues in this Reimbursement:

-
- {% include 'includes/revenue_list_panel.html' with revenue_list=reimbursement.covered_revenues.all %} -
-
- {% endif %} + {% if revenues %} +
+

Revenues in this Reimbursement:

+
+ {% include 'includes/revenue_list_panel.html' with revenue_list=revenues %} +
+
+ {% endif %} -
-

Reimbursement:

-

The total amount for this reimbursement will be {{ total_amount }} DKK.
Note: a negative number here means you owe BornHack money, while a positive number means that BornHack owes you money.

{% csrf_token %} {% bootstrap_form form %} - - + {% if request.resolver_match.app_name == "backoffice" %} + + {% if not object.paid %} + + {% endif %} + {% else %} + + {% endif %} Cancel
diff --git a/src/economy/views.py b/src/economy/views.py index dfd2240fd..442073b9e 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 From dcf30394631d532139b51a883bcdc97325d9b379 Mon Sep 17 00:00:00 2001 From: Vidir Valberg Gudmundsson Date: Mon, 22 Dec 2025 15:23:58 +0100 Subject: [PATCH 06/13] Use get_payment_status_display to display payment status a bit more pretty. --- src/economy/templates/includes/expense_list_panel.html | 2 +- src/economy/templates/includes/revenue_detail_panel.html | 2 +- src/economy/templates/includes/revenue_list_panel.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/economy/templates/includes/expense_list_panel.html b/src/economy/templates/includes/expense_list_panel.html index 69bc7a2c8..a949b4350 100644 --- a/src/economy/templates/includes/expense_list_panel.html +++ b/src/economy/templates/includes/expense_list_panel.html @@ -32,7 +32,7 @@ {{ expense.invoice_date }} {{ expense.user.profile.get_name }} - {{ expense.payment_status }} + {{ expense.get_payment_status_display }} {{ expense.creditor.name }} {{ expense.amount }} DKK {{ expense.description }} diff --git a/src/economy/templates/includes/revenue_detail_panel.html b/src/economy/templates/includes/revenue_detail_panel.html index f1337193a..aa3cbab20 100644 --- a/src/economy/templates/includes/revenue_detail_panel.html +++ b/src/economy/templates/includes/revenue_detail_panel.html @@ -26,7 +26,7 @@ Payment Status - {{ revenue.payment_status }} + {{ revenue.get_payment_status_display }} Description diff --git a/src/economy/templates/includes/revenue_list_panel.html b/src/economy/templates/includes/revenue_list_panel.html index 27155a981..0600aed23 100644 --- a/src/economy/templates/includes/revenue_list_panel.html +++ b/src/economy/templates/includes/revenue_list_panel.html @@ -32,7 +32,7 @@ {{ revenue.invoice_date }} {{ revenue.user.profile.get_name }} - {{ revenue.payment_status }} + {{ revenue.get_payment_status_display }} {{ revenue.debtor }} {{ revenue.amount }} DKK {{ revenue.description }} From 8143489582665f00d848a1ef768b4f27127c0a18 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Mon, 22 Dec 2025 15:27:49 +0100 Subject: [PATCH 07/13] add default=0 to Sum() --- src/economy/models.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/economy/models.py b/src/economy/models.py index 4476d2586..c98136588 100644 --- a/src/economy/models.py +++ b/src/economy/models.py @@ -595,16 +595,12 @@ def covered_revenues(self): @property def amount(self): """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"))[ + 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"))[ + revenues_total = self.revenues.filter(created_for_reimbursement=False).aggregate(models.Sum("amount", default=0))[ "amount__sum" ] - if expenses_total is None: - expenses_total = 0 - if revenues_total is None: - revenues_total = 0 return expenses_total - revenues_total @property From 84e831bc62a79f3a8fb9e11ebd5823796fd75763 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Mon, 22 Dec 2025 15:30:13 +0100 Subject: [PATCH 08/13] move economy team check earlier --- src/economy/views.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/economy/views.py b/src/economy/views.py index 442073b9e..7aa8e9ade 100644 --- a/src/economy/views.py +++ b/src/economy/views.py @@ -451,6 +451,13 @@ 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, @@ -474,13 +481,6 @@ def form_valid(self, form): reverse("economy:dashboard", kwargs={"camp_slug": self.camp.slug}), ) - # 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}), - ) - # calculate the reimbursement total reimbursement_total = expenses_total - revenues_total if reimbursement_total > 0: From 47fd5e1d50810e367aba5d25ef82196c1e37952d Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Mon, 22 Dec 2025 15:31:05 +0100 Subject: [PATCH 09/13] update docstring --- src/economy/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/economy/views.py b/src/economy/views.py index 7aa8e9ade..06ad877f0 100644 --- a/src/economy/views.py +++ b/src/economy/views.py @@ -274,7 +274,7 @@ class ExpenseCreateView( form_class = ExpenseCreateForm def get_context_data(self, **kwargs): - """Set creditor and fixup choices for payment method.""" + """Set creditor.""" context = super().get_context_data(**kwargs) context["creditor"] = self.credebtor return context From e098694feb5b50c084e131302d1772ad262ea893 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Mon, 22 Dec 2025 15:54:08 +0100 Subject: [PATCH 10/13] enable ATOMIC_REQUESTS and remove unused setting --- src/bornhack/environment_settings.py.dist | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 From 177dfd1198003521cdbf73328e40d87a74718540 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Mon, 22 Dec 2025 15:54:25 +0100 Subject: [PATCH 11/13] add payment status to expense detail --- src/economy/templates/includes/expense_detail_panel.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/economy/templates/includes/expense_detail_panel.html b/src/economy/templates/includes/expense_detail_panel.html index 1467a47f7..8d542b81f 100644 --- a/src/economy/templates/includes/expense_detail_panel.html +++ b/src/economy/templates/includes/expense_detail_panel.html @@ -24,6 +24,10 @@ Invoice Date {{ expense.invoice_date }} + + Payment Status + {{ revenue.get_payment_status_display }} + Description {{ expense.description }} From 0128cade55d5a5885ef0994bb3f9f33e46642aa2 Mon Sep 17 00:00:00 2001 From: Vidir Valberg Gudmundsson Date: Mon, 22 Dec 2025 15:58:43 +0100 Subject: [PATCH 12/13] Add an empty state to payment_status. --- ...emove_expense_responsible_team_and_more.py | 132 ++++++++++++++---- src/economy/models.py | 4 +- 2 files changed, 105 insertions(+), 31 deletions(-) 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 index ea4858a3e..1abe72509 100644 --- a/src/economy/migrations/0047_remove_expense_responsible_team_and_more.py +++ b/src/economy/migrations/0047_remove_expense_responsible_team_and_more.py @@ -5,57 +5,131 @@ class Migration(migrations.Migration): - dependencies = [ - ('economy', '0046_remove_posreport_bank_responsible_and_more'), + ("economy", "0046_remove_posreport_bank_responsible_and_more"), ] operations = [ migrations.RemoveField( - model_name='expense', - name='responsible_team', + model_name="expense", + name="responsible_team", ), migrations.RemoveField( - model_name='revenue', - name='invoice_fk', + model_name="revenue", + name="invoice_fk", ), migrations.RemoveField( - model_name='revenue', - name='responsible_team', + 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'), + 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=[('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.', null=True), + 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.", + null=True, + ), ), 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'), + 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=[('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.', null=True), + 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.", + null=True, + ), ), 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'), + 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'), + 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), + 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/models.py b/src/economy/models.py index c98136588..1296aca3d 100644 --- a/src/economy/models.py +++ b/src/economy/models.py @@ -251,6 +251,7 @@ class Revenue(ExportModelOperationsMixin("revenue"), CampRelatedModel, UUIDModel ) PAYMENT_STATUS_CHOICES = [ + ("", "Unknown"), ( "Paid to BornHack", ( @@ -275,7 +276,6 @@ class Revenue(ExportModelOperationsMixin("revenue"), CampRelatedModel, UUIDModel ), ] payment_status = models.CharField( - null=True, choices=PAYMENT_STATUS_CHOICES, help_text="Payment status for this revenue.", ) @@ -399,6 +399,7 @@ class Expense(ExportModelOperationsMixin("expense"), CampRelatedModel, UUIDModel ) PAYMENT_STATUS_CHOICES = [ + ("", "Unknown"), ( "Paid by BornHack", ( @@ -423,7 +424,6 @@ class Expense(ExportModelOperationsMixin("expense"), CampRelatedModel, UUIDModel ), ] payment_status = models.CharField( - null=True, choices=PAYMENT_STATUS_CHOICES, help_text="Payment status for this expense.", ) From a7d02565d03bc3317fe761412ea2696395e36767 Mon Sep 17 00:00:00 2001 From: Vidir Valberg Gudmundsson Date: Mon, 22 Dec 2025 16:36:59 +0100 Subject: [PATCH 13/13] Fix migration --- ...047_remove_expense_responsible_team_and_more.py | 4 ++-- src/economy/models.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) 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 index 1abe72509..6114c6e11 100644 --- a/src/economy/migrations/0047_remove_expense_responsible_team_and_more.py +++ b/src/economy/migrations/0047_remove_expense_responsible_team_and_more.py @@ -56,7 +56,7 @@ class Migration(migrations.Migration): ("Unpaid", (("UNPAID_NEEDS_PAYMENT", "Expense is unpaid"),)), ], help_text="Payment status for this expense.", - null=True, + default="", ), ), migrations.AddField( @@ -96,7 +96,7 @@ class Migration(migrations.Migration): ("Unpaid", (("UNPAID_NEEDS_PAYMENT", "Revenue is unpaid"),)), ], help_text="Payment status for this revenue.", - null=True, + default="", ), ), migrations.AddField( diff --git a/src/economy/models.py b/src/economy/models.py index 659566d80..ffe83e62c 100644 --- a/src/economy/models.py +++ b/src/economy/models.py @@ -274,6 +274,7 @@ class Revenue(ExportModelOperationsMixin("revenue"), CampRelatedModel, UUIDModel payment_status = models.CharField( choices=PAYMENT_STATUS_CHOICES, help_text="Payment status for this revenue.", + default="", ) reimbursement = models.ForeignKey( @@ -422,6 +423,7 @@ class Expense(ExportModelOperationsMixin("expense"), CampRelatedModel, UUIDModel payment_status = models.CharField( choices=PAYMENT_STATUS_CHOICES, help_text="Payment status for this expense.", + default="", ) invoice = models.ImageField( @@ -591,12 +593,12 @@ def covered_revenues(self): @property def amount(self): """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" - ] + 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