Skip to content

Commit

Permalink
multiple reschedulings*
Browse files Browse the repository at this point in the history
  • Loading branch information
simontreanor committed Oct 4, 2024
1 parent cd87340 commit bcdb705
Show file tree
Hide file tree
Showing 6 changed files with 301 additions and 266 deletions.
72 changes: 49 additions & 23 deletions src/PaymentSchedule.fs
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,39 @@ module PaymentSchedule =
member x.Html =
formatCent x.Value

/// a rescheduled payment, including the day on which the payment was created
[<Struct; StructuredFormatDisplay("{Html}")>]
type RescheduledPayment =
{
/// the original payment amount
Value: int64<Cent>
/// the day on which the rescheduled payment was created
RescheduleDay: int<OffsetDay>
}
member x.Html =
formatCent x.Value

/// any original or rescheduled payment, affecting how any payment due is calculated
[<StructuredFormatDisplay("{Html}")>]
type ScheduledPayment =
{
/// any original payment
Original: OriginalPayment voption
/// any rescheduled payment
/// the payment relating to the latest rescheduling, if any
/// > NB: if set to `ValueSome 0L<Cent>` this indicates that the original payment is no longer due
Rescheduled: int64<Cent> voption
Rescheduled: RescheduledPayment voption
/// any payments relating to previous reschedulings *sorted in creation order*, if any
PreviousRescheduled: RescheduledPayment array voption
/// any adjustment due to interest or charges being applied to the relevant payment rather than being amortised later
Adjustment: int64<Cent> voption
/// any reference numbers or other information pertaining to this payment
Metadata: Map<string, obj>
}
/// the total amount of the payment
member x.Total =
match x.Original, x.Rescheduled with
match x.Original, x.Rescheduled with
| _, ValueSome r ->
r
r.Value
| ValueSome o, ValueNone ->
o.Value
| ValueNone, ValueNone ->
Expand All @@ -63,6 +77,7 @@ module PaymentSchedule =
{
Original = ValueNone
Rescheduled = ValueNone
PreviousRescheduled = ValueNone
Adjustment = ValueNone
Metadata = Map.empty
}
Expand All @@ -74,13 +89,14 @@ module PaymentSchedule =
}
/// HTML formatting to display the scheduled payment in a concise way
member x.Html =
let previous = if x.PreviousRescheduled.IsSome then x.PreviousRescheduled.Value |> Array.map(fun pr -> $"<s>r {formatCent pr.Value}</s>&nbsp;") |> Array.reduce (+) else ""
match x.Original, x.Rescheduled with
| ValueSome o, ValueSome r ->
$"""<s>{formatCent o.Value}</s>&nbsp;{formatCent r}"""
$"<s>o {formatCent o.Value}</s>&nbsp;{previous}r {formatCent r.Value}"
| ValueSome o, ValueNone ->
$"original {formatCent o.Value}"
| ValueNone, ValueSome r ->
$"rescheduled {formatCent r}"
$"""{(if previous = "" then "rescheduled&nbsp;" else previous)}{formatCent r.Value}"""
| ValueNone, ValueNone ->
""
|> fun s ->
Expand Down Expand Up @@ -200,15 +216,15 @@ module PaymentSchedule =
type ScheduleType =
/// an original schedule
| Original
/// a schedule based on a previous one
| Rescheduled
/// a new schedule created after the original schedule, indicating the day it was created
| Rescheduled of RescheduleDay: int<OffsetDay>

/// a regular schedule based on a unit-period config with a specific number of payments of a specified amount
[<RequireQualifiedAccess; Struct>]
type FixedSchedule = {
UnitPeriodConfig: UnitPeriod.Config
PaymentCount: int
PaymentAmount: int64<Cent>
PaymentValue: int64<Cent>
ScheduleType: ScheduleType
}

