Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/user-guides/LEDGER_NOTES.md
Original file line number Diff line number Diff line change
@@ -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).
10 changes: 10 additions & 0 deletions hypha/apply/activity/adapters/activity_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}

Expand Down
3 changes: 3 additions & 0 deletions hypha/apply/activity/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions hypha/apply/activity/adapters/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
),
Expand Down
97 changes: 97 additions & 0 deletions hypha/apply/activity/migrations/0097_alter_event_type.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
3 changes: 3 additions & 0 deletions hypha/apply/activity/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
17 changes: 16 additions & 1 deletion hypha/apply/dashboard/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 %}
<div
class="flex flex-wrap gap-4 justify-between items-center mb-2 w-full"
>
Expand Down Expand Up @@ -62,6 +62,18 @@ <h2 class="section-header">{{ heading }}</h2>
{% heroicon_mini "arrow-down-tray" %}
</a>
{% endif %}

{% if include_export %}
<a
class="btn btn-square"
aria-label="{% trans 'Export as CSV' %}"
href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}format=csv"
data-tippy-content="{% trans 'Export as CSV' %}"
>
{% heroicon_mini "arrow-down-tray" aria_hidden=true %}
</a>
{% endif %}

</div>
{% endif %}

Expand Down
66 changes: 66 additions & 0 deletions hypha/apply/projects/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions hypha/apply/projects/forms/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .disbursement import DisbursementForm
from .invoice import (
BatchUpdateInvoiceStatusForm,
ChangeInvoiceStatusForm,
Expand Down Expand Up @@ -54,4 +55,5 @@
"ChangeInvoiceStatusForm",
"EditInvoiceForm",
"InvoiceTagsForm",
"DisbursementForm",
]
Loading
Loading