From cb7b9cedba40b8c8dc15a8470a6584be1c513e8b Mon Sep 17 00:00:00 2001 From: lemunozm Date: Tue, 18 Jul 2023 12:58:57 +0200 Subject: [PATCH 1/3] redo multi-cashflows work --- Cargo.lock | 25 ++- pallets/loans/Cargo.toml | 3 + pallets/loans/src/entities/loans.rs | 53 +++++- pallets/loans/src/types/mod.rs | 268 ++++++++++++++++++++++++++- pallets/loans/src/types/policy.rs | 6 + pallets/loans/src/types/valuation.rs | 1 + 6 files changed, 345 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58caa7ccad..b64c229094 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,6 +299,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf688625d06217d5b1bb0ea9d9c44a1635fd0ee3534466388d18203174f4d11" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -1371,19 +1377,28 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.24" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" dependencies = [ + "android-tzdata", "iana-time-zone", "js-sys", - "num-integer", "num-traits", "time 0.1.45", "wasm-bindgen", "winapi", ] +[[package]] +name = "chronoutil" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "154aa5253c981d51e9466afc1e9ce41631197837fd1c41ee931008f229b8a3d7" +dependencies = [ + "chrono", +] + [[package]] name = "cid" version = "0.8.6" @@ -7574,6 +7589,8 @@ dependencies = [ "cfg-primitives", "cfg-traits", "cfg-types", + "chrono", + "chronoutil", "frame-benchmarking", "frame-support", "frame-system", @@ -14518,7 +14535,7 @@ checksum = "e7141e445af09c8919f1d5f8a20dae0b20c3b57a45dee0d5823c6ed5d237f15a" dependencies = [ "bitflags", "chrono", - "rustc_version 0.2.3", + "rustc_version 0.4.0", ] [[package]] diff --git a/pallets/loans/Cargo.toml b/pallets/loans/Cargo.toml index f8bd1d4297..2d1831dc3c 100644 --- a/pallets/loans/Cargo.toml +++ b/pallets/loans/Cargo.toml @@ -26,6 +26,8 @@ cfg-types = { path = "../../libs/types", default-features = false } orml-traits = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = false, branch = "polkadot-v0.9.38" } strum = { version = "0.24", default-features = false, features = ["derive"] } +chrono = { version = "0.4", default-features = false } +chronoutil = "0.2" # Optionals for benchmarking frame-benchmarking = { git = "https://github.com/paritytech/substrate", default-features = false, optional = true, branch = "polkadot-v0.9.38" } @@ -63,6 +65,7 @@ std = [ "sp-io/std", "strum/std", "orml-traits/std", + "chrono/std", ] runtime-benchmarks = [ "frame-benchmarking/runtime-benchmarks", diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index d617fc9b12..199c68edbb 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -211,6 +211,12 @@ impl ActiveLoan { &self.pricing } + pub fn principal(&self) -> Result { + Ok(self + .total_borrowed + .ensure_sub(self.total_repaid.principal)?) + } + pub fn write_off_status(&self) -> WriteOffStatus { WriteOffStatus { percentage: self.write_off_percentage, @@ -241,6 +247,35 @@ impl ActiveLoan { } ActivePricing::Internal(_) => Ok(false), }, + WriteOffTrigger::InterestOverdue(_overdue_seconds) => match &self.pricing { + // TODO: should be implemented once interest_rate is moved to ActiveLoan + ActivePricing::External(_) => Ok(false), + ActivePricing::Internal(pricing) => { + let cashflows = self.schedule.generate_expected_cashflows( + self.origination_date, + self.principal()?, + &pricing.interest.rate(), + )?; + + // TODO(Luis): If from this point, any field of ActiveLoan is needed, I would + // move this code into cashflow.rs + + /* + let now_date = NaiveDateTime::from_timestamp_opt(now as i64, 0) + .ok_or(DispatchError::Other("Invalid now date"))?; + let cashflows_in_past = cashflows + .iter() + .filter(|(d, _)| d.and_hms_opt(0, 0, 0).unwrap() > now_date); + */ + + // TODO: should find the first cashflow that was not paid, + // based on total_repaid_interest and by reducing the cashflows_in_past + // from this, and should the ncompare the first cash flow that was not paid date + // with now - overdue_secs. + + Ok(false) + } + }, } } @@ -313,6 +348,19 @@ impl ActiveLoan { Error::::from(BorrowLoanError::MaturityDatePassed) ); + // If the loan has an interest or pay down schedule other than None, + // then we should only allow borrowing more if no interest or principal + // payments are overdue. + // + // This is required because after borrowing more, it is not possible + // to validate anymore whether previous cashflows matched the repayment + // schedule, as we don't store historic data of the principal. + // + // Therefore, in `borrow()` we set repayments_on_schedule_until to now. + // + // TODO: check total_repaid_interest >= total_expected_interest + // and total_repaid_principal >= total_expected_principal + Ok(()) } @@ -347,10 +395,7 @@ impl ActiveLoan { let (interest_accrued, max_repay_principal) = match &self.pricing { ActivePricing::Internal(inner) => { amount.principal.internal()?; - - let principal = self - .total_borrowed - .ensure_sub(self.total_repaid.principal)?; + let principal = self.principal()?; (inner.current_interest(principal)?, principal) } diff --git a/pallets/loans/src/types/mod.rs b/pallets/loans/src/types/mod.rs index eb0e1a4741..6386ae880e 100644 --- a/pallets/loans/src/types/mod.rs +++ b/pallets/loans/src/types/mod.rs @@ -13,14 +13,19 @@ //! Contains base types without Config references -use cfg_primitives::Moment; +use cfg_primitives::{Moment, SECONDS_PER_YEAR}; use cfg_traits::interest::InterestRate; +use chrono::prelude::*; +use chronoutil::DateRule; use codec::{Decode, Encode, MaxEncodedLen}; use frame_support::{storage::bounded_vec::BoundedVec, PalletError, RuntimeDebug}; use scale_info::TypeInfo; use sp_runtime::{ - traits::{EnsureAdd, EnsureAddAssign, EnsureSubAssign, Get}, - ArithmeticError, + traits::{ + EnsureAdd, EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureSub, EnsureSubAssign, + Get, + }, + ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, }; pub mod policy; @@ -129,11 +134,32 @@ impl Maturity { } } +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub enum ReferenceDate { + /// Payments are expected every period relative to a specific date. + /// E.g. if the period is monthly and the specific date is Mar 3, the + /// first interest payment is expected on Apr 3. + Date(Moment), + + /// At the end of the period, e.g. the last day of the month for a monthly + /// period + End, +} + /// Interest payment periods #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] pub enum InterestPayments { /// All interest is expected to be paid at the maturity date None, + + /// Interest is expected to be paid monthly + Monthly(ReferenceDate), + + /// Interest is expected to be paid twice per year + SemiAnnually(ReferenceDate), + + /// Interest is expected to be paid once per year + Annually(ReferenceDate), } /// Specify the paydown schedules of the loan @@ -162,6 +188,149 @@ impl RepaymentSchedule { pub fn is_valid(&self, now: Moment) -> bool { self.maturity.is_valid(now) } + + // TODO: this should be exposed through a runtime API by (pool_id, loan_id) + pub fn generate_expected_cashflows( + &self, + origination_date: Moment, + principal: Balance, + interest_rate: &InterestRate, + ) -> Result, DispatchError> + where + Balance: FixedPointOperand, + Rate: FixedPointNumber, + { + match self.maturity { + Maturity::Fixed { date, .. } => { + let start = NaiveDateTime::from_timestamp_opt(origination_date as i64, 0) + .ok_or(DispatchError::Other("Invalid origination date"))? + .date(); + + let end = NaiveDateTime::from_timestamp_opt(date as i64, 0) + .ok_or(DispatchError::Other("Invalid maturity date"))? + .date(); + + match &self.interest_payments { + InterestPayments::None => Ok(vec![]), + InterestPayments::Monthly(reference_date) => Self::add_interest_amounts( + Self::get_cashflow_list::( + start, + end, + reference_date.clone(), + )?, + principal, + &self.interest_payments, + interest_rate, + origination_date, + date, + ), + InterestPayments::SemiAnnually(reference_date) => Ok(vec![]), + InterestPayments::Annually(reference_date) => Ok(vec![]), + } + } + } + } + + fn get_cashflow_list( + start: NaiveDate, + end: NaiveDate, + reference_date: ReferenceDate, + ) -> Result, DispatchError> + where + Balance: FixedPointOperand, + Rate: FixedPointNumber, + { + // TODO: once we implement a pay_down_schedule other than `None`, + // we will need to adjust the expected interest amounts based on the + // expected outstanding principal at the time of the interest payment + Ok(match reference_date { + ReferenceDate::Date(reference) => { + let reference_date = NaiveDateTime::from_timestamp_opt(reference as i64, 0) + .ok_or(DispatchError::Other("Invalid origination date"))? + .date(); + + DateRule::monthly(start) + .with_end(end) + .with_rolling_day(reference_date.day()) + .unwrap() + .into_iter() + // There's no interest payment expected on the origination date + .skip(1) + .collect() + } + ReferenceDate::End => DateRule::monthly(start) + .with_end(end) + .with_rolling_day(31) + .unwrap() + .into_iter() + .collect(), + }) + } + + fn add_interest_amounts( + cashflows: Vec, + principal: Balance, + interest_payments: &InterestPayments, + interest_rate: &InterestRate, + origination_date: Moment, + maturity_date: Moment, + ) -> Result, DispatchError> + where + Balance: FixedPointOperand, + Rate: FixedPointNumber, + { + let cashflows_len = cashflows.len(); + cashflows + .into_iter() + .enumerate() + .map(|(i, d)| -> Result<(NaiveDate, Balance), DispatchError> { + let interest_rate_per_sec = interest_rate.per_sec()?; + let amount_per_sec = interest_rate_per_sec.ensure_mul_int(principal)?; + + if i == 0 { + // First cashflow: cashflow date - origination date * interest per day + let dt = d.and_hms_opt(0, 0, 0).ok_or("")?.timestamp() as u64; + return Ok(( + d, + Rate::saturating_from_rational( + dt.ensure_sub(origination_date)?, + SECONDS_PER_YEAR, + ) + .ensure_mul_int(amount_per_sec)?, + )); + } + + if i == cashflows_len { + // Last cashflow: maturity date - cashflow date * interest per day + let dt = d.and_hms_opt(0, 0, 0).ok_or("")?.timestamp() as u64; + return Ok(( + d, + Rate::saturating_from_rational( + maturity_date.ensure_sub(dt)?, + SECONDS_PER_YEAR, + ) + .ensure_mul_int(amount_per_sec)?, + )); + } + + // Inbetween cashflows: interest per year / number of periods per year (e.g. + // divided by 12 for monthly interest payments) + let periods_per_year = match interest_payments { + InterestPayments::None => 0, + InterestPayments::Monthly(_) => 12, + InterestPayments::SemiAnnually(_) => 2, + InterestPayments::Annually(_) => 1, + }; + + let interest_rate_per_period = interest_rate + .per_year() + .ensure_div(Rate::saturating_from_integer(periods_per_year))?; + let amount_per_period = interest_rate_per_period.ensure_mul_int(principal)?; + + return Ok((d, amount_per_period)); + }) + .collect() + } } /// Specify how offer a loan can be borrowed @@ -245,3 +414,96 @@ impl RepaidAmount { self.unscheduled.ensure_add_assign(other.unscheduled) } } + +#[cfg(test)] +mod tests { + use cfg_traits::interest::CompoundingSchedule; + use frame_support::assert_ok; + + use super::*; + + pub type Rate = sp_arithmetic::fixed_point::FixedU128; + + fn from_ymd(year: i32, month: u32, day: u32) -> Moment { + NaiveDate::from_ymd_opt(year, month, day) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .timestamp() as u64 + } + + #[test] + fn cashflow_generation_works() { + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(from_ymd(2022, 12, 1)), + interest_payments: InterestPayments::Monthly(ReferenceDate::End), + pay_down_schedule: PayDownSchedule::None + } + .generate_expected_cashflows( + from_ymd(2022, 6, 1), + 1000, + &InterestRate::Fixed { + rate_per_year: Rate::from_float(0.1), + compounding: CompoundingSchedule::Secondly + } + ), + vec![ + (NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 7, 31).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 8, 31).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 9, 30).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 10, 31).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 11, 30).unwrap(), 8u128.into()) + ] + ); + + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(from_ymd(2022, 12, 2)), + interest_payments: InterestPayments::Monthly(ReferenceDate::Date(from_ymd( + 2022, 6, 2 + ))), + pay_down_schedule: PayDownSchedule::None + } + .generate_expected_cashflows( + from_ymd(2022, 6, 2), + 1000, + &InterestRate::Fixed { + rate_per_year: Rate::from_float(0.25), + compounding: CompoundingSchedule::Secondly + } + ), + vec![ + (NaiveDate::from_ymd_opt(2022, 7, 2).unwrap(), 20u128.into()), + (NaiveDate::from_ymd_opt(2022, 8, 2).unwrap(), 20u128.into()), + (NaiveDate::from_ymd_opt(2022, 9, 2).unwrap(), 20u128.into()), + (NaiveDate::from_ymd_opt(2022, 10, 2).unwrap(), 20u128.into()), + (NaiveDate::from_ymd_opt(2022, 11, 2).unwrap(), 20u128.into()) + ] + ); + + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(from_ymd(2023, 6, 1)), + interest_payments: InterestPayments::SemiAnnually(ReferenceDate::End), + pay_down_schedule: PayDownSchedule::None + } + .generate_expected_cashflows( + from_ymd(2022, 6, 1), + 1000, + &InterestRate::Fixed { + rate_per_year: Rate::from_float(0.1), + compounding: CompoundingSchedule::Secondly + } + ), + vec![ + (NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(), 48u128.into()), + ( + NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(), + 48u128.into() + ), + ] + ); + } +} diff --git a/pallets/loans/src/types/policy.rs b/pallets/loans/src/types/policy.rs index 974c5a2009..4bc17a7d65 100644 --- a/pallets/loans/src/types/policy.rs +++ b/pallets/loans/src/types/policy.rs @@ -42,6 +42,9 @@ pub enum WriteOffTrigger { /// Seconds since the oracle valuation was last updated PriceOutdated(Moment), + + /// Seconds that an interest payment is overdue + InterestOverdue(Moment), } /// Wrapper type to identify equality berween kinds of triggers, @@ -58,6 +61,9 @@ impl PartialEq for UniqueWriteOffTrigger { WriteOffTrigger::PriceOutdated(_) => { matches!(other.0, WriteOffTrigger::PriceOutdated(_)) } + WriteOffTrigger::InterestOverdue(_) => { + matches!(other.0, WriteOffTrigger::InterestOverdue(_)) + } } } } diff --git a/pallets/loans/src/types/valuation.rs b/pallets/loans/src/types/valuation.rs index a6f3d258cf..80ce1c97af 100644 --- a/pallets/loans/src/types/valuation.rs +++ b/pallets/loans/src/types/valuation.rs @@ -52,6 +52,7 @@ impl DiscountedCashFlow { }) } + // TODO: this should account for cashflows from interest payments pub fn compute_present_value( &self, debt: Balance, From 24b7c2903f863be73f3f050cd78700e694303611 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Tue, 18 Jul 2023 13:28:24 +0200 Subject: [PATCH 2/3] cashflow module --- pallets/loans/Cargo.toml | 2 +- pallets/loans/src/benchmarking.rs | 4 +- pallets/loans/src/entities/loans.rs | 4 +- pallets/loans/src/tests/mod.rs | 6 +- pallets/loans/src/types/cashflow.rs | 355 +++++++++++++++++++ pallets/loans/src/types/mod.rs | 344 +----------------- runtime/integration-tests/src/utils/loans.rs | 4 +- 7 files changed, 370 insertions(+), 349 deletions(-) create mode 100644 pallets/loans/src/types/cashflow.rs diff --git a/pallets/loans/Cargo.toml b/pallets/loans/Cargo.toml index 2d1831dc3c..8691d994b3 100644 --- a/pallets/loans/Cargo.toml +++ b/pallets/loans/Cargo.toml @@ -25,9 +25,9 @@ cfg-traits = { path = "../../libs/traits", default-features = false } cfg-types = { path = "../../libs/types", default-features = false } orml-traits = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = false, branch = "polkadot-v0.9.38" } -strum = { version = "0.24", default-features = false, features = ["derive"] } chrono = { version = "0.4", default-features = false } chronoutil = "0.2" +strum = { version = "0.24", default-features = false, features = ["derive"] } # Optionals for benchmarking frame-benchmarking = { git = "https://github.com/paritytech/substrate", default-features = false, optional = true, branch = "polkadot-v0.9.38" } diff --git a/pallets/loans/src/benchmarking.rs b/pallets/loans/src/benchmarking.rs index f1a82b71bf..f83e69bbff 100644 --- a/pallets/loans/src/benchmarking.rs +++ b/pallets/loans/src/benchmarking.rs @@ -46,10 +46,10 @@ use crate::{ }, pallet::*, types::{ + cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, policy::{WriteOffRule, WriteOffTrigger}, valuation::{DiscountedCashFlow, ValuationMethod}, - BorrowRestrictions, InterestPayments, LoanMutation, LoanRestrictions, Maturity, - PayDownSchedule, RepayRestrictions, RepaymentSchedule, + BorrowRestrictions, LoanMutation, LoanRestrictions, RepayRestrictions, }, }; diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 199c68edbb..6084a4b570 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -24,10 +24,10 @@ use super::pricing::{ use crate::{ pallet::{AssetOf, Config, Error, PriceOf}, types::{ + cashflow::RepaymentSchedule, policy::{WriteOffStatus, WriteOffTrigger}, BorrowLoanError, BorrowRestrictions, CloseLoanError, CreateLoanError, LoanMutation, LoanRestrictions, MutationError, RepaidAmount, RepayLoanError, RepayRestrictions, - RepaymentSchedule, }, }; @@ -531,7 +531,7 @@ impl ActiveLoan { #[cfg(feature = "runtime-benchmarks")] pub fn set_maturity(&mut self, duration: Moment) { - self.schedule.maturity = crate::types::Maturity::fixed(duration); + self.schedule.maturity = crate::types::cashflow::Maturity::fixed(duration); } } diff --git a/pallets/loans/src/tests/mod.rs b/pallets/loans/src/tests/mod.rs index d5d84b544d..aefde1d172 100644 --- a/pallets/loans/src/tests/mod.rs +++ b/pallets/loans/src/tests/mod.rs @@ -18,12 +18,12 @@ use super::{ }, pallet::{ActiveLoans, Error, LastLoanId, PortfolioValuation}, types::{ + cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, policy::{WriteOffRule, WriteOffStatus, WriteOffTrigger}, valuation::{DiscountedCashFlow, ValuationMethod}, BorrowLoanError, BorrowRestrictions, Change, CloseLoanError, CreateLoanError, - InterestPayments, InternalMutation, LoanMutation, LoanRestrictions, Maturity, - MutationError, PayDownSchedule, RepayLoanError, RepayRestrictions, RepaymentSchedule, - WrittenOffError, + InternalMutation, LoanMutation, LoanRestrictions, MutationError, RepayLoanError, + RepayRestrictions, WrittenOffError, }, }; diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs new file mode 100644 index 0000000000..808fc1fe52 --- /dev/null +++ b/pallets/loans/src/types/cashflow.rs @@ -0,0 +1,355 @@ +// Copyright 2023 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge chain project. + +// Centrifuge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version (see http://www.gnu.org/licenses). + +// Centrifuge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use cfg_primitives::{Moment, SECONDS_PER_YEAR}; +use cfg_traits::interest::InterestRate; +use chrono::{Datelike, NaiveDate, NaiveDateTime}; +use chronoutil::DateRule; +use codec::{Decode, Encode, MaxEncodedLen}; +use frame_support::RuntimeDebug; +use scale_info::TypeInfo; +use sp_runtime::{ + traits::{EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureSub, EnsureSubAssign}, + ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, +}; + +/// Specify the expected repayments date +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub enum Maturity { + /// Fixed point in time, in secs + Fixed { + /// Secs when maturity ends + date: Moment, + /// Extension in secs, without special permissions + extension: Moment, + }, +} + +impl Maturity { + pub fn fixed(date: Moment) -> Self { + Self::Fixed { date, extension: 0 } + } + + pub fn date(&self) -> Moment { + match self { + Maturity::Fixed { date, .. } => *date, + } + } + + pub fn is_valid(&self, now: Moment) -> bool { + match self { + Maturity::Fixed { date, .. } => *date > now, + } + } + + pub fn extends(&mut self, value: Moment) -> Result<(), ArithmeticError> { + match self { + Maturity::Fixed { date, extension } => { + date.ensure_add_assign(value)?; + extension.ensure_sub_assign(value) + } + } + } +} + +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub enum ReferenceDate { + /// Payments are expected every period relative to a specific date. + /// E.g. if the period is monthly and the specific date is Mar 3, the + /// first interest payment is expected on Apr 3. + Date(Moment), + + /// At the end of the period, e.g. the last day of the month for a monthly + /// period + End, +} + +/// Interest payment periods +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub enum InterestPayments { + /// All interest is expected to be paid at the maturity date + None, + + /// Interest is expected to be paid monthly + Monthly(ReferenceDate), + + /// Interest is expected to be paid twice per year + SemiAnnually(ReferenceDate), + + /// Interest is expected to be paid once per year + Annually(ReferenceDate), +} + +/// Specify the paydown schedules of the loan +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub enum PayDownSchedule { + /// The entire borrowed amount is expected to be paid back at the maturity + /// date + None, +} + +/// Specify the repayment schedule of the loan +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub struct RepaymentSchedule { + /// Expected repayments date for remaining debt + pub maturity: Maturity, + + /// Period at which interest is paid + pub interest_payments: InterestPayments, + + /// How much of the initially borrowed amount is paid back during interest + /// payments + pub pay_down_schedule: PayDownSchedule, +} + +impl RepaymentSchedule { + pub fn is_valid(&self, now: Moment) -> bool { + self.maturity.is_valid(now) + } + + // TODO: this should be exposed through a runtime API by (pool_id, loan_id) + pub fn generate_expected_cashflows( + &self, + origination_date: Moment, + principal: Balance, + interest_rate: &InterestRate, + ) -> Result, DispatchError> + where + Balance: FixedPointOperand, + Rate: FixedPointNumber, + { + match self.maturity { + Maturity::Fixed { date, .. } => { + let start = NaiveDateTime::from_timestamp_opt(origination_date as i64, 0) + .ok_or(DispatchError::Other("Invalid origination date"))? + .date(); + + let end = NaiveDateTime::from_timestamp_opt(date as i64, 0) + .ok_or(DispatchError::Other("Invalid maturity date"))? + .date(); + + match &self.interest_payments { + InterestPayments::None => Ok(vec![]), + InterestPayments::Monthly(reference_date) => Self::add_interest_amounts( + Self::get_cashflow_list::( + start, + end, + reference_date.clone(), + )?, + principal, + &self.interest_payments, + interest_rate, + origination_date, + date, + ), + InterestPayments::SemiAnnually(reference_date) => Ok(vec![]), + InterestPayments::Annually(reference_date) => Ok(vec![]), + } + } + } + } + + fn get_cashflow_list( + start: NaiveDate, + end: NaiveDate, + reference_date: ReferenceDate, + ) -> Result, DispatchError> + where + Balance: FixedPointOperand, + Rate: FixedPointNumber, + { + // TODO: once we implement a pay_down_schedule other than `None`, + // we will need to adjust the expected interest amounts based on the + // expected outstanding principal at the time of the interest payment + Ok(match reference_date { + ReferenceDate::Date(reference) => { + let reference_date = NaiveDateTime::from_timestamp_opt(reference as i64, 0) + .ok_or(DispatchError::Other("Invalid origination date"))? + .date(); + + DateRule::monthly(start) + .with_end(end) + .with_rolling_day(reference_date.day()) + .unwrap() + .into_iter() + // There's no interest payment expected on the origination date + .skip(1) + .collect() + } + ReferenceDate::End => DateRule::monthly(start) + .with_end(end) + .with_rolling_day(31) + .unwrap() + .into_iter() + .collect(), + }) + } + + fn add_interest_amounts( + cashflows: Vec, + principal: Balance, + interest_payments: &InterestPayments, + interest_rate: &InterestRate, + origination_date: Moment, + maturity_date: Moment, + ) -> Result, DispatchError> + where + Balance: FixedPointOperand, + Rate: FixedPointNumber, + { + let cashflows_len = cashflows.len(); + cashflows + .into_iter() + .enumerate() + .map(|(i, d)| -> Result<(NaiveDate, Balance), DispatchError> { + let interest_rate_per_sec = interest_rate.per_sec()?; + let amount_per_sec = interest_rate_per_sec.ensure_mul_int(principal)?; + + if i == 0 { + // First cashflow: cashflow date - origination date * interest per day + let dt = d.and_hms_opt(0, 0, 0).ok_or("")?.timestamp() as u64; + return Ok(( + d, + Rate::saturating_from_rational( + dt.ensure_sub(origination_date)?, + SECONDS_PER_YEAR, + ) + .ensure_mul_int(amount_per_sec)?, + )); + } + + if i == cashflows_len { + // Last cashflow: maturity date - cashflow date * interest per day + let dt = d.and_hms_opt(0, 0, 0).ok_or("")?.timestamp() as u64; + return Ok(( + d, + Rate::saturating_from_rational( + maturity_date.ensure_sub(dt)?, + SECONDS_PER_YEAR, + ) + .ensure_mul_int(amount_per_sec)?, + )); + } + + // Inbetween cashflows: interest per year / number of periods per year (e.g. + // divided by 12 for monthly interest payments) + let periods_per_year = match interest_payments { + InterestPayments::None => 0, + InterestPayments::Monthly(_) => 12, + InterestPayments::SemiAnnually(_) => 2, + InterestPayments::Annually(_) => 1, + }; + + let interest_rate_per_period = interest_rate + .per_year() + .ensure_div(Rate::saturating_from_integer(periods_per_year))?; + let amount_per_period = interest_rate_per_period.ensure_mul_int(principal)?; + + return Ok((d, amount_per_period)); + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use cfg_traits::interest::CompoundingSchedule; + use frame_support::assert_ok; + + use super::*; + + pub type Rate = sp_arithmetic::fixed_point::FixedU128; + + fn from_ymd(year: i32, month: u32, day: u32) -> Moment { + NaiveDate::from_ymd_opt(year, month, day) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .timestamp() as u64 + } + + #[test] + fn cashflow_generation_works() { + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(from_ymd(2022, 12, 1)), + interest_payments: InterestPayments::Monthly(ReferenceDate::End), + pay_down_schedule: PayDownSchedule::None + } + .generate_expected_cashflows( + from_ymd(2022, 6, 1), + 1000, + &InterestRate::Fixed { + rate_per_year: Rate::from_float(0.1), + compounding: CompoundingSchedule::Secondly + } + ), + vec![ + (NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 7, 31).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 8, 31).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 9, 30).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 10, 31).unwrap(), 8u128.into()), + (NaiveDate::from_ymd_opt(2022, 11, 30).unwrap(), 8u128.into()) + ] + ); + + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(from_ymd(2022, 12, 2)), + interest_payments: InterestPayments::Monthly(ReferenceDate::Date(from_ymd( + 2022, 6, 2 + ))), + pay_down_schedule: PayDownSchedule::None + } + .generate_expected_cashflows( + from_ymd(2022, 6, 2), + 1000, + &InterestRate::Fixed { + rate_per_year: Rate::from_float(0.25), + compounding: CompoundingSchedule::Secondly + } + ), + vec![ + (NaiveDate::from_ymd_opt(2022, 7, 2).unwrap(), 20u128.into()), + (NaiveDate::from_ymd_opt(2022, 8, 2).unwrap(), 20u128.into()), + (NaiveDate::from_ymd_opt(2022, 9, 2).unwrap(), 20u128.into()), + (NaiveDate::from_ymd_opt(2022, 10, 2).unwrap(), 20u128.into()), + (NaiveDate::from_ymd_opt(2022, 11, 2).unwrap(), 20u128.into()) + ] + ); + + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(from_ymd(2023, 6, 1)), + interest_payments: InterestPayments::SemiAnnually(ReferenceDate::End), + pay_down_schedule: PayDownSchedule::None + } + .generate_expected_cashflows( + from_ymd(2022, 6, 1), + 1000, + &InterestRate::Fixed { + rate_per_year: Rate::from_float(0.1), + compounding: CompoundingSchedule::Secondly + } + ), + vec![ + (NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(), 48u128.into()), + ( + NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(), + 48u128.into() + ), + ] + ); + } +} diff --git a/pallets/loans/src/types/mod.rs b/pallets/loans/src/types/mod.rs index 6386ae880e..6e8302df03 100644 --- a/pallets/loans/src/types/mod.rs +++ b/pallets/loans/src/types/mod.rs @@ -13,25 +13,22 @@ //! Contains base types without Config references -use cfg_primitives::{Moment, SECONDS_PER_YEAR}; +use cfg_primitives::Moment; use cfg_traits::interest::InterestRate; -use chrono::prelude::*; -use chronoutil::DateRule; use codec::{Decode, Encode, MaxEncodedLen}; use frame_support::{storage::bounded_vec::BoundedVec, PalletError, RuntimeDebug}; use scale_info::TypeInfo; use sp_runtime::{ - traits::{ - EnsureAdd, EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureSub, EnsureSubAssign, - Get, - }, - ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, + traits::{EnsureAdd, Get}, + ArithmeticError, }; +pub mod cashflow; pub mod policy; pub mod portfolio; pub mod valuation; +use cashflow::{InterestPayments, Maturity, PayDownSchedule}; use policy::WriteOffRule; use valuation::ValuationMethod; @@ -95,244 +92,6 @@ pub enum MutationError { MaturityExtendedTooMuch, } -/// Specify the expected repayments date -#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] -pub enum Maturity { - /// Fixed point in time, in secs - Fixed { - /// Secs when maturity ends - date: Moment, - /// Extension in secs, without special permissions - extension: Moment, - }, -} - -impl Maturity { - pub fn fixed(date: Moment) -> Self { - Self::Fixed { date, extension: 0 } - } - - pub fn date(&self) -> Moment { - match self { - Maturity::Fixed { date, .. } => *date, - } - } - - pub fn is_valid(&self, now: Moment) -> bool { - match self { - Maturity::Fixed { date, .. } => *date > now, - } - } - - pub fn extends(&mut self, value: Moment) -> Result<(), ArithmeticError> { - match self { - Maturity::Fixed { date, extension } => { - date.ensure_add_assign(value)?; - extension.ensure_sub_assign(value) - } - } - } -} - -#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] -pub enum ReferenceDate { - /// Payments are expected every period relative to a specific date. - /// E.g. if the period is monthly and the specific date is Mar 3, the - /// first interest payment is expected on Apr 3. - Date(Moment), - - /// At the end of the period, e.g. the last day of the month for a monthly - /// period - End, -} - -/// Interest payment periods -#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] -pub enum InterestPayments { - /// All interest is expected to be paid at the maturity date - None, - - /// Interest is expected to be paid monthly - Monthly(ReferenceDate), - - /// Interest is expected to be paid twice per year - SemiAnnually(ReferenceDate), - - /// Interest is expected to be paid once per year - Annually(ReferenceDate), -} - -/// Specify the paydown schedules of the loan -#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] -pub enum PayDownSchedule { - /// The entire borrowed amount is expected to be paid back at the maturity - /// date - None, -} - -/// Specify the repayment schedule of the loan -#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] -pub struct RepaymentSchedule { - /// Expected repayments date for remaining debt - pub maturity: Maturity, - - /// Period at which interest is paid - pub interest_payments: InterestPayments, - - /// How much of the initially borrowed amount is paid back during interest - /// payments - pub pay_down_schedule: PayDownSchedule, -} - -impl RepaymentSchedule { - pub fn is_valid(&self, now: Moment) -> bool { - self.maturity.is_valid(now) - } - - // TODO: this should be exposed through a runtime API by (pool_id, loan_id) - pub fn generate_expected_cashflows( - &self, - origination_date: Moment, - principal: Balance, - interest_rate: &InterestRate, - ) -> Result, DispatchError> - where - Balance: FixedPointOperand, - Rate: FixedPointNumber, - { - match self.maturity { - Maturity::Fixed { date, .. } => { - let start = NaiveDateTime::from_timestamp_opt(origination_date as i64, 0) - .ok_or(DispatchError::Other("Invalid origination date"))? - .date(); - - let end = NaiveDateTime::from_timestamp_opt(date as i64, 0) - .ok_or(DispatchError::Other("Invalid maturity date"))? - .date(); - - match &self.interest_payments { - InterestPayments::None => Ok(vec![]), - InterestPayments::Monthly(reference_date) => Self::add_interest_amounts( - Self::get_cashflow_list::( - start, - end, - reference_date.clone(), - )?, - principal, - &self.interest_payments, - interest_rate, - origination_date, - date, - ), - InterestPayments::SemiAnnually(reference_date) => Ok(vec![]), - InterestPayments::Annually(reference_date) => Ok(vec![]), - } - } - } - } - - fn get_cashflow_list( - start: NaiveDate, - end: NaiveDate, - reference_date: ReferenceDate, - ) -> Result, DispatchError> - where - Balance: FixedPointOperand, - Rate: FixedPointNumber, - { - // TODO: once we implement a pay_down_schedule other than `None`, - // we will need to adjust the expected interest amounts based on the - // expected outstanding principal at the time of the interest payment - Ok(match reference_date { - ReferenceDate::Date(reference) => { - let reference_date = NaiveDateTime::from_timestamp_opt(reference as i64, 0) - .ok_or(DispatchError::Other("Invalid origination date"))? - .date(); - - DateRule::monthly(start) - .with_end(end) - .with_rolling_day(reference_date.day()) - .unwrap() - .into_iter() - // There's no interest payment expected on the origination date - .skip(1) - .collect() - } - ReferenceDate::End => DateRule::monthly(start) - .with_end(end) - .with_rolling_day(31) - .unwrap() - .into_iter() - .collect(), - }) - } - - fn add_interest_amounts( - cashflows: Vec, - principal: Balance, - interest_payments: &InterestPayments, - interest_rate: &InterestRate, - origination_date: Moment, - maturity_date: Moment, - ) -> Result, DispatchError> - where - Balance: FixedPointOperand, - Rate: FixedPointNumber, - { - let cashflows_len = cashflows.len(); - cashflows - .into_iter() - .enumerate() - .map(|(i, d)| -> Result<(NaiveDate, Balance), DispatchError> { - let interest_rate_per_sec = interest_rate.per_sec()?; - let amount_per_sec = interest_rate_per_sec.ensure_mul_int(principal)?; - - if i == 0 { - // First cashflow: cashflow date - origination date * interest per day - let dt = d.and_hms_opt(0, 0, 0).ok_or("")?.timestamp() as u64; - return Ok(( - d, - Rate::saturating_from_rational( - dt.ensure_sub(origination_date)?, - SECONDS_PER_YEAR, - ) - .ensure_mul_int(amount_per_sec)?, - )); - } - - if i == cashflows_len { - // Last cashflow: maturity date - cashflow date * interest per day - let dt = d.and_hms_opt(0, 0, 0).ok_or("")?.timestamp() as u64; - return Ok(( - d, - Rate::saturating_from_rational( - maturity_date.ensure_sub(dt)?, - SECONDS_PER_YEAR, - ) - .ensure_mul_int(amount_per_sec)?, - )); - } - - // Inbetween cashflows: interest per year / number of periods per year (e.g. - // divided by 12 for monthly interest payments) - let periods_per_year = match interest_payments { - InterestPayments::None => 0, - InterestPayments::Monthly(_) => 12, - InterestPayments::SemiAnnually(_) => 2, - InterestPayments::Annually(_) => 1, - }; - - let interest_rate_per_period = interest_rate - .per_year() - .ensure_div(Rate::saturating_from_integer(periods_per_year))?; - let amount_per_period = interest_rate_per_period.ensure_mul_int(principal)?; - - return Ok((d, amount_per_period)); - }) - .collect() - } -} - /// Specify how offer a loan can be borrowed #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub enum BorrowRestrictions { @@ -414,96 +173,3 @@ impl RepaidAmount { self.unscheduled.ensure_add_assign(other.unscheduled) } } - -#[cfg(test)] -mod tests { - use cfg_traits::interest::CompoundingSchedule; - use frame_support::assert_ok; - - use super::*; - - pub type Rate = sp_arithmetic::fixed_point::FixedU128; - - fn from_ymd(year: i32, month: u32, day: u32) -> Moment { - NaiveDate::from_ymd_opt(year, month, day) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .timestamp() as u64 - } - - #[test] - fn cashflow_generation_works() { - assert_ok!( - RepaymentSchedule { - maturity: Maturity::fixed(from_ymd(2022, 12, 1)), - interest_payments: InterestPayments::Monthly(ReferenceDate::End), - pay_down_schedule: PayDownSchedule::None - } - .generate_expected_cashflows( - from_ymd(2022, 6, 1), - 1000, - &InterestRate::Fixed { - rate_per_year: Rate::from_float(0.1), - compounding: CompoundingSchedule::Secondly - } - ), - vec![ - (NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(), 8u128.into()), - (NaiveDate::from_ymd_opt(2022, 7, 31).unwrap(), 8u128.into()), - (NaiveDate::from_ymd_opt(2022, 8, 31).unwrap(), 8u128.into()), - (NaiveDate::from_ymd_opt(2022, 9, 30).unwrap(), 8u128.into()), - (NaiveDate::from_ymd_opt(2022, 10, 31).unwrap(), 8u128.into()), - (NaiveDate::from_ymd_opt(2022, 11, 30).unwrap(), 8u128.into()) - ] - ); - - assert_ok!( - RepaymentSchedule { - maturity: Maturity::fixed(from_ymd(2022, 12, 2)), - interest_payments: InterestPayments::Monthly(ReferenceDate::Date(from_ymd( - 2022, 6, 2 - ))), - pay_down_schedule: PayDownSchedule::None - } - .generate_expected_cashflows( - from_ymd(2022, 6, 2), - 1000, - &InterestRate::Fixed { - rate_per_year: Rate::from_float(0.25), - compounding: CompoundingSchedule::Secondly - } - ), - vec![ - (NaiveDate::from_ymd_opt(2022, 7, 2).unwrap(), 20u128.into()), - (NaiveDate::from_ymd_opt(2022, 8, 2).unwrap(), 20u128.into()), - (NaiveDate::from_ymd_opt(2022, 9, 2).unwrap(), 20u128.into()), - (NaiveDate::from_ymd_opt(2022, 10, 2).unwrap(), 20u128.into()), - (NaiveDate::from_ymd_opt(2022, 11, 2).unwrap(), 20u128.into()) - ] - ); - - assert_ok!( - RepaymentSchedule { - maturity: Maturity::fixed(from_ymd(2023, 6, 1)), - interest_payments: InterestPayments::SemiAnnually(ReferenceDate::End), - pay_down_schedule: PayDownSchedule::None - } - .generate_expected_cashflows( - from_ymd(2022, 6, 1), - 1000, - &InterestRate::Fixed { - rate_per_year: Rate::from_float(0.1), - compounding: CompoundingSchedule::Secondly - } - ), - vec![ - (NaiveDate::from_ymd_opt(2022, 6, 30).unwrap(), 48u128.into()), - ( - NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(), - 48u128.into() - ), - ] - ); - } -} diff --git a/runtime/integration-tests/src/utils/loans.rs b/runtime/integration-tests/src/utils/loans.rs index 8d10d65f07..ec6d7e0155 100644 --- a/runtime/integration-tests/src/utils/loans.rs +++ b/runtime/integration-tests/src/utils/loans.rs @@ -27,9 +27,9 @@ use pallet_loans::{ }, }, types::{ + cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, valuation::{DiscountedCashFlow, ValuationMethod}, - BorrowRestrictions, InterestPayments, LoanRestrictions, Maturity, PayDownSchedule, - RepaidAmount, RepayRestrictions, RepaymentSchedule, + BorrowRestrictions, LoanRestrictions, RepaidAmount, RepayRestrictions, }, Call as LoansCall, }; From f6cbc44c2858f0858ccdccb509f11fd85968a0f4 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Fri, 4 Aug 2023 12:40:44 +0200 Subject: [PATCH 3/3] fix clippy issues --- pallets/loans/src/entities/loans.rs | 2 +- pallets/loans/src/types/cashflow.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 6084a4b570..0e11cb6607 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -254,7 +254,7 @@ impl ActiveLoan { let cashflows = self.schedule.generate_expected_cashflows( self.origination_date, self.principal()?, - &pricing.interest.rate(), + pricing.interest.rate(), )?; // TODO(Luis): If from this point, any field of ActiveLoan is needed, I would diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index 808fc1fe52..2d2ef7c710 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -255,7 +255,7 @@ impl RepaymentSchedule { .ensure_div(Rate::saturating_from_integer(periods_per_year))?; let amount_per_period = interest_rate_per_period.ensure_mul_int(principal)?; - return Ok((d, amount_per_period)); + Ok((d, amount_per_period)) }) .collect() }