Expand Down Expand Up @@ -380,11 +396,11 @@ module PaymentSchedule =
else
generatePaymentSchedule rfs.PaymentCount ValueNone Direction.Forward rfs.UnitPeriodConfig |> Array.map (OffsetDay.fromDate startDate)
|> Array.map(fun d ->
let originalAmount, rescheduledAmount =
let originalValue, rescheduledValue =
match rfs.ScheduleType with
| ScheduleType.Original -> ValueSome rfs.PaymentAmount, ValueNone
| ScheduleType.Rescheduled -> ValueNone, ValueSome rfs.PaymentAmount
d, ScheduledPayment.Quick originalAmount rescheduledAmount
| ScheduleType.Original -> ValueSome rfs.PaymentValue, ValueNone
| ScheduleType.Rescheduled rescheduleDay -> ValueNone, ValueSome { Value = rfs.PaymentValue; RescheduleDay = rescheduleDay }
d, ScheduledPayment.Quick originalValue rescheduledValue
)
)
|> Array.concat
Expand Down Expand Up @@ -438,7 +454,7 @@ module PaymentSchedule =

let mutable schedule = [||]

let toleranceSteps = ValueSome <| ToleranceSteps.forPaymentAmount paymentCount
let toleranceSteps = ValueSome <| ToleranceSteps.forPaymentValue paymentCount

let calculateInterest interestMethod payment previousItem day =
match interestMethod with
Expand Down Expand Up @@ -470,7 +486,7 @@ module PaymentSchedule =
TotalPrincipal = previousItem.TotalPrincipal + principalPortion
}

let generatePaymentAmount firstItem interestMethod roughPayment =
let generatePaymentValue firstItem interestMethod roughPayment =
let scheduledPayment =
roughPayment
|> Cent.round (ValueSome sp.Calculation.RoundingOptions.PaymentRounding)
Expand Down Expand Up @@ -513,7 +529,7 @@ module PaymentSchedule =
let solution =
match sp.ScheduleConfig with
| AutoGenerateSchedule _ ->
Array.solve (generatePaymentAmount initialItem sp.Interest.Method) 100 (totalAddOnInterest |> decimal |> calculateLevelPayment) toleranceOption toleranceSteps
Array.solve (generatePaymentValue initialItem sp.Interest.Method) 100 (totalAddOnInterest |> decimal |> calculateLevelPayment) toleranceOption toleranceSteps
| FixedSchedules _
| CustomSchedule _ ->
schedule <-
Expand Down Expand Up @@ -542,7 +558,7 @@ module PaymentSchedule =
|> ValueOption.map(fun p ->
{ p with
Original = if p.Rescheduled.IsNone then p.Original |> ValueOption.map(fun o -> { o with Value = o.Value + si.PrincipalBalance }) else p.Original
Rescheduled = if p.Rescheduled.IsSome then p.Rescheduled |> ValueOption.map(fun r -> r + si.PrincipalBalance) else p.Rescheduled
Rescheduled = if p.Rescheduled.IsSome then p.Rescheduled |> ValueOption.map(fun r -> { r with Value = r.Value + si.PrincipalBalance }) else p.Rescheduled
}
)
let adjustedPrincipal = si.PrincipalPortion + si.PrincipalBalance
Expand All @@ -562,7 +578,7 @@ module PaymentSchedule =
let aprSolution =
items
|> Array.filter _.ScheduledPayment.IsSome
|> Array.map(fun si -> { Apr.TransferType = Apr.Payment; Apr.TransferDate = sp.StartDate.AddDays(int si.Day); Apr.Amount = si.ScheduledPayment.Value.Total })
|> Array.map(fun si -> { Apr.TransferType = Apr.Payment; Apr.TransferDate = sp.StartDate.AddDays(int si.Day); Apr.Value = si.ScheduledPayment.Value.Total })
|> Apr.calculate sp.Calculation.AprMethod sp.Principal sp.StartDate
let finalPayment =
items
Expand All @@ -588,25 +604,35 @@ module PaymentSchedule =
| _ ->
ValueNone

