diff --git a/docs/user-guides/LEDGER_NOTES.md b/docs/user-guides/LEDGER_NOTES.md new file mode 100644 index 0000000000..ec0d54c1e2 --- /dev/null +++ b/docs/user-guides/LEDGER_NOTES.md @@ -0,0 +1,27 @@ +# ARDC Ledger Functionality + +## Requested and approved amounts + +Negative amounts are allowed in case the grantee needs to repay. +Wide currency amounts are allowed in cases of strange or inflated currencies. +The currency code is left optional and relatively unconstrained in case of new +or unofficial currencies. + +## Contracts and disbursed totals on the All Projects table and dashboards + +The All Projects table at `apply/projects/all/` and the projects tables on +the staff, contracting, and applicant dashboards show a "Contracts total" +column with the sum of the approved amounts recorded on each project's +contracts, and a "Disbursed total" column with the sum of the disbursement +amounts recorded against those contracts (negative disbursements, for +repayments, net out). Each column is followed by the currency code when all +of its underlying amounts share one currency code; when they do not — +including coded amounts alongside uncoded ones — that column shows +"Multiple currencies" instead of a total. A project with no approved amount +entered, or with no disbursements, shows "—" because absence is not zero; a +sum that genuinely totals zero shows as 0.00. The All Projects table's CSV +export shows the same values with locale-neutral number formatting. + +Both columns are only present when `PROJECTS_PAYMENTS_FLOW` is +`DISBURSEMENTS`; in the `INVOICING` and `DISABLED` flows they are neither +computed nor shown (table, dashboards, CSV). diff --git a/hypha/apply/activity/adapters/activity_feed.py b/hypha/apply/activity/adapters/activity_feed.py index 94ff2d60e5..ce5703bbd3 100644 --- a/hypha/apply/activity/adapters/activity_feed.py +++ b/hypha/apply/activity/adapters/activity_feed.py @@ -71,6 +71,13 @@ class ActivityAdapter(AdapterBase): MESSAGES.APPROVE_CONTRACT: _("Approved contract"), MESSAGES.UPDATE_INVOICE_STATUS: "handle_update_invoice_status", MESSAGES.CREATE_INVOICE: _("Invoice added"), + MESSAGES.CREATE_DISBURSEMENT: _("Disbursement of {disbursement.amount} added"), + MESSAGES.UPDATE_DISBURSEMENT: _( + "Disbursement of {disbursement.amount} updated" + ), + MESSAGES.DELETE_DISBURSEMENT: _( + "Disbursement of {disbursement.amount} deleted" + ), MESSAGES.SUBMIT_REPORT: _("Submitted a report"), MESSAGES.DELETE_REPORT: _("deleted a report"), MESSAGES.SKIPPED_REPORT: "handle_skipped_report", @@ -126,6 +133,9 @@ def extra_kwargs(self, message_type, source, sources, **kwargs): MESSAGES.DELETE_INVOICE, MESSAGES.CREATE_INVOICE, MESSAGES.DELETE_REPORT, + MESSAGES.CREATE_DISBURSEMENT, + MESSAGES.UPDATE_DISBURSEMENT, + MESSAGES.DELETE_DISBURSEMENT, ]: return {"visibility": APPLICANT} diff --git a/hypha/apply/activity/adapters/base.py b/hypha/apply/activity/adapters/base.py index 3957b43209..164a063f90 100644 --- a/hypha/apply/activity/adapters/base.py +++ b/hypha/apply/activity/adapters/base.py @@ -36,6 +36,9 @@ MESSAGES.APPROVE_INVOICE: "invoice", MESSAGES.DELETE_INVOICE: "invoice", MESSAGES.UPDATE_INVOICE: "invoice", + MESSAGES.CREATE_DISBURSEMENT: "disbursement", + MESSAGES.UPDATE_DISBURSEMENT: "disbursement", + MESSAGES.DELETE_DISBURSEMENT: "disbursement", MESSAGES.SUBMIT_REPORT: "report", MESSAGES.DELETE_REPORT: "report", MESSAGES.SKIPPED_REPORT: "report", diff --git a/hypha/apply/activity/adapters/slack.py b/hypha/apply/activity/adapters/slack.py index d06f65938a..703e4d0964 100644 --- a/hypha/apply/activity/adapters/slack.py +++ b/hypha/apply/activity/adapters/slack.py @@ -114,6 +114,15 @@ class SlackAdapter(AdapterBase): MESSAGES.CREATE_INVOICE: _( "{user} has created invoice for <{link}|{source.title}>" ), + MESSAGES.CREATE_DISBURSEMENT: _( + "{user} has created a disbursement of {disbursement.amount} for <{link}|{source.title}>" + ), + MESSAGES.UPDATE_DISBURSEMENT: _( + "{user} has updated a disbursement of {disbursement.amount} for <{link}|{source.title}>" + ), + MESSAGES.DELETE_DISBURSEMENT: _( + "{user} has deleted a disbursement of {disbursement.amount} for <{link}|{source.title}>" + ), MESSAGES.UPDATE_INVOICE_STATUS: _( "{user} has changed the status of <{link_related}|invoice> on <{link}|{source.title}> to {invoice.status_display}" ), diff --git a/hypha/apply/activity/migrations/0097_alter_event_type.py b/hypha/apply/activity/migrations/0097_alter_event_type.py new file mode 100644 index 0000000000..b846d6ea9c --- /dev/null +++ b/hypha/apply/activity/migrations/0097_alter_event_type.py @@ -0,0 +1,97 @@ +# Generated by Django 5.2.19 on 2026-11-09 21:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("activity", "0096_alter_event_type"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="type", + field=models.CharField( + choices=[ + ("UPDATE_LEAD", "updated lead"), + ("BATCH_UPDATE_LEAD", "batch updated lead"), + ("EDIT_SUBMISSION", "edited submission"), + ("APPLICANT_EDIT", "edited applicant"), + ("NEW_SUBMISSION", "submitted new submission"), + ("DRAFT_SUBMISSION", "submitted new draft submission"), + ("SCREENING", "screened"), + ("TRANSITION", "transitioned"), + ("BATCH_TRANSITION", "batch transitioned"), + ("DETERMINATION_OUTCOME", "sent determination outcome"), + ("BATCH_DETERMINATION_OUTCOME", "sent batch determination outcome"), + ("INVITED_TO_PROPOSAL", "invited to proposal"), + ("REVIEWERS_UPDATED", "updated reviewers"), + ("BATCH_REVIEWERS_UPDATED", "batch updated reviewers"), + ("READY_FOR_REVIEW", "marked ready for review"), + ("BATCH_READY_FOR_REVIEW", "marked batch ready for review"), + ("NEW_REVIEW", "added new review"), + ("COMMENT", "added comment"), + ("PROPOSAL_SUBMITTED", "submitted proposal"), + ("OPENED_SEALED", "opened sealed submission"), + ("REVIEW_OPINION", "reviewed opinion"), + ("DELETE_SUBMISSION", "deleted submission"), + ("ANONYMIZE_SUBMISSION", "anonymized submission"), + ("DELETE_REVIEW", "deleted review"), + ("DELETE_REVIEW_OPINION", "deleted review opinion"), + ("CREATED_PROJECT", "created project"), + ("CREATED_SOW", "created a project SOW"), + ("EDITED_SOW", "edited a project SOW"), + ("CREATED_PF", "created a project form"), + ("EDITED_PF", "edited a project form"), + ("UPDATE_PROJECT_LEAD", "updated project lead"), + ("UPDATE_PROJECT_TITLE", "updated project title"), + ( + "UPDATE_PROJECT_CONTRACT_NUMBER", + "updated project contract number", + ), + ("EDIT_REVIEW", "edited review"), + ("SEND_FOR_APPROVAL", "sent for approval"), + ("APPROVE_PROJECT", "approved project"), + ("ASSIGN_PAF_APPROVER", "assign project form approver"), + ("APPROVE_PAF", "approved project form"), + ("PROJECT_TRANSITION", "transitioned project"), + ("REQUEST_PROJECT_CHANGE", "requested project change"), + ("SUBMIT_CONTRACT_DOCUMENTS", "submitted contract documents"), + ("UPLOAD_DOCUMENT", "uploaded document to project"), + ("UPLOAD_CONTRACT", "uploaded contract to project"), + ("APPROVE_CONTRACT", "approved contract"), + ("CREATE_INVOICE", "created invoice for project"), + ("UPDATE_INVOICE_STATUS", "updated invoice status"), + ("APPROVE_INVOICE", "approve invoice"), + ("DELETE_INVOICE", "deleted invoice"), + ("CREATE_DISBURSEMENT", "created disbursement"), + ("UPDATE_DISBURSEMENT", "updated disbursement"), + ("DELETE_DISBURSEMENT", "deleted disbursement"), + ("SENT_TO_COMPLIANCE", "sent project to compliance"), + ("UPDATE_INVOICE", "updated invoice"), + ("SUBMIT_REPORT", "submitted report"), + ("DELETE_REPORT", "deleted report"), + ("SKIPPED_REPORT", "skipped report"), + ("REPORT_FREQUENCY_CHANGED", "changed report frequency"), + ("DISABLED_REPORTING", "disabled reporting"), + ("REPORT_NOTIFY", "notified report"), + ("REVIEW_REMINDER", "reminder to review"), + ("BATCH_DELETE_SUBMISSION", "batch deleted submissions"), + ("BATCH_ANONYMIZE_SUBMISSION", "batch anonymized submissions"), + ("BATCH_ARCHIVE_SUBMISSION", "batch archive submissions"), + ("BATCH_INVOICE_STATUS_UPDATE", "batch update invoice status"), + ("STAFF_ACCOUNT_CREATED", "created new account"), + ("STAFF_ACCOUNT_EDITED", "edited account"), + ("ARCHIVE_SUBMISSION", "archived submission"), + ("UNARCHIVE_SUBMISSION", "unarchived submission"), + ("REMOVE_TASK", "remove task"), + ("INVITE_COAPPLICANT", "invite co-applicant"), + ("UPDATE_AUTHOR", "updated author"), + ("COMMENT_ASSIGNED", "comment assigned"), + ], + max_length=50, + verbose_name="verb", + ), + ), + ] diff --git a/hypha/apply/activity/options.py b/hypha/apply/activity/options.py index 0b95a6ee72..b2b842d377 100644 --- a/hypha/apply/activity/options.py +++ b/hypha/apply/activity/options.py @@ -64,6 +64,9 @@ class MESSAGES(TextChoices): UPDATE_INVOICE_STATUS = "UPDATE_INVOICE_STATUS", _("updated invoice status") APPROVE_INVOICE = "APPROVE_INVOICE", _("approve invoice") DELETE_INVOICE = "DELETE_INVOICE", _("deleted invoice") + CREATE_DISBURSEMENT = "CREATE_DISBURSEMENT", _("created disbursement") + UPDATE_DISBURSEMENT = "UPDATE_DISBURSEMENT", _("updated disbursement") + DELETE_DISBURSEMENT = "DELETE_DISBURSEMENT", _("deleted disbursement") SENT_TO_COMPLIANCE = "SENT_TO_COMPLIANCE", _("sent project to compliance") UPDATE_INVOICE = "UPDATE_INVOICE", _("updated invoice") SUBMIT_REPORT = "SUBMIT_REPORT", _("submitted report") diff --git a/hypha/apply/dashboard/tests/test_views.py b/hypha/apply/dashboard/tests/test_views.py index f2a9b2b94d..c50c4d2c88 100644 --- a/hypha/apply/dashboard/tests/test_views.py +++ b/hypha/apply/dashboard/tests/test_views.py @@ -1,4 +1,6 @@ -from django.test import TestCase +import decimal + +from django.test import TestCase, override_settings from django.urls import reverse from hypha.apply.funds.tests.factories import ( @@ -175,6 +177,19 @@ def test_unassigned_staff_cant_see_projects_awaiting_review_stats_or_table(self) response = self.get_page() self.assertNotContains(response, "Projects awaiting approval") + @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS") + def test_projects_table_shows_contracts_total(self): + project = ProjectFactory(lead=self.user, status=INVOICING_AND_REPORTING) + ContractFactory( + project=project, currency="XTS", amount_approved=decimal.Decimal("421") + ) + ContractFactory( + project=project, currency="XTS", amount_approved=decimal.Decimal("571") + ) + + response = self.get_page() + self.assertContains(response, "992.00 XTS") + class TestStaffDashboardWithWagtailAdminAccess(BaseViewTestCase): user_factory = StaffWithWagtailAdminAccessFactory diff --git a/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html b/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html index 40ab28b4bf..fd7c1be87f 100644 --- a/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html +++ b/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html @@ -1,7 +1,7 @@ {% load django_tables2 i18n heroicons %} {% load querystrings table_tags static %} -{% if heading or use_search %} +{% if heading or use_search or include_export %}
@@ -62,6 +62,18 @@

