From 1aa0f7cf26b0d9bd7118b78deecc5710c81cbe82 Mon Sep 17 00:00:00 2001 From: Shresth Juyal Date: Sun, 4 Oct 2026 00:38:55 +0530 Subject: [PATCH] fix(accounting): round percentage financial aid to nearest cent (fixes #6055) Percentage-based financial aid grants on non-whole-dollar amounts (e.g. 25% of $47.50 = $11.875) produce fractional cents in amount_finaid(). This makes amount_due() a non-integer number of cents, causing the Stripe payment flow to fail with an 'inconsistent_amount' error because int(amount_due * 100) disagrees with Decimal(amount_due) * 100. Round amount_finaid() using ROUND_HALF_UP per maintainer guidance so that all downstream accounting calculations resolve to whole cents. Add regression tests covering fractional cents and half-cent rounding. --- esp/esp/accounting/controllers.py | 12 ++++- esp/esp/accounting/tests/test_models.py | 71 ++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/esp/esp/accounting/controllers.py b/esp/esp/accounting/controllers.py index cbf0104f26..d82293ef2c 100644 --- a/esp/esp/accounting/controllers.py +++ b/esp/esp/accounting/controllers.py @@ -46,7 +46,7 @@ from django.db.models import Sum, Q from django.utils.text import slugify -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP class BaseAccountingController(object): @@ -623,7 +623,15 @@ def amount_finaid(self, amount_siblingdiscount=None): discount_aid_amount = (Decimal('0.01') * latest_grant.percent) * (amount_requested - amount_siblingdiscount - aid_amount) aid_amount += discount_aid_amount - return aid_amount + # Round to the nearest cent using ROUND_HALF_UP (.5 rounds up, per + # maintainer guidance). Percentage-based grants can produce fractional + # cents (e.g. 25% of $47.50 = $11.875), which makes amount_due() a + # non-integer number of cents. That causes int(amount_due * 100) in + # payonline() to disagree with Decimal(amount_due()) * 100 in + # charge_payment(), triggering a spurious "inconsistent_amount" error + # that prevents the student from paying online at all. + # See: https://github.com/learning-unlimited/ESP-Website/issues/6055 + return aid_amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) def amount_donation(self): lit = self.donation_lineitemtype() diff --git a/esp/esp/accounting/tests/test_models.py b/esp/esp/accounting/tests/test_models.py index 064a698590..7ba699131f 100644 --- a/esp/esp/accounting/tests/test_models.py +++ b/esp/esp/accounting/tests/test_models.py @@ -4,7 +4,7 @@ Tests LineItemType, LineItemOptions, Account, Transfer, and FinancialAidGrant models. """ -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP from django.contrib.auth.models import Group @@ -455,6 +455,74 @@ def test_amount_finaid_with_full_grant(self): finaid = self.iac.amount_finaid() self.assertEqual(finaid, Decimal('50.00')) + def test_amount_finaid_percentage_rounded_to_cents(self): + """Regression test for issue #6055. + + A percentage-based finaid grant on a non-whole-dollar amount must + produce a result rounded to the nearest cent (ROUND_HALF_UP), so + that amount_due() is always a whole number of cents. + + Exact scenario from the issue: 25% of $47.50 = $11.875. + Without rounding, amount_due() = $35.625, causing: + - payonline: int(35.625 * 100) = 3562 + - charge_payment: Decimal(35.625)*100 = 3562.5 + These never match → "inconsistent_amount" error → student can't pay. + + With ROUND_HALF_UP, finaid = $11.88, amount_due() = $35.62, and + int(35.62 * 100) == Decimal(35.62) * 100 == 3562. + """ + LineItemType.objects.filter( + program=self.program, text='Program admission' + ).update(amount_dec=Decimal('47.50')) + + self.iac.set_finaid_params(None, 25) # 25% discount, no dollar cap + finaid = self.iac.amount_finaid() + + # 25% of $47.50 = $11.875 → rounds up to $11.88 with ROUND_HALF_UP + self.assertEqual(finaid, Decimal('11.88')) + # amount_due must be a whole number of cents + due = self.iac.amount_due() + self.assertEqual(due, Decimal('35.62')) + # The two code paths for computing cents must agree + self.assertEqual(int(due * 100), int(Decimal(due) * 100)) + + def test_amount_finaid_half_cent_rounds_up(self): + """ROUND_HALF_UP: a half-cent aid amount rounds up, not to nearest even. + + Per Will Gearty's comment on issue #6055: '.5 or higher gets rounded + up; less than .5 gets rounded down.' + """ + # $47.50 at 25% = $11.875 — this is the exact half-cent boundary. + LineItemType.objects.filter( + program=self.program, text='Program admission' + ).update(amount_dec=Decimal('47.50')) + + self.iac.set_finaid_params(None, 25) + finaid = self.iac.amount_finaid() + # ROUND_HALF_UP: $11.875 → $11.88 (rounds up, not to $11.88 via ROUND_HALF_EVEN too, + # but importantly NOT $11.87 as a truncation or floor would give). + self.assertEqual(finaid, Decimal('11.88')) + + def test_amount_finaid_percentage_and_dollar_cap_rounded_to_cents(self): + """Regression test for issue #6055 with combined percent + dollar cap. + + When both a dollar-cap and a percentage are applied together, the + final amount_finaid() must still be rounded to whole cents. + """ + LineItemType.objects.filter( + program=self.program, text='Program admission' + ).update(amount_dec=Decimal('47.50')) + + # Dollar cap of $5.00 applied first, then 25% on remainder ($42.50) + # 25% of $42.50 = $10.625 → rounds up to $10.63 + # Total finaid = $5.00 + $10.63 = $15.63 + self.iac.set_finaid_params(5.00, 25) + finaid = self.iac.amount_finaid() + self.assertEqual(finaid, finaid.quantize(Decimal('0.01'))) + due = self.iac.amount_due() + self.assertEqual(due, due.quantize(Decimal('0.01'))) + self.assertEqual(int(due * 100), int(Decimal(due) * 100)) + def test_amount_due_with_full_finaid(self): self.iac.grant_full_financial_aid() self.assertEqual(self.iac.amount_due(), Decimal('0.00')) @@ -464,6 +532,7 @@ def test_amount_due_with_partial_finaid(self): due = self.iac.amount_due() self.assertEqual(due, Decimal('30.00')) + def test_amount_refunded_zero(self): """amount_refunded returns 0 when no refunds exist.""" self.assertEqual(self.iac.amount_refunded(), Decimal('0'))