let mergeScheduledPayments rescheduleDay (scheduledPayments: (int<OffsetDay> * ScheduledPayment) array) =
let mergeScheduledPayments (scheduledPayments: (int<OffsetDay> * ScheduledPayment) array) =
let mutable previousRescheduleDay = ValueOption<int<OffsetDay>>.None
scheduledPayments
|> Array.groupBy fst
|> Array.map(fun (offsetDay, scheduledPayments) -> // for each day, there will only ever be a maximum of one original and one rescheduled payment
|> Array.map(fun (offsetDay, scheduledPayments) ->
let original = scheduledPayments |> Array.tryFind (snd >> _.Original.IsSome) |> toValueOption |> ValueOption.map snd
let rescheduled = scheduledPayments |> Array.tryFind (snd >> _.Rescheduled.IsSome) |> toValueOption |> ValueOption.map snd
let rescheduled = scheduledPayments |> Array.filter (snd >> _.Rescheduled.IsSome) |> Array.map snd |> Array.sortByDescending _.Rescheduled.Value.RescheduleDay |> Array.toList
let latestRescheduling, previousReschedulings =
match rescheduled with
| r :: [] -> ValueSome r, ValueNone
| r :: pr -> ValueSome r, ValueSome pr
| _ -> ValueNone, ValueNone
let rescheduleDay = latestRescheduling |> ValueOption.map _.Rescheduled.Value.RescheduleDay |> ValueOption.orElse previousRescheduleDay
previousRescheduleDay <- rescheduleDay
let scheduledPayment =
match original, rescheduled with
match original, latestRescheduling with
| _, ValueSome r ->
{
Original = original |> ValueOption.bind _.Original
Rescheduled = r.Rescheduled
PreviousRescheduled = previousReschedulings |> ValueOption.map (List.rev >> List.map _.Rescheduled.Value >> List.toArray)
Adjustment = r.Adjustment
Metadata = r.Metadata
}
| ValueSome o, ValueNone ->
{
Original = o.Original
Rescheduled = if offsetDay >= rescheduleDay then ValueSome 0L<Cent> else ValueNone //overwrite original scheduled payments from start of rescheduled payments
Rescheduled = previousRescheduleDay |> ValueOption.bind(fun prd -> if offsetDay >= prd then ValueSome { Value = 0L<Cent>; RescheduleDay = prd } else ValueNone) //overwrite original scheduled payments from start of rescheduled payments
PreviousRescheduled = ValueNone
Adjustment = o.Adjustment
Metadata = o.Metadata
}
Expand Down
18 changes: 10 additions & 8 deletions src/Rescheduling.fs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ module Rescheduling =
/// the parameters used for setting up additional items for an existing schedule or new items for a new schedule
[<RequireQualifiedAccess>]
type RescheduleParameters = {
/// which day the reschedule takes effect: existing payments after this day will be cancelled or replaced by rescheduled payments
RescheduleDay: int<OffsetDay>
/// whether the fees should be pro-rated or due in full in the new schedule
FeeSettlementRefund: Fee.SettlementRefund
/// whether the plan is autogenerated or a manual plan provided
Expand Down Expand Up @@ -48,11 +46,15 @@ module Rescheduling =
|> Array.map(fun si -> si.Day, si.ScheduledPayment.Value)
| ValueNone ->
[||]
| FixedSchedules regularFixedSchedules ->
regularFixedSchedules
|> Array.map(fun rfs ->
UnitPeriod.generatePaymentSchedule rfs.PaymentCount ValueNone UnitPeriod.Direction.Forward rfs.UnitPeriodConfig
|> Array.map(fun d -> OffsetDay.fromDate sp.StartDate d, ScheduledPayment.Quick ValueNone (ValueSome rfs.PaymentAmount))
| FixedSchedules fixedSchedules ->
fixedSchedules
|> Array.map(fun fs ->
let scheduledPayment =
match fs.ScheduleType with
| ScheduleType.Original -> ScheduledPayment.Quick (ValueSome fs.PaymentValue) ValueNone
| ScheduleType.Rescheduled rescheduleDay -> ScheduledPayment.Quick ValueNone (ValueSome { Value = fs.PaymentValue; RescheduleDay = rescheduleDay })
UnitPeriod.generatePaymentSchedule fs.PaymentCount ValueNone UnitPeriod.Direction.Forward fs.UnitPeriodConfig
|> Array.map(fun d -> OffsetDay.fromDate sp.StartDate d, scheduledPayment)
)
|> Array.concat
| CustomSchedule payments ->
Expand All @@ -67,7 +69,7 @@ module Rescheduling =
// configure the parameters for the new schedule
let spNew =
{ sp with
ScheduleConfig = [| oldPaymentSchedule; newPaymentSchedule |] |> Array.concat |> mergeScheduledPayments rp.RescheduleDay |> CustomSchedule
ScheduleConfig = [| oldPaymentSchedule; newPaymentSchedule |] |> Array.concat |> mergeScheduledPayments |> CustomSchedule
FeeConfig.SettlementRefund = rp.FeeSettlementRefund
ChargeConfig.ChargeHolidays = rp.ChargeHolidays
Interest =
Expand Down
6 changes: 3 additions & 3 deletions tests/ActualPaymentTestsExtra.fs
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,10 @@ module ActualPaymentTestsExtra =
Map [
0<OffsetDay>, [| ActualPayment.QuickConfirmed 166_60L<Cent> |]
]
let rescheduleDay = sp.AsOfDate |> OffsetDay.fromDate sp.StartDate
let rp : RescheduleParameters = {
RescheduleDay = sp.AsOfDate |> OffsetDay.fromDate sp.StartDate
FeeSettlementRefund = Fee.SettlementRefund.ProRata (ValueSome originalFinalPaymentDay')
PaymentSchedule = FixedSchedules [| { UnitPeriodConfig = UnitPeriod.Config.Weekly(2, Date(2022, 9, 1)); PaymentCount = 155; PaymentAmount = 20_00L<Cent>; ScheduleType = ScheduleType.Rescheduled } |]
PaymentSchedule = FixedSchedules [| { UnitPeriodConfig = UnitPeriod.Config.Weekly(2, Date(2022, 9, 1)); PaymentCount = 155; PaymentValue = 20_00L<Cent>; ScheduleType = ScheduleType.Rescheduled rescheduleDay } |]
RateOnNegativeBalance = ValueNone
PromotionalInterestRates = [||]
ChargeHolidays = [||]
Expand All @@ -273,7 +273,7 @@ module ActualPaymentTestsExtra =
let expected = ValueSome (1969<OffsetDay>, {
OffsetDate = Date(2027, 7, 29)
Advances = [||]
ScheduledPayment = ScheduledPayment.Quick ValueNone (ValueSome 20_00L<Cent>)
ScheduledPayment = ScheduledPayment.Quick ValueNone (ValueSome { Value = 20_00L<Cent>; RescheduleDay = 176<OffsetDay> })
Window = 141
PaymentDue = 9_80L<Cent>
ActualPayments = [||]
Expand Down
10 changes: 6 additions & 4 deletions tests/EdgeCaseTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -635,11 +635,12 @@ module EdgeCaseTests =

let originalFinalPaymentDay = ((Date(2024, 5, 22) - Date(2024, 2, 2)).Days) * 1<OffsetDay>

let rescheduleDay = sp.AsOfDate |> OffsetDay.fromDate sp.StartDate

let (rp: RescheduleParameters) = {
RescheduleDay = sp.AsOfDate |> OffsetDay.fromDate sp.StartDate
FeeSettlementRefund = Fee.SettlementRefund.ProRata (ValueSome originalFinalPaymentDay)
PaymentSchedule = CustomSchedule <| Map [
58<OffsetDay>, ScheduledPayment.Quick ValueNone (ValueSome 5000L<Cent>)
58<OffsetDay>, ScheduledPayment.Quick ValueNone (ValueSome { Value = 5000L<Cent>; RescheduleDay = rescheduleDay })
]
RateOnNegativeBalance = ValueSome (Interest.Rate.Annual (Percent 8m))
PromotionalInterestRates = [||]
Expand Down Expand Up @@ -718,11 +719,12 @@ module EdgeCaseTests =

let originalFinalPaymentDay = ((Date(2024, 5, 22) - Date(2024, 2, 2)).Days) * 1<OffsetDay>

let rescheduleDay = sp.AsOfDate |> OffsetDay.fromDate sp.StartDate

let (rp: RescheduleParameters) = {
RescheduleDay = sp.AsOfDate |> OffsetDay.fromDate sp.StartDate
FeeSettlementRefund = Fee.SettlementRefund.ProRata (ValueSome originalFinalPaymentDay)
PaymentSchedule = CustomSchedule <| Map [
58<OffsetDay>, ScheduledPayment.Quick ValueNone (ValueSome 5000L<Cent>)
58<OffsetDay>, ScheduledPayment.Quick ValueNone (ValueSome { Value = 5000L<Cent>; RescheduleDay = rescheduleDay })
]
RateOnNegativeBalance = ValueSome (Interest.Rate.Annual (Percent 8m))
PromotionalInterestRates = [||]
Expand Down
Loading

0 comments on commit bcdb705

Please sign in to comment.