diff --git a/Cargo.lock b/Cargo.lock index 581b6b3526..817b5d06de 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" @@ -7481,6 +7496,8 @@ dependencies = [ "cfg-primitives", "cfg-traits", "cfg-types", + "chrono", + "chronoutil", "frame-benchmarking", "frame-support", "frame-system", @@ -14385,7 +14402,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 37dec6cd1a..854f376147 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", diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 863519057a..ff264faf8c 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,