{{ heading }}

{% heroicon_mini "arrow-down-tray" %} {% endif %} + + {% if include_export %} + + {% heroicon_mini "arrow-down-tray" aria_hidden=true %} + + {% endif %} +
{% endif %} diff --git a/hypha/apply/projects/filters.py b/hypha/apply/projects/filters.py index 0b13cebbbd..91ae28c0ab 100644 --- a/hypha/apply/projects/filters.py +++ b/hypha/apply/projects/filters.py @@ -10,13 +10,16 @@ get_used_funds, ) +from .models.disbursement import Disbursement from .models.invoice import INVOICE_STATUS_CHOICES, Invoice from .models.project import ( CLOSING, INVOICING_AND_REPORTING, PROJECT_STATUS_CHOICES, + Contract, Project, ) +from .reports.filters import DateRangeInputWidget User = get_user_model() @@ -114,3 +117,66 @@ def filter_reporting(self, queryset, name, value): Q(outstanding_reports__lt=1) | Q(outstanding_reports__isnull=True), status__in=(INVOICING_AND_REPORTING, CLOSING), ) + + +NO_CURRENCY = "none" + + +def get_currency_choices(): + """Choices for the disbursement currency filter. + + Currency lives on the related Contract, so the choices are the distinct + codes recorded on contracts, plus an explicit choice for rows whose + contract carries no code (they must remain filterable on their own so a + single-currency total stays reachable). + + Called per FilterSet instantiation (DB-backed choices) and bound to the + filter's form field there, because django-filter's choice rendering does + not resolve callables. + + """ + codes = ( + Contract.objects.filter(currency__isnull=False) + .values_list("currency", flat=True) + .distinct() + .order_by("currency") + ) + return [(code, code) for code in codes] + [(NO_CURRENCY, _("No currency"))] + + +class DisbursementListFilter(filters.FilterSet): + """Filters for the all-disbursements ledger. Minimal by design: date + range (the disbursement's own date, not the date it was entered), + currency and project status; more may be added on customer request. + + """ + + date = filters.DateFromToRangeFilter(widget=DateRangeInputWidget) + currency = MultipleChoiceFilter( + label=_("Currency"), + method="filter_currency", + ) + project_status = MultipleChoiceFilter( + field_name="contract__project__status", + label=_("Project status"), + choices=PROJECT_STATUS_CHOICES, + ) + + class Meta: + fields = ["date", "currency", "project_status"] + model = Disbursement + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.filters["currency"].field.choices = get_currency_choices() + + def filter_currency(self, queryset, name, value): + codes = [code for code in value if code != NO_CURRENCY] + if NO_CURRENCY in value: + q = Q(contract__currency__isnull=True) + if codes: + q |= Q(contract__currency__in=codes) + return queryset.filter(q) + if codes: + return queryset.filter(contract__currency__in=codes) + return queryset diff --git a/hypha/apply/projects/forms/__init__.py b/hypha/apply/projects/forms/__init__.py index a8dd172dfa..9b0244165c 100644 --- a/hypha/apply/projects/forms/__init__.py +++ b/hypha/apply/projects/forms/__init__.py @@ -1,3 +1,4 @@ +from .disbursement import DisbursementForm from .invoice import ( BatchUpdateInvoiceStatusForm, ChangeInvoiceStatusForm, @@ -54,4 +55,5 @@ "ChangeInvoiceStatusForm", "EditInvoiceForm", "InvoiceTagsForm", + "DisbursementForm", ] diff --git a/hypha/apply/projects/forms/disbursement.py b/hypha/apply/projects/forms/disbursement.py new file mode 100644 index 0000000000..db46220056 --- /dev/null +++ b/hypha/apply/projects/forms/disbursement.py @@ -0,0 +1,66 @@ +from django import forms +from django.db.models import DecimalField, Sum, Value +from django.db.models.functions import Coalesce +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from ..models import Disbursement +from .project import TrimmedDecimalInput + + +class TrimmedSignedDecimalInput(TrimmedDecimalInput): + """``TrimmedDecimalInput`` that also accepts a leading minus, so the + disbursement amount can record repayments. Scoped to disbursements via a + dedicated template; the shared ``number.html`` stays positive-only. + + """ + + template_name = "django/forms/widgets/number_signed.html" + + +class DisbursementForm(forms.ModelForm): + """Create/edit a Disbursement. + + Only the user-editable fields are exposed; ``contract``, ``created_by`` + and ``updated_by`` are set by the views so that the model's ``save()`` + audit log (wagtail.create/wagtail.edit) records the acting user. The + ``amount`` widget mirrors the contract amount fields so values render + trimmed but preserve full precision. The views also pass the related + ``contract``, whose currency is shown on the ``amount`` label (e.g. + ``Amount (USD)``) so the unit of the entered amount is unambiguous. On + create the ``amount`` prepopulates with the contract's approved amount + (else the project's awarded value) less this contract's existing + disbursements, still editable. + + """ + + class Meta: + fields = ["amount", "date", "notes"] + model = Disbursement + widgets = {"amount": TrimmedSignedDecimalInput()} + + def __init__(self, *args, **kwargs): + contract = kwargs.pop("contract", None) + super().__init__(*args, **kwargs) + # Default the date to today so a disbursement recorded on the day it + # is made does not need re-entering; the model field stays default-free. + self.fields["date"].initial = timezone.localdate() + # Prepopulate with the awarded amount net of this contract's + # disbursements; editing keeps the instance's recorded amount. + if self.instance.pk is None: + awarded = contract.amount_approved if contract is not None else None + if awarded is None and contract is not None: + awarded = contract.project.value + if awarded is not None: + disbursed = contract.disbursements.aggregate( + total=Coalesce(Sum("amount"), Value(0), output_field=DecimalField()) + )["total"] + self.fields["amount"].initial = awarded - disbursed + self.fields["amount"].help_text = _( + "Amount sent to the grantee. Use a negative value to record a repayment." + ) + # Contract.currency is nullable (old data, other payments flows), so + # only annotate the label when a code is actually recorded. + if contract is not None and contract.currency: + amount_field = self.fields["amount"] + amount_field.label = f"{amount_field.label} ({contract.currency})" diff --git a/hypha/apply/projects/forms/project.py b/hypha/apply/projects/forms/project.py index 0f18edfa90..912b3c8ee4 100644 --- a/hypha/apply/projects/forms/project.py +++ b/hypha/apply/projects/forms/project.py @@ -1,9 +1,14 @@ from datetime import date +from decimal import Decimal, InvalidOperation from django import forms from django.conf import settings from django.contrib.auth import get_user_model +from django.core.validators import RegexValidator from django.db.models import Count, Q +from django.forms import NumberInput +from django.utils.formats import number_format +from django.utils.html import mark_safe from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django_file_form.forms import FileFormMixin @@ -21,6 +26,7 @@ from ..models.project import ( CLOSING, COMPLETE, + CURRENCY_CODE_REGEX, DRAFT, INVOICING_AND_REPORTING, PAF_STATUS_CHOICES, @@ -33,9 +39,24 @@ Project, ProjectSOW, ) +from ..payments import PaymentsFlow User = get_user_model() +TOP_CURRENCY_CODES = [ + "USD", + "EUR", + "CNY", + "INR", + "JPY", + "RUB", + "IDR", + "BRL", + "MXN", + "NGN", +] +CURRENCY_MAX_LENGTH = 3 + def filter_request_choices(choices): return [(k, v) for k, v in PROJECT_STATUS_CHOICES if k in choices] @@ -52,6 +73,89 @@ def get_latest_project_paf_approval_via_roles(project, roles): return paf_approvals.first() +class CurrencyDatalistWidget(forms.TextInput): + """Renders an with a for auto-suggestions + free typing.""" + + def __init__(self, choices=None, attrs=None): + super().__init__(attrs) + self.choices = choices or [] + + def render(self, name, value, attrs=None, renderer=None): + # Base input HTML + if self.choices: + attrs = dict(attrs or {}) + attrs["list"] = f"{name}_datalist" + input_html = super().render(name, value, attrs, renderer) + if not self.choices: + return input_html + + # Datalist options + options_html = "".join(f'