Skip to content

Commit 78c0678

Browse files
committed
new over-refunded balance status
1 parent d146d07 commit 78c0678

File tree

6 files changed

+86
-66
lines changed

6 files changed

+86
-66
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@
88

99
---
1010

11+
## [2.5.5] - 2025-06-05
12+
13+
### Fixed
14+
15+
- Reverting some of the changes made in 2.5.2, which sought to eliminate discrepancies caused by small amounts of interest at the end of a schedule.
16+
The problem was misidentified as being due to interest/principal forgiveness whereas it was actually caused by unusual edge case of having over-refunded
17+
the customer. A solution has now been implemented by introducing a new balance status `OverRefunded` to describe precisely this scenario and prevent any
18+
interest being accrued from this point on in a schedule, as the resulting positive balance is not the fault of the customer.
19+
20+
---
21+
1122
## [2.5.4] - 2025-06-04
1223

1324
### Fixed

io/out/ActualPayment/ActualPaymentTestExtra008.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@
151151
<td class="ci07"><i>n/a</i></td>
152152
<td class="ci08">-2.85</td>
153153
<td class="ci09"><i>refunded</i></td>
154-
<td class="ci10">open</td>
154+
<td class="ci10">over-refunded</td>
155155
<td class="ci11">-0.1335</td>
156156
<td class="ci12">-0.1335</td>
157157
<td class="ci13">-0.17</td>
@@ -168,11 +168,11 @@
168168
<td class="ci04">19</td>
169169
<td class="ci05">0.00</td>
170170
<td class="ci06"><i>n/a</i></td>
171-
<td class="ci07">0.00</td>
172-
<td class="ci08">0.00</td>
171+
<td class="ci07">0.02</td>
172+
<td class="ci08">0.02</td>
173173
<td class="ci09"><i>generated</i></td>
174174
<td class="ci10">closed</td>
175-
<td class="ci11">0.0376</td>
175+
<td class="ci11">0.0000</td>
176176
<td class="ci12">0.0000</td>
177177
<td class="ci13">0.00</td>
178178
<td class="ci14">0.02</td>
@@ -184,7 +184,7 @@
184184
<h4>Final Stats</h4>
185185
<table>
186186
<tr>
187-
<td>Generated settlement: <i>0.00 on day 573</i></td>
187+
<td>Generated settlement: <i>0.02 on day 573</i></td>
188188
<td>Final balance status: <i>closed</i></td>
189189
</tr>
190190
<tr>

io/out/ActualPayment/ActualPaymentTestExtra009.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@
131131
<td class="ci07"><i>n/a</i></td>
132132
<td class="ci08">-57.05</td>
133133
<td class="ci09"><i>refunded</i></td>
134-
<td class="ci10">open</td>
134+
<td class="ci10">over-refunded</td>
135135
<td class="ci11">-3.1740</td>
136136
<td class="ci12">-3.1740</td>
137137
<td class="ci13">-3.18</td>
@@ -148,11 +148,11 @@
148148
<td class="ci04">21</td>
149149
<td class="ci05">0.00</td>
150150
<td class="ci06"><i>n/a</i></td>
151-
<td class="ci07">1.92</td>
152-
<td class="ci08">1.92</td>
151+
<td class="ci07">0.63</td>
152+
<td class="ci08">0.63</td>
153153
<td class="ci09"><i>generated</i></td>
154154
<td class="ci10">closed</td>
155-
<td class="ci11">1.2852</td>
155+
<td class="ci11">0.0000</td>
156156
<td class="ci12">0.0000</td>
157157
<td class="ci13">0.00</td>
158158
<td class="ci14">0.63</td>
@@ -164,7 +164,7 @@
164164
<h4>Final Stats</h4>
165165
<table>
166166
<tr>
167-
<td>Generated settlement: <i>1.92 on day 643</i></td>
167+
<td>Generated settlement: <i>0.63 on day 643</i></td>
168168
<td>Final balance status: <i>closed</i></td>
169169
</tr>
170170
<tr>

io/out/GeneratedDate.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<p>Generated: <i>2025-06-05 11:19:01 +01:00</i> using library version: <i>2.5.4</i></p>
1+
<p>Generated: <i>2025-06-05 14:28:24 +01:00</i> using library version: <i>2.5.4</i></p>

src/Amortisation.fs

