From 206e8d142f01a7e9ea4e03c0ca1f76ed21d3264f Mon Sep 17 00:00:00 2001 From: Jesse Bickel Date: Fri, 12 Jun 2026 12:22:13 -0500 Subject: [PATCH 01/18] Disbursements: add requested and approved amounts A broad range is allowed so as to support current and future monies. Several fiat currencies use 0 or 3 decimal places. Several digital currencies support 6-18 digits following the decimal place. Displaying the amounts in a sensible way will be a UI concern. Thanks to GLM-5.2 (LLM) for helpful hints. --- ...oved_contract_amount_requested_and_more.py | 40 +++++++++++++++++++ hypha/apply/projects/models/project.py | 11 +++++ 2 files changed, 51 insertions(+) create mode 100644 hypha/apply/projects/migrations/0111_contract_amount_approved_contract_amount_requested_and_more.py diff --git a/hypha/apply/projects/migrations/0111_contract_amount_approved_contract_amount_requested_and_more.py b/hypha/apply/projects/migrations/0111_contract_amount_approved_contract_amount_requested_and_more.py new file mode 100644 index 0000000000..047dc30dc1 --- /dev/null +++ b/hypha/apply/projects/migrations/0111_contract_amount_approved_contract_amount_requested_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.18 on 2026-11-04 16:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("application_projects", "0110_alter_pafreviewersrole_options_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="contract", + name="amount_approved", + field=models.DecimalField(decimal_places=19, max_digits=38, null=True), + ), + migrations.AddField( + model_name="contract", + name="amount_requested", + field=models.DecimalField(decimal_places=19, max_digits=38, null=True), + ), + migrations.AddField( + model_name="contract", + name="currency", + field=models.CharField(blank=True, default=None, max_length=3, null=True), + ), + migrations.AddConstraint( + model_name="contract", + constraint=models.CheckConstraint( + condition=models.Q( + ("currency__isnull", True), + ("currency__regex", "^[A-Z]{3}$"), + _connector="OR", + ), + name="contract_currency_code_format", + ), + ), + ] diff --git a/hypha/apply/projects/models/project.py b/hypha/apply/projects/models/project.py index 2ae28b842b..fb3103a6e3 100644 --- a/hypha/apply/projects/models/project.py +++ b/hypha/apply/projects/models/project.py @@ -752,12 +752,23 @@ class Contract(models.Model): created_at = models.DateTimeField(auto_now_add=True) approved_at = models.DateTimeField(null=True) updated_at = models.DateTimeField(null=True) + # Purposefully avoid currency libraries: support unofficial currencies. + currency = models.CharField(max_length=3, null=True, blank=True, default=None) + # Purposefully large decimal fields: support many currencies and inflation. + amount_requested = models.DecimalField(max_digits=38, decimal_places=19, null=True) + amount_approved = models.DecimalField(max_digits=38, decimal_places=19, null=True) objects = ContractQuerySet.as_manager() class Meta: verbose_name = _("contract") verbose_name_plural = _("contracts") + constraints = [ + models.CheckConstraint( + condition=Q(currency__isnull=True) | Q(currency__regex=r"^[A-Z]{3}$"), + name="contract_currency_code_format", + ), + ] def save(self, *args, **kwargs): self.updated_at = timezone.now() From 6b45cc0cf4af85c18320d5b8dbd75906c7a494db Mon Sep 17 00:00:00 2001 From: Jesse Bickel Date: Fri, 26 Jun 2026 14:12:40 -0500 Subject: [PATCH 02/18] Disbursements: show currency, amount fields in UI Offer an hint at currency codes with a datalist with top ten codes. Actually load original when editing and save updates to the contract. There is wide decimal precision available on input, but exactly two decimal places are shown in the UI unless more are used. LLM assistance by unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL Further LLM assistance by GLM-5.2 --- docs/user-guides/LEDGER_NOTES.md | 8 ++ hypha/apply/projects/forms/project.py | 130 ++++++++++++++++++++++- hypha/apply/projects/models/project.py | 2 + hypha/apply/projects/tests/test_forms.py | 100 +++++++++++++++++ hypha/apply/projects/views/project.py | 107 ++++++++++--------- 5 files changed, 292 insertions(+), 55 deletions(-) create mode 100644 docs/user-guides/LEDGER_NOTES.md diff --git a/docs/user-guides/LEDGER_NOTES.md b/docs/user-guides/LEDGER_NOTES.md new file mode 100644 index 0000000000..448aad10e8 --- /dev/null +++ b/docs/user-guides/LEDGER_NOTES.md @@ -0,0 +1,8 @@ +# 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. diff --git a/hypha/apply/projects/forms/project.py b/hypha/apply/projects/forms/project.py index 0f18edfa90..5b1d290c97 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, @@ -36,6 +42,19 @@ User = get_user_model() +TOP_CURRENCY_CODES = [ + "USD", + "EUR", + "CNY", + "INR", + "JPY", + "RUB", + "IDR", + "BRL", + "MXN", + "NGN", +] + def filter_request_choices(choices): return [(k, v) for k, v in PROJECT_STATUS_CHOICES if k in choices] @@ -52,6 +71,74 @@ 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'