Skip to content
Open
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
12 changes: 10 additions & 2 deletions esp/esp/accounting/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
71 changes: 70 additions & 1 deletion esp/esp/accounting/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'))
Expand All @@ -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'))
Expand Down
Loading