Lines changed: 55 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,16 @@ module Amortisation =
3535
| OpenBalance
3636
/// due to an overpayment or a refund of charges, a refund is due
3737
| RefundDue
38+
/// a refund was made but this left a positive principal balance, meaning the customer has been over-refunded
39+
| OverRefunded
3840

3941
/// HTML formatting to display the balance status in a readable format
4042
member bs.Html =
4143
match bs with
4244
| ClosedBalance -> "closed"
4345
| OpenBalance -> "open"
4446
| RefundDue -> "refund due"
47+
| OverRefunded -> "over-refunded"
4548

4649
/// a breakdown of how an actual payment is apportioned to principal, fee, interest and charges
4750
type Apportionment = {
@@ -430,10 +433,15 @@ module Amortisation =
430433
decimal feeTotal / decimal principal |> Percent.fromDecimal
431434

432435
/// gets the balance status based on the principal balance
433-
let getBalanceStatus principalBalance =
434-
if principalBalance = 0L<Cent> then ClosedBalance
435-
elif principalBalance < 0L<Cent> then RefundDue
436-
else OpenBalance
436+
let getBalanceStatus principalBalance previousBalanceStatus =
437+
if principalBalance = 0L<Cent> then
438+
ClosedBalance
439+
elif principalBalance < 0L<Cent> then
440+
RefundDue
441+
elif principalBalance > 0L<Cent> && previousBalanceStatus = RefundDue then
442+
OverRefunded
443+
else
444+
OpenBalance
437445

438446
/// determines whether a schedule is settled within any grace period (e.g. no interest may be due if settlement is made within three days of the advance)
439447
let isSettledWithinGracePeriod (p: Parameters) =
@@ -500,8 +508,8 @@ module Amortisation =
500508

501509
/// determines any payment due on the day
502510
let calculatePaymentDue si originalPayment rescheduledPayment extraPaymentsBalance interestPortionL minimumPayment =
503-
// if the balance is closed or a refund is due, no payment is due
504-
if si.BalanceStatus = ClosedBalance || si.BalanceStatus = RefundDue then
511+
// if the balance is not open, no payment is due
512+
if si.BalanceStatus <> OpenBalance then
505513
0L<Cent>
506514
// otherwise, calculate the payment due based on scheduled payments and various balances
507515
else
@@ -606,7 +614,9 @@ module Amortisation =
606614
=
607615
let dailyInterestRates = getDailyInterestRates p previousDay currentDay
608616

609-
if previous.PrincipalBalance <= 0L<Cent> then
617+
if previous.BalanceStatus = OverRefunded then
618+
0m<Cent>
619+
elif previous.PrincipalBalance <= 0L<Cent> then
610620
dailyInterestRates
611621
|> Array.map (fun dr -> {
612622
dr with
@@ -629,10 +639,11 @@ module Amortisation =
629639
initialInterestBalanceM
630640
actuarialInterestM //this can be higher than the capped actuarial interest because it can include an adjustment that sucks in all the lost interest from rounding
631641
=
632-
match interestMethod with
633-
| Interest.Method.Actuarial -> actuarialInterestM
634-
| Interest.Method.AddOn when previousBalanceStatus = ClosedBalance -> 0m<Cent>
635-
| Interest.Method.AddOn ->
642+
match previousBalanceStatus, interestMethod with
643+
| _, Interest.Method.Actuarial -> actuarialInterestM
644+
| ClosedBalance, _
645+
| OverRefunded, _ -> 0m<Cent>
646+
| _ ->
636647
cumulativeActuarialInterestM + cappedActuarialInterestM
637648
|> fun i ->
638649
if i > initialInterestBalanceM then
@@ -642,20 +653,16 @@ module Amortisation =
642653
|> min cappedActuarialInterestM
643654

644655
/// ignores small amounts of interest that have accumulated by the last day of the schedule, with the allowance being proportional to the length of the schedule
645-
let calculateFinalInterestReduction currentDay maxAppliedPaymentDay appliedPaymentCount interestM =
646-
if currentDay = maxAppliedPaymentDay then
647-
let valueMinusForgiven =
648-
Interest.ignoreFractionalCents appliedPaymentCount interestM
649-
650-
{|
651-
Reduction = interestM - valueMinusForgiven |> max 0m<Cent>
652-
InterestForgiven = valueMinusForgiven = 0m<Cent>
653-
|}
656+
let calculateFinalInterestReduction
657+
(currentDay: int<OffsetDay>)
658+
maxAppliedPaymentDay
659+
appliedPaymentCount
660+
interestM
661+
=
662+
if interestM > 0m<Cent> && currentDay = maxAppliedPaymentDay then
663+
interestM - Interest.ignoreFractionalCents appliedPaymentCount interestM
654664
else
655-
{|
656-
Reduction = 0m<Cent>
657-
InterestForgiven = false
658-
|}
665+
0m<Cent>
659666

660667
/// calculates any new interest accrued since the previous item, according to the interest method supplied in the schedule parameters
661668
let calculateInterestAdjustment
@@ -666,14 +673,13 @@ module Amortisation =
666673
cumulativeActuarialInterestM
667674
initialInterestBalanceM
668675
(basicParameters: BasicParameters)
669-
interestForgiven
670676
=
671677
match basicParameters.InterestConfig.Method with
672-
| Interest.Method.AddOn when interestForgiven -> 0m<Cent>
673678
| Interest.Method.AddOn when
674679
previousBalanceStatus <> ClosedBalance
675680
&& (currentGeneratedPayment = ToBeGenerated || settlement <= 0L<Cent>)
676681
&& previousBalanceStatus <> RefundDue
682+
&& previousBalanceStatus <> OverRefunded
677683
&& cappedNewInterestM = 0m<Cent> // cappedNewInterest check here avoids adding an interest adjustment twice (one for generated payment, one for final payment)
678684
->
679685
cumulativeActuarialInterestM - initialInterestBalanceM
@@ -855,18 +861,18 @@ module Amortisation =
855861
initialInterestBalanceM
856862
actuarialInterestM
857863

858-
let cappedNewInterestM, finalInterestReductionM, interestForgiven =
864+
let cappedNewInterestM, finalInterestReductionM =
859865
let cni =
860866
Interest.Cap.cappedAddedValue
861867
p.Basic.InterestConfig.Cap.TotalAmount
862868
p.Basic.Principal
863869
totals.CumulativeInterest
864870
newInterestM
865871

866-
let sr =
872+
let fir =
867873
calculateFinalInterestReduction currentDay maxAppliedPaymentDay appliedPaymentCount cni
868874

869-
cni - sr.Reduction, sr.Reduction, sr.InterestForgiven
875+
cni - fir, fir
870876

871877
let finalInterestReductionL =
872878
finalInterestReductionM |> Cent.fromDecimalCent interestRounding
@@ -951,7 +957,6 @@ module Amortisation =
951957
totals'.CumulativeActuarialInterestM
952958
initialInterestBalanceM
953959
p.Basic
954-
interestForgiven
955960

956961
// refine the capped new interest value using any interest adjustment
957962
let cappedNewInterestM' = cappedNewInterestM + interestAdjustmentM
@@ -1060,7 +1065,7 @@ module Amortisation =
10601065
let offsetDate = p.Basic.StartDate.AddDays(int currentDay)
10611066

10621067
// determine the principal balance
1063-
let balanceStatus = getBalanceStatus principalBalance'
1068+
let balanceStatus = getBalanceStatus principalBalance' previous.BalanceStatus
10641069

10651070
// calculate the interest balance as a decimal
10661071
let interestBalanceM =
@@ -1074,17 +1079,15 @@ module Amortisation =
10741079
let createScheduleItem isSettlement =
10751080
// refine the payment status based on the balance status and whether this is a settlement
10761081
let paymentStatus =
1077-
match previous.BalanceStatus, isSettlement with
1078-
| ClosedBalance, _ when current.PaymentStatus.IsInformationOnly -> InformationOnly
1079-
| ClosedBalance, _ -> NoLongerRequired
1080-
| _, true -> Generated
1081-
| RefundDue, _ when netEffect' < 0L<Cent> -> Refunded
1082-
| RefundDue, _ when netEffect' > 0L<Cent> -> Overpayment
1083-
| RefundDue, _ when current.PaymentStatus.IsInformationOnly -> InformationOnly
1084-
| RefundDue, _ -> NoLongerRequired
1082+
match current.PaymentStatus, previous.BalanceStatus, isSettlement with
1083+
| InformationOnly, _, _ -> InformationOnly
1084+
| _, ClosedBalance, _ -> NoLongerRequired
1085+
| _, _, true -> Generated
1086+
| _, RefundDue, _ when netEffect' < 0L<Cent> -> Refunded
1087+
| _, RefundDue, _ when netEffect' > 0L<Cent> -> Overpayment
1088+
| _, RefundDue, _ -> NoLongerRequired
10851089
| _ when
1086-
current.PaymentStatus <> InformationOnly
1087-
&& paymentDue' = 0L<Cent>
1090+
paymentDue' = 0L<Cent>
10881091
&& madePaymentTotal = 0L<Cent>
10891092
&& pendingPaymentTotal = 0L<Cent>
10901093
&& GeneratedPayment.total current.GeneratedPayment = 0L<Cent>
@@ -1094,10 +1097,16 @@ module Amortisation =
10941097

10951098
// refine the settlement figure if necessary by subtracting any payment made on the same day, or nullifying it if there are payments pending (settlement cannot be made in this case)
10961099
let settlementFigure =
1097-
match pendingPaymentTotal, current.PaymentStatus, p.Basic.InterestConfig.Method with
1098-
| pp, _, _ when pp > 0L<Cent> -> 0L<Cent>
1099-
| _, NotYetDue, _
1100-
| _, _, Interest.Method.AddOn -> generatedSettlementPayment'
1100+
match
1101+
previous.BalanceStatus,
1102+
pendingPaymentTotal,
1103+
current.PaymentStatus,
1104+
p.Basic.InterestConfig.Method
1105+
with
1106+
| OverRefunded, _, _, _ -> previous.PrincipalBalance
1107+
| _, pp, _, _ when pp > 0L<Cent> -> 0L<Cent>
1108+
| _, _, NotYetDue, _
1109+
| _, _, _, Interest.Method.AddOn -> generatedSettlementPayment'
11011110
| _ -> generatedSettlementPayment' - netEffect'
11021111

11031112
// deteremine the settlement balances or carried balances
@@ -1336,7 +1345,7 @@ module Amortisation =
13361345
|> applyPayments p actualPayments
13371346
|> calculate p basicSchedule.Stats
13381347
|> if p.Advanced.TrimEnd then
1339-
Map.filter (fun _ si -> si.PaymentStatus <> NoLongerRequired || si.BalanceStatus = RefundDue)
1348+
Map.filter (fun _ si -> si.PaymentStatus <> NoLongerRequired)
13401349
else
13411350
id
13421351
|> calculateStats

tests/ActualPaymentTestsExtra.fs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -775,11 +775,11 @@ module ActualPaymentTestsExtra =
775775
Window = 19
776776
PaymentDue = 0L<Cent>
777777
ActualPayments = [||]
778-
GeneratedPayment = GeneratedValue 0L<Cent>
779-
NetEffect = 0L<Cent>
778+
GeneratedPayment = GeneratedValue 2L<Cent>
779+
NetEffect = 2L<Cent>
780780
PaymentStatus = Generated
781781
BalanceStatus = ClosedBalance
782-
ActuarialInterest = 3.76m<Cent>
782+
ActuarialInterest = 0m<Cent>
783783
NewInterest = 0m<Cent>
784784
NewCharges = [||]
785785
PrincipalPortion = 2L<Cent>
@@ -867,7 +867,7 @@ module ActualPaymentTestsExtra =
867867
schedules.AmortisationSchedule.ScheduleItems |> Map.maxKeyValue
868868

869869
let expected =
870-
573<OffsetDay>,
870+
643<OffsetDay>,
871871
{
872872
OffsetDayType = OffsetDayType.SettlementDay
873873
OffsetDate = Date(2025, 6, 2)
@@ -876,16 +876,16 @@ module ActualPaymentTestsExtra =
876876
Window = 21
877877
PaymentDue = 0L<Cent>
878878
ActualPayments = [||]
879-
GeneratedPayment = GeneratedValue 1_91L<Cent>
880-
NetEffect = 1_91L<Cent>
879+
GeneratedPayment = GeneratedValue 63L<Cent>
880+
NetEffect = 63L<Cent>
881881
PaymentStatus = Generated
882882
BalanceStatus = ClosedBalance
883-
ActuarialInterest = 1_28.52m<Cent>
884-
NewInterest = 1_28m<Cent>
883+
ActuarialInterest = 0m<Cent>
884+
NewInterest = 0m<Cent>
885885
NewCharges = [||]
886886
PrincipalPortion = 63L<Cent>
887887
FeePortion = 0L<Cent>
888-
InterestPortion = 1_28L<Cent>
888+
InterestPortion = 0L<Cent>
889889
ChargesPortion = 0L<Cent>
890890
FeeRebate = 0L<Cent>
891891
PrincipalBalance = 0L<Cent>

0 commit comments

Comments
 (0)