From 8ca7dc2fb81299a3d65143f959c85e5f179172a6 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Fri, 5 Apr 2024 13:58:52 +0200 Subject: [PATCH 01/28] updated from #1408 --- Cargo.lock | 11 + Cargo.toml | 2 + pallets/loans/Cargo.toml | 2 + pallets/loans/src/benchmarking.rs | 4 +- pallets/loans/src/entities/changes.rs | 5 +- pallets/loans/src/entities/loans.rs | 36 +- pallets/loans/src/tests/mod.rs | 6 +- pallets/loans/src/types/cashflow.rs | 348 ++++++++++++++++++ pallets/loans/src/types/mod.rs | 80 +--- .../src/generic/cases/loans.rs | 5 +- 10 files changed, 402 insertions(+), 97 deletions(-) create mode 100644 pallets/loans/src/types/cashflow.rs diff --git a/Cargo.lock b/Cargo.lock index 6f069996f5..46eb9776a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,6 +1723,15 @@ dependencies = [ "windows-targets 0.52.4", ] +[[package]] +name = "chronoutil" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa531c9c2b0e6168a6e4c5023cd38e8d5ab009d3a10459cd3e7baecd68fc3715" +dependencies = [ + "chrono", +] + [[package]] name = "cid" version = "0.9.0" @@ -8093,6 +8102,8 @@ dependencies = [ "cfg-traits", "cfg-types", "cfg-utils", + "chrono", + "chronoutil", "frame-benchmarking", "frame-support", "frame-system", diff --git a/Cargo.toml b/Cargo.toml index b5163df121..cd58c59256 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,8 @@ rev_slice = { version = "0.1.5", default-features = false } impl-trait-for-tuples = "0.2.1" num-traits = { version = "0.2", default-features = false } num_enum = { version = "0.5.3", default-features = false } +chrono = { version = "0.4", default-features = false } +chronoutil = "0.2" # Cumulus cumulus-pallet-aura-ext = { git = "https://github.com/paritytech/polkadot-sdk", default-features = false, branch = "release-polkadot-v1.1.0" } diff --git a/pallets/loans/Cargo.toml b/pallets/loans/Cargo.toml index 5934cd764e..e4008ece4d 100644 --- a/pallets/loans/Cargo.toml +++ b/pallets/loans/Cargo.toml @@ -15,6 +15,8 @@ targets = ["x86_64-unknown-linux-gnu"] [dependencies] parity-scale-codec = { workspace = true } scale-info = { workspace = true } +chrono = { workspace = true } +chronoutil = { workspace = true } frame-support = { workspace = true } frame-system = { workspace = true } diff --git a/pallets/loans/src/benchmarking.rs b/pallets/loans/src/benchmarking.rs index d1b2f6ace0..39c72e1e4f 100644 --- a/pallets/loans/src/benchmarking.rs +++ b/pallets/loans/src/benchmarking.rs @@ -40,9 +40,9 @@ use crate::{ }, pallet::*, types::{ + cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, valuation::{DiscountedCashFlow, ValuationMethod}, - BorrowRestrictions, InterestPayments, LoanRestrictions, Maturity, PayDownSchedule, - RepayRestrictions, RepaymentSchedule, + BorrowRestrictions, LoanRestrictions, RepayRestrictions, }, }; diff --git a/pallets/loans/src/entities/changes.rs b/pallets/loans/src/entities/changes.rs index 1181f79da4..eff6cd3ee4 100644 --- a/pallets/loans/src/entities/changes.rs +++ b/pallets/loans/src/entities/changes.rs @@ -7,8 +7,9 @@ use crate::{ entities::input::{PrincipalInput, RepaidInput}, pallet::Config, types::{ - policy::WriteOffRule, valuation::ValuationMethod, InterestPayments, Maturity, - PayDownSchedule, + cashflow::{InterestPayments, Maturity, PayDownSchedule}, + policy::WriteOffRule, + valuation::ValuationMethod, }, }; diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 0489f22cf7..366b825df3 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -27,9 +27,10 @@ use crate::{ }, pallet::{AssetOf, Config, Error}, types::{ + cashflow::RepaymentSchedule, policy::{WriteOffStatus, WriteOffTrigger}, BorrowLoanError, BorrowRestrictions, CloseLoanError, CreateLoanError, LoanRestrictions, - MutationError, RepaidAmount, RepayLoanError, RepayRestrictions, RepaymentSchedule, + MutationError, RepaidAmount, RepayLoanError, RepayRestrictions, }, }; @@ -237,6 +238,12 @@ impl ActiveLoan { } } + 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, @@ -345,6 +352,20 @@ impl ActiveLoan { Error::::from(BorrowLoanError::MaturityDatePassed) ); + // TODO + // 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(()) } @@ -378,11 +399,8 @@ impl ActiveLoan { ) -> Result, DispatchError> { let (max_repay_principal, outstanding_interest) = match &self.pricing { ActivePricing::Internal(inner) => { - amount.principal.internal()?; - - let principal = self - .total_borrowed - .ensure_sub(self.total_repaid.principal)?; + let _ = amount.principal.internal()?; + let principal = self.principal()?; (principal, inner.outstanding_interest(principal)?) } @@ -519,7 +537,7 @@ impl ActiveLoan { #[cfg(feature = "runtime-benchmarks")] pub fn set_maturity(&mut self, duration: Seconds) { - self.schedule.maturity = crate::types::Maturity::fixed(duration); + self.schedule.maturity = crate::types::cashflow::Maturity::fixed(duration); } } @@ -555,9 +573,7 @@ impl TryFrom<(T::PoolId, ActiveLoan)> for ActiveLoanInfo { Ok(match &active_loan.pricing { ActivePricing::Internal(inner) => { - let principal = active_loan - .total_borrowed - .ensure_sub(active_loan.total_repaid.principal)?; + let principal = active_loan.principal()?; Self { present_value, diff --git a/pallets/loans/src/tests/mod.rs b/pallets/loans/src/tests/mod.rs index ac8dd0011c..4cae106046 100644 --- a/pallets/loans/src/tests/mod.rs +++ b/pallets/loans/src/tests/mod.rs @@ -20,11 +20,11 @@ use super::{ }, pallet::{ActiveLoans, CreatedLoan, Error, LastLoanId, PortfolioValuation}, types::{ + cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, policy::{WriteOffRule, WriteOffStatus, WriteOffTrigger}, valuation::{DiscountedCashFlow, ValuationMethod}, - BorrowLoanError, BorrowRestrictions, CloseLoanError, CreateLoanError, InterestPayments, - LoanRestrictions, Maturity, MutationError, PayDownSchedule, RepayLoanError, - RepayRestrictions, RepaymentSchedule, WrittenOffError, + BorrowLoanError, BorrowRestrictions, CloseLoanError, CreateLoanError, 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..9ad8995413 --- /dev/null +++ b/pallets/loans/src/types/cashflow.rs @@ -0,0 +1,348 @@ +// 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::SECONDS_PER_YEAR; +use cfg_traits::{interest::InterestRate, Seconds}; +use chrono::{Datelike, NaiveDate, NaiveDateTime}; +use chronoutil::DateRule; +use frame_support::pallet_prelude::RuntimeDebug; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +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: Seconds, + /// Extension in secs, without special permissions + extension: Seconds, + }, +} + +impl Maturity { + pub fn fixed(date: Seconds) -> Self { + Self::Fixed { date, extension: 0 } + } + + pub fn date(&self) -> Seconds { + match self { + Maturity::Fixed { date, .. } => *date, + } + } + + pub fn is_valid(&self, now: Seconds) -> bool { + match self { + Maturity::Fixed { date, .. } => *date > now, + } + } + + pub fn extends(&mut self, value: Seconds) -> 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(Seconds), + + /// 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: Seconds) -> bool { + self.maturity.is_valid(now) + } + + pub fn generate_expected_cashflows( + &self, + origination_date: Seconds, + principal: Balance, + interest_rate: &InterestRate, + ) -> Result, DispatchError> + where + Balance: FixedPointOperand, + Rate: FixedPointNumber, + { + let Maturity::Fixed { date, .. } = self.maturity; + + 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: Seconds, + maturity_date: Seconds, + ) -> 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)?; + + 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) -> Seconds { + 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 922f3cda39..cc32ca9972 100644 --- a/pallets/loans/src/types/mod.rs +++ b/pallets/loans/src/types/mod.rs @@ -17,11 +17,9 @@ use cfg_traits::Seconds; use frame_support::{pallet_prelude::RuntimeDebug, PalletError}; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; -use sp_runtime::{ - traits::{EnsureAdd, EnsureAddAssign, EnsureSubAssign}, - ArithmeticError, -}; +use sp_runtime::{traits::EnsureAdd, ArithmeticError}; +pub mod cashflow; pub mod policy; pub mod valuation; @@ -85,80 +83,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: Seconds, - /// Extension in secs, without special permissions - extension: Seconds, - }, -} - -impl Maturity { - pub fn fixed(date: Seconds) -> Self { - Self::Fixed { date, extension: 0 } - } - - pub fn date(&self) -> Seconds { - match self { - Maturity::Fixed { date, .. } => *date, - } - } - - pub fn is_valid(&self, now: Seconds) -> bool { - match self { - Maturity::Fixed { date, .. } => *date > now, - } - } - - pub fn extends(&mut self, value: Seconds) -> Result<(), ArithmeticError> { - match self { - Maturity::Fixed { date, extension } => { - date.ensure_add_assign(value)?; - extension.ensure_sub_assign(value) - } - } - } -} - -/// 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, -} - -/// 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: Seconds) -> bool { - self.maturity.is_valid(now) - } -} - /// Specify how offer a loan can be borrowed #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub enum BorrowRestrictions { diff --git a/runtime/integration-tests/src/generic/cases/loans.rs b/runtime/integration-tests/src/generic/cases/loans.rs index 1bc33b5d1b..1abc7a68e0 100644 --- a/runtime/integration-tests/src/generic/cases/loans.rs +++ b/runtime/integration-tests/src/generic/cases/loans.rs @@ -21,8 +21,9 @@ use pallet_loans::{ }, }, types::{ - valuation::ValuationMethod, BorrowLoanError, BorrowRestrictions, InterestPayments, - LoanRestrictions, Maturity, PayDownSchedule, RepayRestrictions, RepaymentSchedule, + cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, + valuation::ValuationMethod, + BorrowLoanError, BorrowRestrictions, LoanRestrictions, RepayRestrictions, }, }; use runtime_common::{ From 9945d65a2dcb8af04d39c42679c1b17c144728d1 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Mon, 15 Apr 2024 17:34:00 +0200 Subject: [PATCH 02/28] add testing --- pallets/loans/src/types/cashflow.rs | 426 ++++++++++++++++++---------- pallets/loans/src/types/mod.rs | 1 - 2 files changed, 276 insertions(+), 151 deletions(-) diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index 9ad8995413..d0d54a5fa2 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -11,15 +11,17 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -use cfg_primitives::SECONDS_PER_YEAR; +use cfg_primitives::{SECONDS_PER_MONTH, SECONDS_PER_YEAR}; use cfg_traits::{interest::InterestRate, Seconds}; -use chrono::{Datelike, NaiveDate, NaiveDateTime}; +use chrono::{DateTime, Datelike, Days, Months, NaiveDate, TimeDelta}; use chronoutil::DateRule; use frame_support::pallet_prelude::RuntimeDebug; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::{ - traits::{EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureSub, EnsureSubAssign}, + traits::{ + EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureInto, EnsureSub, EnsureSubAssign, + }, ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, }; @@ -62,6 +64,32 @@ impl Maturity { } } +fn seconds_to_days(seconds: Seconds) -> Result { + Ok(TimeDelta::try_seconds(seconds.ensure_into()?) + .ok_or(DispatchError::Other("Precission error with seconds"))? + .num_days() + .ensure_into()?) +} + +fn seconds_to_date(date_in_seconds: Seconds) -> Result { + Ok(DateTime::from_timestamp(date_in_seconds.ensure_into()?, 0) + .ok_or(DispatchError::Other("Invalid date in seconds"))? + .date_naive()) +} + +fn date_to_seconds(date: NaiveDate) -> Result { + Ok(date + .and_hms_opt(0, 0, 0) + .ok_or(DispatchError::Other("Invalid h/m/s"))? + .and_utc() + .timestamp() + .ensure_into()?) +} + +fn seconds_in_month(date: NaiveDate) -> Result { + todo!() +} + #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] pub enum ReferenceDate { /// Payments are expected every period relative to a specific date. @@ -74,6 +102,49 @@ pub enum ReferenceDate { End, } +impl ReferenceDate { + fn monthly_cashflow_dates( + &self, + start: Seconds, + end: Seconds, + ) -> Result, DispatchError> { + if start > end { + return Err(DispatchError::Other("cashflow must start before it ends")); + } + + let start = seconds_to_date(start)?; + let end = seconds_to_date(end - 1)? + Months::new(1); + + let rolling_days = match self { + Self::Date(reference) => seconds_to_days(*reference)?, + Self::End => 31, + }; + + let mut dates = DateRule::monthly(start) + .with_end(end) + .with_rolling_day(rolling_days) + .map_err(|_| DispatchError::Other("Month with more than 31 days"))? + .into_iter() + .collect::>(); + + if let Some(first) = dates.first() { + if *first < start { + dates.remove(0); + } + } + + dates + .into_iter() + .map(|date| { + //TODO + let date = date_to_seconds(date)?; + let interval = 0; + Ok((date, interval)) + }) + .collect::>() + } +} + /// Interest payment periods #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] pub enum InterestPayments { @@ -90,6 +161,19 @@ pub enum InterestPayments { Annually(ReferenceDate), } +impl InterestPayments { + /// Inbetween cashflows: interest per year / number of periods per year + /// (e.g. divided by 12 for monthly interest payments) + fn periods_per_year(&self) -> u32 { + match self { + InterestPayments::None => 0, + InterestPayments::Monthly(_) => 12, + InterestPayments::SemiAnnually(_) => 2, + InterestPayments::Annually(_) => 1, + } + } +} + /// Specify the paydown schedules of the loan #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] pub enum PayDownSchedule { @@ -117,85 +201,49 @@ impl RepaymentSchedule { self.maturity.is_valid(now) } + /* pub fn generate_expected_cashflows( &self, origination_date: Seconds, principal: Balance, interest_rate: &InterestRate, - ) -> Result, DispatchError> + ) -> Result, DispatchError> where Balance: FixedPointOperand, Rate: FixedPointNumber, { - let Maturity::Fixed { date, .. } = self.maturity; - - let start = NaiveDateTime::from_timestamp_opt(origination_date as i64, 0) - .ok_or(DispatchError::Other("Invalid origination date"))? - .date(); + let Maturity::Fixed { + date: maturity_date, + .. + } = self.maturity; - let end = NaiveDateTime::from_timestamp_opt(date as i64, 0) - .ok_or(DispatchError::Other("Invalid maturity date"))? - .date(); + let start = origination_date; + let end = maturity_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())?, + reference_date.monthly_cashflow_dates(start, end)?, principal, - &self.interest_payments, + self.interest_payments.periods_per_year(), interest_rate, origination_date, - date, + maturity_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, + cashflows: Vec, principal: Balance, - interest_payments: &InterestPayments, + periods_per_year: u32, interest_rate: &InterestRate, origination_date: Seconds, maturity_date: Seconds, - ) -> Result, DispatchError> + ) -> Result, DispatchError> where Balance: FixedPointOperand, Rate: FixedPointNumber, @@ -204,56 +252,66 @@ impl RepaymentSchedule { 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)?; + .map(|(i, date)| -> Result<(Seconds, Balance), DispatchError> { + /* + 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)?; 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, + date, Rate::saturating_from_rational( - dt.ensure_sub(origination_date)?, - SECONDS_PER_YEAR, + date.ensure_sub(origination_date)?, + SECONDS_PER_MONTH, ) .ensure_mul_int(amount_per_sec)?, )); } - if i == cashflows_len { + if i == cashflows_len - 1 { // 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, + date, Rate::saturating_from_rational( - maturity_date.ensure_sub(dt)?, - SECONDS_PER_YEAR, + maturity_date.ensure_sub(date)?, + SECONDS_PER_MONTH, ) .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)?; - Ok((d, amount_per_period)) + Ok((date, amount_per_period)) + */ + todo!() }) .collect() } } +fn compute_cashflow_interest( + start: Seconds, + end: Seconds, + amount_per_sec: Balance, +) -> Result +where + Balance: FixedPointOperand, + Rate: FixedPointNumber, +{ + Ok( + Rate::saturating_from_rational(end.ensure_sub(start)?, SECONDS_PER_YEAR) + .ensure_mul_int(amount_per_sec)?, + ) +} + #[cfg(test)] mod tests { use cfg_traits::interest::CompoundingSchedule; @@ -263,86 +321,154 @@ mod tests { pub type Rate = sp_arithmetic::fixed_point::FixedU128; + fn days(days: u32) -> Seconds { + TimeDelta::days(days as i64).num_seconds() as Seconds + } + fn from_ymd(year: i32, month: u32, day: u32) -> Seconds { + from_ymdhms(year, month, day, 0, 0, 0) + } + + fn from_ymdhms(year: i32, month: u32, day: u32, hour: u32, min: u32, seconds: u32) -> Seconds { NaiveDate::from_ymd_opt(year, month, day) .unwrap() - .and_hms_opt(0, 0, 0) + .and_hms_opt(hour, min, seconds) .unwrap() - .timestamp() as u64 + .timestamp() as Seconds } - #[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() + fn rate_per_year(rate: f32) -> InterestRate { + InterestRate::Fixed { + rate_per_year: Rate::from_float(0.1), + compounding: CompoundingSchedule::Secondly, + } + } + + mod cashflow_dates { + use super::*; + + #[test] + fn foo() { + let date = NaiveDate::from_ymd_opt(2022, 6, 30) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + } + + #[test] + fn basic_list() { + assert_ok!( + ReferenceDate::End + .monthly_cashflow_dates(from_ymd(2022, 6, 15), from_ymd(2022, 12, 15)), + vec![ + (from_ymd(2022, 6, 30), 0), + (from_ymd(2022, 7, 31), 0), + (from_ymd(2022, 8, 31), 0), + (from_ymd(2022, 9, 30), 0), + (from_ymd(2022, 10, 31), 0), + (from_ymd(2022, 11, 30), 0), + (from_ymd(2022, 12, 31), 0), + ] + ); + + assert_ok!( + ReferenceDate::Date(days(20)) + .monthly_cashflow_dates(from_ymd(2022, 6, 15), from_ymd(2022, 12, 15)), + vec![ + (from_ymd(2022, 6, 20), 0), + (from_ymd(2022, 7, 20), 0), + (from_ymd(2022, 8, 20), 0), + (from_ymd(2022, 9, 20), 0), + (from_ymd(2022, 10, 20), 0), + (from_ymd(2022, 11, 20), 0), + (from_ymd(2022, 12, 20), 0), + ] + ); + } + + #[test] + fn same_date() { + assert_ok!( + ReferenceDate::End + .monthly_cashflow_dates(from_ymd(2022, 6, 15), from_ymd(2022, 6, 15)), + vec![(from_ymd(2022, 6, 30), 0)] + ); + + assert_ok!( + ReferenceDate::Date(days(20)) + .monthly_cashflow_dates(from_ymd(2022, 6, 15), from_ymd(2022, 6, 15)), + vec![(from_ymd(2022, 6, 20), 0)] + ); + } + + #[test] + fn end_limit_exact() { + assert_ok!( + ReferenceDate::End.monthly_cashflow_dates( + from_ymdhms(2022, 6, 1, 0, 0, 0), + from_ymdhms(2022, 8, 1, 0, 0, 0) ), - ] - ); + vec![(from_ymd(2022, 6, 30), 0), (from_ymd(2022, 7, 31), 0)] + ); + assert_ok!( + ReferenceDate::Date(days(20)).monthly_cashflow_dates( + from_ymdhms(2022, 6, 21, 0, 0, 0), + from_ymdhms(2022, 8, 21, 0, 0, 0) + ), + vec![(from_ymd(2022, 7, 20), 0), (from_ymd(2022, 8, 20), 0)] + ); + } + + #[test] + fn end_limit_plus_a_second() { + assert_ok!( + ReferenceDate::End.monthly_cashflow_dates( + from_ymdhms(2022, 6, 1, 0, 0, 0), + from_ymdhms(2022, 8, 1, 0, 0, 1) + ), + vec![ + (from_ymd(2022, 6, 30), 0), + (from_ymd(2022, 7, 31), 0), + (from_ymd(2022, 8, 31), 0), // by 1 second + ] + ); + assert_ok!( + ReferenceDate::Date(days(20)).monthly_cashflow_dates( + from_ymdhms(2022, 6, 21, 0, 0, 0), + from_ymdhms(2022, 8, 21, 0, 0, 1) + ), + vec![ + (from_ymd(2022, 7, 20), 0), + (from_ymd(2022, 8, 20), 0), + (from_ymd(2022, 9, 20), 0), // by 1 second + ] + ); + } + + #[test] + fn start_limit_less_a_second() { + assert_ok!( + ReferenceDate::End.monthly_cashflow_dates( + from_ymdhms(2022, 5, 31, 23, 59, 59), + from_ymdhms(2022, 7, 31, 23, 59, 59) + ), + vec![ + (from_ymd(2022, 5, 31), 0), // by 1 second + (from_ymd(2022, 6, 30), 0), + (from_ymd(2022, 7, 31), 0) + ] + ); + assert_ok!( + ReferenceDate::Date(days(20)).monthly_cashflow_dates( + from_ymdhms(2022, 6, 20, 23, 59, 59), + from_ymdhms(2022, 8, 20, 23, 59, 59) + ), + vec![ + (from_ymd(2022, 6, 20), 0), // by 1 second + (from_ymd(2022, 7, 20), 0), + (from_ymd(2022, 8, 20), 0), + ] + ); + } } } diff --git a/pallets/loans/src/types/mod.rs b/pallets/loans/src/types/mod.rs index cc32ca9972..ee9def50ed 100644 --- a/pallets/loans/src/types/mod.rs +++ b/pallets/loans/src/types/mod.rs @@ -13,7 +13,6 @@ //! Contains base types without Config references -use cfg_traits::Seconds; use frame_support::{pallet_prelude::RuntimeDebug, PalletError}; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; From d129f98ce243e7a49ccf4ad461e3a0a648217a7b Mon Sep 17 00:00:00 2001 From: lemunozm Date: Fri, 19 Apr 2024 13:45:41 +0200 Subject: [PATCH 03/28] organize and polish cashflow module --- pallets/loans/src/types/cashflow.rs | 467 ++++++++++------------------ 1 file changed, 160 insertions(+), 307 deletions(-) diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index d0d54a5fa2..4a50157382 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -11,9 +11,8 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -use cfg_primitives::{SECONDS_PER_MONTH, SECONDS_PER_YEAR}; use cfg_traits::{interest::InterestRate, Seconds}; -use chrono::{DateTime, Datelike, Days, Months, NaiveDate, TimeDelta}; +use chrono::{DateTime, NaiveDate}; use chronoutil::DateRule; use frame_support::pallet_prelude::RuntimeDebug; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; @@ -64,13 +63,6 @@ impl Maturity { } } -fn seconds_to_days(seconds: Seconds) -> Result { - Ok(TimeDelta::try_seconds(seconds.ensure_into()?) - .ok_or(DispatchError::Other("Precission error with seconds"))? - .num_days() - .ensure_into()?) -} - fn seconds_to_date(date_in_seconds: Seconds) -> Result { Ok(DateTime::from_timestamp(date_in_seconds.ensure_into()?, 0) .ok_or(DispatchError::Other("Invalid date in seconds"))? @@ -86,65 +78,6 @@ fn date_to_seconds(date: NaiveDate) -> Result { .ensure_into()?) } -fn seconds_in_month(date: NaiveDate) -> Result { - todo!() -} - -#[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(Seconds), - - /// At the end of the period, e.g. the last day of the month for a monthly - /// period - End, -} - -impl ReferenceDate { - fn monthly_cashflow_dates( - &self, - start: Seconds, - end: Seconds, - ) -> Result, DispatchError> { - if start > end { - return Err(DispatchError::Other("cashflow must start before it ends")); - } - - let start = seconds_to_date(start)?; - let end = seconds_to_date(end - 1)? + Months::new(1); - - let rolling_days = match self { - Self::Date(reference) => seconds_to_days(*reference)?, - Self::End => 31, - }; - - let mut dates = DateRule::monthly(start) - .with_end(end) - .with_rolling_day(rolling_days) - .map_err(|_| DispatchError::Other("Month with more than 31 days"))? - .into_iter() - .collect::>(); - - if let Some(first) = dates.first() { - if *first < start { - dates.remove(0); - } - } - - dates - .into_iter() - .map(|date| { - //TODO - let date = date_to_seconds(date)?; - let interval = 0; - Ok((date, interval)) - }) - .collect::>() - } -} - /// Interest payment periods #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] pub enum InterestPayments { @@ -152,26 +85,10 @@ pub enum InterestPayments { 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), -} - -impl InterestPayments { - /// Inbetween cashflows: interest per year / number of periods per year - /// (e.g. divided by 12 for monthly interest payments) - fn periods_per_year(&self) -> u32 { - match self { - InterestPayments::None => 0, - InterestPayments::Monthly(_) => 12, - InterestPayments::SemiAnnually(_) => 2, - InterestPayments::Annually(_) => 1, - } - } + /// The associated value correspond to the paydown day in the month, + /// from 1-31. + /// The day will be adjusted to the month. + Monthly(u8), } /// Specify the paydown schedules of the loan @@ -201,8 +118,7 @@ impl RepaymentSchedule { self.maturity.is_valid(now) } - /* - pub fn generate_expected_cashflows( + pub fn generate_cashflows( &self, origination_date: Seconds, principal: Balance, @@ -212,126 +128,125 @@ impl RepaymentSchedule { Balance: FixedPointOperand, Rate: FixedPointNumber, { - let Maturity::Fixed { - date: maturity_date, - .. - } = self.maturity; - - let start = origination_date; - let end = maturity_date; - - match &self.interest_payments { - InterestPayments::None => Ok(vec![]), - InterestPayments::Monthly(reference_date) => Self::add_interest_amounts( - reference_date.monthly_cashflow_dates(start, end)?, - principal, - self.interest_payments.periods_per_year(), - interest_rate, - origination_date, - maturity_date, + let start_date = seconds_to_date(origination_date)?; + let end_date = seconds_to_date(self.maturity.date())?; + + let (timeflow, periods_per_year) = match &self.interest_payments { + InterestPayments::None => (vec![], 1), + InterestPayments::Monthly(reference_day) => ( + monthly_dates_intervals::(start_date, end_date, (*reference_day).into())?, + 12, ), - InterestPayments::SemiAnnually(reference_date) => Ok(vec![]), - InterestPayments::Annually(reference_date) => Ok(vec![]), - } - } - */ + }; - fn add_interest_amounts( - cashflows: Vec, - principal: Balance, - periods_per_year: u32, - interest_rate: &InterestRate, - origination_date: Seconds, - maturity_date: Seconds, - ) -> Result, DispatchError> - where - Balance: FixedPointOperand, - Rate: FixedPointNumber, - { - let cashflows_len = cashflows.len(); - cashflows + let amount_per_period = interest_rate + .per_year() + .ensure_div(Rate::saturating_from_integer(periods_per_year))? + .ensure_mul_int(principal)?; + + timeflow .into_iter() - .enumerate() - .map(|(i, date)| -> Result<(Seconds, Balance), DispatchError> { - /* - 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)?; - - if i == 0 { - // First cashflow: cashflow date - origination date * interest per day - return Ok(( - date, - Rate::saturating_from_rational( - date.ensure_sub(origination_date)?, - SECONDS_PER_MONTH, - ) - .ensure_mul_int(amount_per_sec)?, - )); - } + .map(|(date, interval)| { + Ok(( + date_to_seconds(date)?, + interval.ensure_mul_int(amount_per_period)?, + )) + }) + .collect() + } +} - if i == cashflows_len - 1 { - // Last cashflow: maturity date - cashflow date * interest per day - return Ok(( - date, - Rate::saturating_from_rational( - maturity_date.ensure_sub(date)?, - SECONDS_PER_MONTH, - ) - .ensure_mul_int(amount_per_sec)?, - )); - } +fn monthly_dates( + start_date: NaiveDate, + end_date: NaiveDate, + reference_day: u32, +) -> Result, DispatchError> { + if start_date > end_date { + return Err(DispatchError::Other("Cashflow must start before it ends")); + } - 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)?; + let mut dates = DateRule::monthly(start_date) + .with_end(end_date) + .with_rolling_day(reference_day) + .map_err(|_| DispatchError::Other("Paydown day must be less than 31 days"))? + .into_iter() + .collect::>(); + + // We want to skip the first month if the date is before the starting date + if let Some(first) = dates.first() { + if *first <= start_date { + dates.remove(0); + } + } - Ok((date, amount_per_period)) - */ - todo!() - }) - .collect() + // We want to add the end date as last date if the previous last date is before + // end date + if let Some(last) = dates.last() { + if *last < end_date { + dates.push(end_date); + } } + + Ok(dates) } -fn compute_cashflow_interest( - start: Seconds, - end: Seconds, - amount_per_sec: Balance, -) -> Result -where - Balance: FixedPointOperand, - Rate: FixedPointNumber, -{ - Ok( - Rate::saturating_from_rational(end.ensure_sub(start)?, SECONDS_PER_YEAR) - .ensure_mul_int(amount_per_sec)?, - ) +fn monthly_dates_intervals( + start_date: NaiveDate, + end_date: NaiveDate, + reference_day: u32, +) -> Result, DispatchError> { + let monthly_dates = monthly_dates(start_date, end_date, reference_day)?; + let last_index = monthly_dates.len().ensure_sub(1)?; + + monthly_dates + .clone() + .into_iter() + .enumerate() + .map(|(i, date)| { + let days = match i { + 0 => (date - start_date).num_days(), + n if n == last_index => { + let prev_date = monthly_dates + .get(n.ensure_sub(1)?) + .ok_or(DispatchError::Other("n > 1. qed"))?; + + (date - *prev_date).num_days() + } + _ => 30, + }; + + Ok((date, Rate::saturating_from_rational(days, 30))) + }) + .collect() } #[cfg(test)] mod tests { use cfg_traits::interest::CompoundingSchedule; - use frame_support::assert_ok; + use frame_support::{assert_err, assert_ok}; + use sp_runtime::traits::One; use super::*; pub type Rate = sp_arithmetic::fixed_point::FixedU128; - fn days(days: u32) -> Seconds { - TimeDelta::days(days as i64).num_seconds() as Seconds + fn from_ymd(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() } - fn from_ymd(year: i32, month: u32, day: u32) -> Seconds { - from_ymdhms(year, month, day, 0, 0, 0) + fn secs_from_ymd(year: i32, month: u32, day: u32) -> Seconds { + secs_from_ymdhms(year, month, day, 0, 0, 0) } - fn from_ymdhms(year: i32, month: u32, day: u32, hour: u32, min: u32, seconds: u32) -> Seconds { - NaiveDate::from_ymd_opt(year, month, day) - .unwrap() + fn secs_from_ymdhms( + year: i32, + month: u32, + day: u32, + hour: u32, + min: u32, + seconds: u32, + ) -> Seconds { + from_ymd(year, month, day) .and_hms_opt(hour, min, seconds) .unwrap() .timestamp() as Seconds @@ -344,131 +259,69 @@ mod tests { } } - mod cashflow_dates { + mod dates { use super::*; - #[test] - fn foo() { - let date = NaiveDate::from_ymd_opt(2022, 6, 30) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap(); - } - - #[test] - fn basic_list() { - assert_ok!( - ReferenceDate::End - .monthly_cashflow_dates(from_ymd(2022, 6, 15), from_ymd(2022, 12, 15)), - vec![ - (from_ymd(2022, 6, 30), 0), - (from_ymd(2022, 7, 31), 0), - (from_ymd(2022, 8, 31), 0), - (from_ymd(2022, 9, 30), 0), - (from_ymd(2022, 10, 31), 0), - (from_ymd(2022, 11, 30), 0), - (from_ymd(2022, 12, 31), 0), - ] - ); - - assert_ok!( - ReferenceDate::Date(days(20)) - .monthly_cashflow_dates(from_ymd(2022, 6, 15), from_ymd(2022, 12, 15)), - vec![ - (from_ymd(2022, 6, 20), 0), - (from_ymd(2022, 7, 20), 0), - (from_ymd(2022, 8, 20), 0), - (from_ymd(2022, 9, 20), 0), - (from_ymd(2022, 10, 20), 0), - (from_ymd(2022, 11, 20), 0), - (from_ymd(2022, 12, 20), 0), - ] - ); - } - - #[test] - fn same_date() { - assert_ok!( - ReferenceDate::End - .monthly_cashflow_dates(from_ymd(2022, 6, 15), from_ymd(2022, 6, 15)), - vec![(from_ymd(2022, 6, 30), 0)] - ); - - assert_ok!( - ReferenceDate::Date(days(20)) - .monthly_cashflow_dates(from_ymd(2022, 6, 15), from_ymd(2022, 6, 15)), - vec![(from_ymd(2022, 6, 20), 0)] - ); - } + mod months { + use super::*; + + #[test] + fn basic_list() { + assert_ok!( + monthly_dates_intervals(from_ymd(2022, 1, 20), from_ymd(2022, 4, 20), 1), + vec![ + (from_ymd(2022, 2, 1), Rate::from((12, 30))), + (from_ymd(2022, 3, 1), Rate::one()), + (from_ymd(2022, 4, 1), Rate::one()), + (from_ymd(2022, 4, 20), Rate::from((19, 30))), + ] + ); + + assert_ok!( + monthly_dates_intervals(from_ymd(2022, 1, 20), from_ymd(2022, 4, 20), 31), + vec![ + (from_ymd(2022, 1, 31), Rate::from((11, 30))), + (from_ymd(2022, 2, 28), Rate::one()), + (from_ymd(2022, 3, 31), Rate::one()), + (from_ymd(2022, 4, 20), Rate::from((20, 30))), + ] + ); + } - #[test] - fn end_limit_exact() { - assert_ok!( - ReferenceDate::End.monthly_cashflow_dates( - from_ymdhms(2022, 6, 1, 0, 0, 0), - from_ymdhms(2022, 8, 1, 0, 0, 0) - ), - vec![(from_ymd(2022, 6, 30), 0), (from_ymd(2022, 7, 31), 0)] - ); - assert_ok!( - ReferenceDate::Date(days(20)).monthly_cashflow_dates( - from_ymdhms(2022, 6, 21, 0, 0, 0), - from_ymdhms(2022, 8, 21, 0, 0, 0) - ), - vec![(from_ymd(2022, 7, 20), 0), (from_ymd(2022, 8, 20), 0)] - ); - } + #[test] + fn one_day() { + assert_err!( + monthly_dates(from_ymd(2022, 1, 20), from_ymd(2022, 1, 20), 31), + DispatchError::Arithmetic(ArithmeticError::Underflow), + ); + } - #[test] - fn end_limit_plus_a_second() { - assert_ok!( - ReferenceDate::End.monthly_cashflow_dates( - from_ymdhms(2022, 6, 1, 0, 0, 0), - from_ymdhms(2022, 8, 1, 0, 0, 1) - ), - vec![ - (from_ymd(2022, 6, 30), 0), - (from_ymd(2022, 7, 31), 0), - (from_ymd(2022, 8, 31), 0), // by 1 second - ] - ); - assert_ok!( - ReferenceDate::Date(days(20)).monthly_cashflow_dates( - from_ymdhms(2022, 6, 21, 0, 0, 0), - from_ymdhms(2022, 8, 21, 0, 0, 1) - ), - vec![ - (from_ymd(2022, 7, 20), 0), - (from_ymd(2022, 8, 20), 0), - (from_ymd(2022, 9, 20), 0), // by 1 second - ] - ); - } + #[test] + fn same_month() { + assert_ok!( + monthly_dates_intervals(from_ymd(2022, 1, 10), from_ymd(2022, 1, 15), 20), + vec![(from_ymd(2022, 1, 15), Rate::from((5, 30)))] + ); + + assert_ok!( + monthly_dates_intervals(from_ymd(2022, 1, 10), from_ymd(2022, 1, 20), 15), + vec![ + (from_ymd(2022, 1, 15), Rate::from((5, 30))), + (from_ymd(2022, 1, 20), Rate::from((5, 30))), + ] + ); + } - #[test] - fn start_limit_less_a_second() { - assert_ok!( - ReferenceDate::End.monthly_cashflow_dates( - from_ymdhms(2022, 5, 31, 23, 59, 59), - from_ymdhms(2022, 7, 31, 23, 59, 59) - ), - vec![ - (from_ymd(2022, 5, 31), 0), // by 1 second - (from_ymd(2022, 6, 30), 0), - (from_ymd(2022, 7, 31), 0) - ] - ); - assert_ok!( - ReferenceDate::Date(days(20)).monthly_cashflow_dates( - from_ymdhms(2022, 6, 20, 23, 59, 59), - from_ymdhms(2022, 8, 20, 23, 59, 59) - ), - vec![ - (from_ymd(2022, 6, 20), 0), // by 1 second - (from_ymd(2022, 7, 20), 0), - (from_ymd(2022, 8, 20), 0), - ] - ); + #[test] + fn same_day_as_paydown_day() { + assert_ok!( + monthly_dates_intervals(from_ymd(2022, 1, 15), from_ymd(2022, 3, 15), 15), + vec![ + (from_ymd(2022, 2, 15), Rate::one()), + (from_ymd(2022, 3, 15), Rate::one()), + ] + ); + } } } } From a4c790a58be80bde748a0726e245d13a0bb3b8b6 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Fri, 19 Apr 2024 19:54:21 +0200 Subject: [PATCH 04/28] add Runtime API --- pallets/loans/src/entities/loans.rs | 36 +++++++++++++-------------- pallets/loans/src/entities/pricing.rs | 18 +++++++++++++- pallets/loans/src/lib.rs | 13 +++++++++- runtime/common/src/apis/loans.rs | 4 ++- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 366b825df3..dbe3b55c3e 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -244,13 +244,18 @@ impl ActiveLoan { .ensure_sub(self.total_repaid.principal)?) } + pub fn cashflow(&self) -> Result, DispatchError> { + self.schedule.generate_cashflows( + self.origination_date, + self.principal()?, + self.pricing.interest().rate(), + ) + } + pub fn write_off_status(&self) -> WriteOffStatus { WriteOffStatus { percentage: self.write_off_percentage, - penalty: match &self.pricing { - ActivePricing::Internal(inner) => inner.interest.penalty(), - ActivePricing::External(inner) => inner.interest.penalty(), - }, + penalty: self.pricing.interest().penalty(), } } @@ -458,11 +463,9 @@ impl ActiveLoan { } pub fn write_off(&mut self, new_status: &WriteOffStatus) -> DispatchResult { - let penalty = new_status.penalty; - match &mut self.pricing { - ActivePricing::Internal(inner) => inner.interest.set_penalty(penalty)?, - ActivePricing::External(inner) => inner.interest.set_penalty(penalty)?, - } + self.pricing + .interest_mut() + .set_penalty(new_status.penalty)?; self.write_off_percentage = new_status.percentage; @@ -470,12 +473,10 @@ impl ActiveLoan { } fn ensure_can_close(&self) -> DispatchResult { - let can_close = match &self.pricing { - ActivePricing::Internal(inner) => !inner.interest.has_debt(), - ActivePricing::External(inner) => !inner.interest.has_debt(), - }; - - ensure!(can_close, Error::::from(CloseLoanError::NotFullyRepaid)); + ensure!( + !self.pricing.interest().has_debt(), + Error::::from(CloseLoanError::NotFullyRepaid) + ); Ok(()) } @@ -518,10 +519,7 @@ impl ActiveLoan { .maturity .extends(extension) .map_err(|_| Error::::from(MutationError::MaturityExtendedTooMuch))?, - LoanMutation::InterestRate(rate) => match &mut self.pricing { - ActivePricing::Internal(inner) => inner.interest.set_base_rate(rate)?, - ActivePricing::External(inner) => inner.interest.set_base_rate(rate)?, - }, + LoanMutation::InterestRate(rate) => self.pricing.interest_mut().set_base_rate(rate)?, LoanMutation::InterestPayments(payments) => self.schedule.interest_payments = payments, LoanMutation::PayDownSchedule(schedule) => self.schedule.pay_down_schedule = schedule, LoanMutation::Internal(mutation) => match &mut self.pricing { diff --git a/pallets/loans/src/entities/pricing.rs b/pallets/loans/src/entities/pricing.rs index 557b6b88a8..799feb35b8 100644 --- a/pallets/loans/src/entities/pricing.rs +++ b/pallets/loans/src/entities/pricing.rs @@ -2,7 +2,7 @@ use frame_support::RuntimeDebugNoBound; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; -use crate::pallet::Config; +use crate::{entities::interest::ActiveInterestRate, pallet::Config}; pub mod external; pub mod internal; @@ -28,3 +28,19 @@ pub enum ActivePricing { /// Internal attributes External(external::ExternalActivePricing), } + +impl ActivePricing { + pub fn interest(&self) -> &ActiveInterestRate { + match self { + Self::Internal(inner) => &inner.interest, + Self::External(inner) => &inner.interest, + } + } + + pub fn interest_mut(&mut self) -> &mut ActiveInterestRate { + match self { + Self::Internal(inner) => &mut inner.interest, + Self::External(inner) => &mut inner.interest, + } + } +} diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index 14bc7be4ea..25683e10bb 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -1210,9 +1210,20 @@ pub mod pallet { ActiveLoans::::get(pool_id) .into_iter() .find(|(id, _)| *id == loan_id) - .map(|(_, loan)| (pool_id, loan).try_into()) + .map(|(_, loan)| ActiveLoanInfo::try_from((pool_id, loan))) .transpose() } + + pub fn cashflow( + pool_id: T::PoolId, + loan_id: T::LoanId, + ) -> Result, DispatchError> { + ActiveLoans::::get(pool_id) + .into_iter() + .find(|(id, _)| *id == loan_id) + .map(|(_, loan)| loan.cashflow()) + .ok_or(Error::::LoanNotActiveOrNotFound)? + } } // TODO: This implementation can be cleaned once #908 be solved diff --git a/runtime/common/src/apis/loans.rs b/runtime/common/src/apis/loans.rs index def6a6759a..6bb298a917 100644 --- a/runtime/common/src/apis/loans.rs +++ b/runtime/common/src/apis/loans.rs @@ -11,6 +11,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +use cfg_traits::Seconds; use parity_scale_codec::Codec; use sp_api::decl_runtime_apis; use sp_runtime::DispatchError; @@ -18,7 +19,7 @@ use sp_std::vec::Vec; decl_runtime_apis! { /// Runtime API for the rewards pallet. - #[api_version(2)] + #[api_version(3)] pub trait LoansApi where PoolId: Codec, @@ -30,5 +31,6 @@ decl_runtime_apis! { fn portfolio(pool_id: PoolId) -> Vec<(LoanId, Loan)>; fn portfolio_loan(pool_id: PoolId, loan_id: LoanId) -> Option; fn portfolio_valuation(pool_id: PoolId, input_prices: PriceCollectionInput) -> Result; + fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result, DispatchError>; } } From db17ebc77482a87c7a9aba51199025e49cad79fc Mon Sep 17 00:00:00 2001 From: lemunozm Date: Fri, 19 Apr 2024 19:57:49 +0200 Subject: [PATCH 05/28] update types.md --- pallets/loans/docs/types.md | 39 ++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/pallets/loans/docs/types.md b/pallets/loans/docs/types.md index 3ec16a509c..be9476fc4b 100644 --- a/pallets/loans/docs/types.md +++ b/pallets/loans/docs/types.md @@ -4,28 +4,31 @@ set namespaceSeparator :: hide methods -enum Maturity { - Fixed::date: Seconds - Fixed::extension: Seconds -} +package cashflow { + enum Maturity { + Fixed::date: Seconds + Fixed::extension: Seconds + } -enum InterestPayments { - None -} + enum InterestPayments { + None + Monthly::reference_day: u8 + } -enum PayDownSchedule { - None -} + enum PayDownSchedule { + None + } -class RepaymentSchedule { - maturity: Maturity - interest_payments: InterestPayments - pay_down_schedule: PayDownSchedule -} + class RepaymentSchedule { + maturity: Maturity + interest_payments: InterestPayments + pay_down_schedule: PayDownSchedule + } -RepaymentSchedule *--> Maturity -RepaymentSchedule *---> PayDownSchedule -RepaymentSchedule *----> InterestPayments + RepaymentSchedule *--> Maturity + RepaymentSchedule *--> PayDownSchedule + RepaymentSchedule *--> InterestPayments +} enum BorrowRestrictions { NoWrittenOff From 85eb05f634e9916c6aaf20d953afda049f8c528b Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 25 Apr 2024 10:47:03 +0100 Subject: [PATCH 06/28] make it works without chronoutils --- Cargo.lock | 10 --- Cargo.toml | 1 - pallets/loans/Cargo.toml | 1 - pallets/loans/src/entities/loans.rs | 2 +- pallets/loans/src/types/cashflow.rs | 109 ++++++++++++++++------------ runtime/altair/src/lib.rs | 6 +- runtime/centrifuge/src/lib.rs | 6 +- runtime/development/src/lib.rs | 6 +- 8 files changed, 78 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bf1916ebb..f91bd4bdfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,15 +1723,6 @@ dependencies = [ "windows-targets 0.52.4", ] -[[package]] -name = "chronoutil" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa531c9c2b0e6168a6e4c5023cd38e8d5ab009d3a10459cd3e7baecd68fc3715" -dependencies = [ - "chrono", -] - [[package]] name = "cid" version = "0.9.0" @@ -8104,7 +8095,6 @@ dependencies = [ "cfg-types", "cfg-utils", "chrono", - "chronoutil", "frame-benchmarking", "frame-support", "frame-system", diff --git a/Cargo.toml b/Cargo.toml index cd58c59256..bafc1e0612 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,7 +85,6 @@ impl-trait-for-tuples = "0.2.1" num-traits = { version = "0.2", default-features = false } num_enum = { version = "0.5.3", default-features = false } chrono = { version = "0.4", default-features = false } -chronoutil = "0.2" # Cumulus cumulus-pallet-aura-ext = { git = "https://github.com/paritytech/polkadot-sdk", default-features = false, branch = "release-polkadot-v1.1.0" } diff --git a/pallets/loans/Cargo.toml b/pallets/loans/Cargo.toml index e4008ece4d..f505a74dbe 100644 --- a/pallets/loans/Cargo.toml +++ b/pallets/loans/Cargo.toml @@ -16,7 +16,6 @@ targets = ["x86_64-unknown-linux-gnu"] parity-scale-codec = { workspace = true } scale-info = { workspace = true } chrono = { workspace = true } -chronoutil = { workspace = true } frame-support = { workspace = true } frame-system = { workspace = true } diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index dbe3b55c3e..6a99dd20a4 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -14,7 +14,7 @@ use sp_runtime::{ }, DispatchError, }; -use sp_std::collections::btree_map::BTreeMap; +use sp_std::{collections::btree_map::BTreeMap, vec::Vec}; use crate::{ entities::{ diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index 4a50157382..af5353d6e1 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -12,8 +12,7 @@ // GNU General Public License for more details. use cfg_traits::{interest::InterestRate, Seconds}; -use chrono::{DateTime, NaiveDate}; -use chronoutil::DateRule; +use chrono::{DateTime, Datelike, NaiveDate}; use frame_support::pallet_prelude::RuntimeDebug; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; @@ -23,6 +22,13 @@ use sp_runtime::{ }, ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, }; +use sp_std::{cmp::min, vec, vec::Vec}; + +// By now only "day 1" of the month is supported for monthly cashflows. +// Modifying this value would make `monthly_dates()` and +// `monthly_dates_intervals()` to no longer work as expected. +// Supporting more reference dates will imply more logic related to dates. +const REFERENCE_DAY_1: u32 = 1; /// Specify the expected repayments date #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] @@ -78,6 +84,15 @@ fn date_to_seconds(date: NaiveDate) -> Result { .ensure_into()?) } +fn next_month_with_day(date: NaiveDate, day: u32) -> Option { + let (month, year) = match date.month() { + 12 => (1, date.year() + 1), + n => (n + 1, date.year()), + }; + + NaiveDate::from_ymd_opt(year, month, day) +} + /// Interest payment periods #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] pub enum InterestPayments { @@ -161,30 +176,37 @@ fn monthly_dates( end_date: NaiveDate, reference_day: u32, ) -> Result, DispatchError> { - if start_date > end_date { + if start_date >= end_date { return Err(DispatchError::Other("Cashflow must start before it ends")); } - let mut dates = DateRule::monthly(start_date) - .with_end(end_date) - .with_rolling_day(reference_day) - .map_err(|_| DispatchError::Other("Paydown day must be less than 31 days"))? - .into_iter() - .collect::>(); - - // We want to skip the first month if the date is before the starting date - if let Some(first) = dates.first() { - if *first <= start_date { - dates.remove(0); - } + if reference_day != REFERENCE_DAY_1 { + return Err(DispatchError::Other( + "Only day 1 as reference is supported by now", + )); } - // We want to add the end date as last date if the previous last date is before - // end date - if let Some(last) = dates.last() { - if *last < end_date { - dates.push(end_date); + let first_date = + next_month_with_day(start_date, REFERENCE_DAY_1).ok_or("must be a correct date, qed")?; + + let mut dates = vec![min(first_date, end_date)]; + + loop { + let last = dates + .last() + .ok_or(DispatchError::Other("must be a last date, qed"))?; + + let next = + next_month_with_day(*last, REFERENCE_DAY_1).ok_or("must be a correct date, qed")?; + + if next >= end_date { + if *last < end_date { + dates.push(end_date); + } + break; } + + dates.push(next); } Ok(dates) @@ -204,13 +226,16 @@ fn monthly_dates_intervals( .enumerate() .map(|(i, date)| { let days = match i { - 0 => (date - start_date).num_days(), + 0 if last_index == 0 => end_date.day() - REFERENCE_DAY_1, + 0 if start_date.day() == REFERENCE_DAY_1 => 30, + 0 => (date - start_date).num_days().ensure_into()?, + n if n == last_index && end_date.day() == REFERENCE_DAY_1 => 30, n if n == last_index => { let prev_date = monthly_dates .get(n.ensure_sub(1)?) .ok_or(DispatchError::Other("n > 1. qed"))?; - (date - *prev_date).num_days() + (date - *prev_date).num_days().ensure_into()? } _ => 30, }; @@ -276,50 +301,40 @@ mod tests { (from_ymd(2022, 4, 20), Rate::from((19, 30))), ] ); - - assert_ok!( - monthly_dates_intervals(from_ymd(2022, 1, 20), from_ymd(2022, 4, 20), 31), - vec![ - (from_ymd(2022, 1, 31), Rate::from((11, 30))), - (from_ymd(2022, 2, 28), Rate::one()), - (from_ymd(2022, 3, 31), Rate::one()), - (from_ymd(2022, 4, 20), Rate::from((20, 30))), - ] - ); } #[test] fn one_day() { assert_err!( - monthly_dates(from_ymd(2022, 1, 20), from_ymd(2022, 1, 20), 31), - DispatchError::Arithmetic(ArithmeticError::Underflow), + monthly_dates(from_ymd(2022, 1, 20), from_ymd(2022, 1, 20), 1), + DispatchError::Other("Cashflow must start before it ends") ); } #[test] - fn same_month() { - assert_ok!( - monthly_dates_intervals(from_ymd(2022, 1, 10), from_ymd(2022, 1, 15), 20), - vec![(from_ymd(2022, 1, 15), Rate::from((5, 30)))] + fn unsupported_reference_day() { + assert_err!( + monthly_dates(from_ymd(2022, 1, 20), from_ymd(2022, 4, 20), 2), + DispatchError::Other("Only day 1 as reference is supported by now") ); + } + #[test] + fn start_and_end_same_day_as_reference_day() { assert_ok!( - monthly_dates_intervals(from_ymd(2022, 1, 10), from_ymd(2022, 1, 20), 15), + monthly_dates_intervals(from_ymd(2022, 1, 1), from_ymd(2022, 3, 1), 1), vec![ - (from_ymd(2022, 1, 15), Rate::from((5, 30))), - (from_ymd(2022, 1, 20), Rate::from((5, 30))), + (from_ymd(2022, 2, 1), Rate::one()), + (from_ymd(2022, 3, 1), Rate::one()), ] ); } #[test] - fn same_day_as_paydown_day() { + fn same_month() { assert_ok!( - monthly_dates_intervals(from_ymd(2022, 1, 15), from_ymd(2022, 3, 15), 15), - vec![ - (from_ymd(2022, 2, 15), Rate::one()), - (from_ymd(2022, 3, 15), Rate::one()), - ] + monthly_dates_intervals(from_ymd(2022, 1, 1), from_ymd(2022, 1, 15), 1), + vec![(from_ymd(2022, 1, 15), Rate::from((14, 30)))] ); } } diff --git a/runtime/altair/src/lib.rs b/runtime/altair/src/lib.rs index 4aa5e3bf40..f41605438c 100644 --- a/runtime/altair/src/lib.rs +++ b/runtime/altair/src/lib.rs @@ -121,7 +121,7 @@ use sp_runtime::{ ApplyExtrinsicResult, DispatchError, DispatchResult, FixedI128, Perbill, Permill, Perquintill, }; use sp_staking::currency_to_vote::U128CurrencyToVote; -use sp_std::{marker::PhantomData, prelude::*}; +use sp_std::{marker::PhantomData, prelude::*, vec::Vec}; use sp_version::RuntimeVersion; use staging_xcm_executor::XcmExecutor; use static_assertions::const_assert; @@ -2350,6 +2350,10 @@ impl_runtime_apis! { ) -> Result { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } + + fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result, DispatchError> { + Loans::cashflow(pool_id, loan_id) + } } diff --git a/runtime/centrifuge/src/lib.rs b/runtime/centrifuge/src/lib.rs index f31ab882d1..c372a136f3 100644 --- a/runtime/centrifuge/src/lib.rs +++ b/runtime/centrifuge/src/lib.rs @@ -124,7 +124,7 @@ use sp_runtime::{ ApplyExtrinsicResult, FixedI128, Perbill, Permill, Perquintill, }; use sp_staking::currency_to_vote::U128CurrencyToVote; -use sp_std::{marker::PhantomData, prelude::*}; +use sp_std::{marker::PhantomData, prelude::*, vec::Vec}; use sp_version::RuntimeVersion; use staging_xcm_executor::XcmExecutor; use static_assertions::const_assert; @@ -2398,6 +2398,10 @@ impl_runtime_apis! { ) -> Result { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } + + fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result, DispatchError> { + Loans::cashflow(pool_id, loan_id) + } } // Investment Runtime APIs diff --git a/runtime/development/src/lib.rs b/runtime/development/src/lib.rs index 3f85a22f85..eb2412a37b 100644 --- a/runtime/development/src/lib.rs +++ b/runtime/development/src/lib.rs @@ -128,7 +128,7 @@ use sp_runtime::{ ApplyExtrinsicResult, FixedI128, Perbill, Permill, Perquintill, }; use sp_staking::currency_to_vote::U128CurrencyToVote; -use sp_std::{marker::PhantomData, prelude::*}; +use sp_std::{marker::PhantomData, prelude::*, vec::Vec}; use sp_version::RuntimeVersion; use staging_xcm_executor::XcmExecutor; use static_assertions::const_assert; @@ -2437,6 +2437,10 @@ impl_runtime_apis! { ) -> Result { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } + + fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result, DispatchError> { + Loans::cashflow(pool_id, loan_id) + } } // Investment Runtime APIs From bd61fea539f89d532e2cacf51c7b0a4f33a5698a Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 25 Apr 2024 12:22:35 +0100 Subject: [PATCH 07/28] minor changes --- pallets/loans/src/types/cashflow.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index af5353d6e1..79256a4dd7 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -28,7 +28,7 @@ use sp_std::{cmp::min, vec, vec::Vec}; // Modifying this value would make `monthly_dates()` and // `monthly_dates_intervals()` to no longer work as expected. // Supporting more reference dates will imply more logic related to dates. -const REFERENCE_DAY_1: u32 = 1; +const DAY_1: u32 = 1; /// Specify the expected repayments date #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] @@ -180,24 +180,18 @@ fn monthly_dates( return Err(DispatchError::Other("Cashflow must start before it ends")); } - if reference_day != REFERENCE_DAY_1 { + if reference_day != DAY_1 { return Err(DispatchError::Other( "Only day 1 as reference is supported by now", )); } - let first_date = - next_month_with_day(start_date, REFERENCE_DAY_1).ok_or("must be a correct date, qed")?; + let first_date = next_month_with_day(start_date, DAY_1).ok_or("it's a correct date, qed")?; let mut dates = vec![min(first_date, end_date)]; - loop { - let last = dates - .last() - .ok_or(DispatchError::Other("must be a last date, qed"))?; - - let next = - next_month_with_day(*last, REFERENCE_DAY_1).ok_or("must be a correct date, qed")?; + let last = dates.last().ok_or("must be a last date, qed")?; + let next = next_month_with_day(*last, DAY_1).ok_or("it's a correct date, qed")?; if next >= end_date { if *last < end_date { @@ -226,10 +220,10 @@ fn monthly_dates_intervals( .enumerate() .map(|(i, date)| { let days = match i { - 0 if last_index == 0 => end_date.day() - REFERENCE_DAY_1, - 0 if start_date.day() == REFERENCE_DAY_1 => 30, + 0 if last_index == 0 => end_date.day().ensure_sub(DAY_1)?, + 0 if start_date.day() == DAY_1 => 30, 0 => (date - start_date).num_days().ensure_into()?, - n if n == last_index && end_date.day() == REFERENCE_DAY_1 => 30, + n if n == last_index && end_date.day() == DAY_1 => 30, n if n == last_index => { let prev_date = monthly_dates .get(n.ensure_sub(1)?) From abc3ef0ea01b9c2448005b36e2cc10d40185a396 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 25 Apr 2024 15:41:06 +0100 Subject: [PATCH 08/28] add borrow check --- pallets/loans/src/entities/loans.rs | 28 +++++++++--------- pallets/loans/src/types/cashflow.rs | 44 ++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 6a99dd20a4..dff883539e 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -357,19 +357,19 @@ impl ActiveLoan { Error::::from(BorrowLoanError::MaturityDatePassed) ); - // TODO - // 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 + if self.schedule.has_cashflow() { + let expected_payment = self.schedule.expected_payment( + self.origination_date, + self.principal()?, + self.pricing.interest().rate(), + now, + )?; + + ensure!( + self.total_repaid.effective()? >= expected_payment, + DispatchError::Other("payment overdue") + ) + } Ok(()) } @@ -388,6 +388,8 @@ impl ActiveLoan { } } + self.repayments_on_schedule_until = T::Time::now(); + Ok(()) } diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index 79256a4dd7..d86f576fa4 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -18,7 +18,8 @@ use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::{ traits::{ - EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureInto, EnsureSub, EnsureSubAssign, + EnsureAdd, EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureInto, EnsureSub, + EnsureSubAssign, }, ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, }; @@ -103,6 +104,8 @@ pub enum InterestPayments { /// The associated value correspond to the paydown day in the month, /// from 1-31. /// The day will be adjusted to the month. + /// + /// NOTE: Only day 1 is supported by now Monthly(u8), } @@ -133,6 +136,20 @@ impl RepaymentSchedule { self.maturity.is_valid(now) } + pub fn has_cashflow(&self) -> bool { + let has_interest_payments = match self.interest_payments { + InterestPayments::None => false, + _ => true, + }; + + let has_pay_down_schedule = match self.pay_down_schedule { + PayDownSchedule::None => false, + _ => true, + }; + + has_interest_payments || has_pay_down_schedule + } + pub fn generate_cashflows( &self, origination_date: Seconds, @@ -169,6 +186,30 @@ impl RepaymentSchedule { }) .collect() } + + pub fn expected_payment( + &self, + origination_date: Seconds, + principal: Balance, + interest_rate: &InterestRate, + until: Seconds, + ) -> Result + where + Balance: FixedPointOperand + EnsureAdd, + Rate: FixedPointNumber, + { + let cashflow = self.generate_cashflows(origination_date, principal, interest_rate)?; + + let until_date = seconds_to_date(until)?; + + let total_amount = cashflow + .iter() + .take_while(|(date, _)| *date < until) + .map(|(_, amount)| amount) + .try_fold(Balance::zero(), |a, b| a.ensure_add(*b))?; + + Ok(total_amount) + } } fn monthly_dates( @@ -268,6 +309,7 @@ mod tests { from_ymd(year, month, day) .and_hms_opt(hour, min, seconds) .unwrap() + .and_utc() .timestamp() as Seconds } From 2de713b65aeef9eb7677b1bd124654dc6004b1dd Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 25 Apr 2024 17:23:21 +0100 Subject: [PATCH 09/28] fix legacy increase_debt test --- pallets/loans/src/tests/borrow_loan.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index 20f51c8ae3..f789a2efff 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -586,10 +586,6 @@ fn twice_with_elapsed_time() { #[test] fn increase_debt_does_not_withdraw() { new_test_ext().execute_with(|| { - MockPools::mock_withdraw(|_, _, _| { - unreachable!("increase debt must not withdraw funds from the pool"); - }); - let loan = LoanInfo { pricing: Pricing::External(ExternalPricing { max_borrow_amount: ExtMaxBorrowAmount::NoLimit, @@ -603,7 +599,11 @@ fn increase_debt_does_not_withdraw() { let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); config_mocks(amount.balance().unwrap()); - assert_ok!(Loans::borrow( + MockPools::mock_withdraw(|_, _, _| { + unreachable!("increase debt must not withdraw funds from the pool"); + }); + + assert_ok!(Loans::increase_debt( RuntimeOrigin::signed(BORROWER), POOL_A, loan_id, From 87a0d07defac847e419b059285431eaeafa5265f Mon Sep 17 00:00:00 2001 From: lemunozm Date: Fri, 26 Apr 2024 13:00:45 +0100 Subject: [PATCH 10/28] add loan cashflow tests --- pallets/loans/src/entities/loans.rs | 4 + pallets/loans/src/tests/borrow_loan.rs | 156 +++++++++++++++++++++++++ pallets/loans/src/tests/mod.rs | 4 +- pallets/loans/src/types/cashflow.rs | 9 +- 4 files changed, 167 insertions(+), 6 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index dff883539e..a35829b045 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -223,6 +223,10 @@ impl ActiveLoan { &self.borrower } + pub fn origination_date(&self) -> Seconds { + self.origination_date + } + pub fn maturity_date(&self) -> Seconds { self.schedule.maturity.date() } diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index f789a2efff..ffa44c5118 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -611,3 +611,159 @@ fn increase_debt_does_not_withdraw() { )); }); } + +mod cashflow { + use super::*; + + fn create_cashflow_loan() -> LoanId { + util::create_loan(LoanInfo { + schedule: RepaymentSchedule { + maturity: Maturity::Fixed { + date: (now() + YEAR).as_secs(), + extension: 0, + }, + interest_payments: InterestPayments::Monthly(1), + pay_down_schedule: PayDownSchedule::None, + }, + ..util::base_internal_loan() + }) + } + + #[test] + fn computed_correctly() { + new_test_ext().execute_with(|| { + let loan_id = create_cashflow_loan(); + + config_mocks(COLLATERAL_VALUE / 2); + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE / 2) + )); + + let loan = util::get_loan(loan_id); + + assert_eq!( + loan.origination_date(), + secs_from_ymdhms(1970, 1, 1, 0, 0, 10) + ); + assert_eq!(loan.maturity_date(), secs_from_ymdhms(1971, 1, 1, 0, 0, 10)); + + let month_value = COLLATERAL_VALUE / 2 / 12 / 2 /* due to 0.5 of interest */; + assert_ok!( + loan.cashflow(), + vec![ + (secs_from_ymdhms(1970, 2, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 3, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 4, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 5, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 6, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 7, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 8, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 9, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 10, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 11, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1970, 12, 1, 23, 59, 59), month_value), + (secs_from_ymdhms(1971, 1, 1, 23, 59, 59), month_value), + ] + ); + }); + } + + #[test] + fn borrow_twice_same_month() { + new_test_ext().execute_with(|| { + let loan_id = create_cashflow_loan(); + + config_mocks(COLLATERAL_VALUE / 2); + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE / 2) + )); + + let cashflow = util::get_loan(loan_id).cashflow().unwrap(); + + let time_until_next_month = Duration::from_secs(cashflow[0].0) - now(); + advance_time(time_until_next_month); + + config_mocks(COLLATERAL_VALUE / 4); + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE / 4) + )); + }); + } + + #[test] + fn payment_overdue() { + new_test_ext().execute_with(|| { + let loan_id = create_cashflow_loan(); + + config_mocks(COLLATERAL_VALUE / 2); + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE / 2) + )); + + let cashflow = util::get_loan(loan_id).cashflow().unwrap(); + + let time_until_next_month = Duration::from_secs(cashflow[0].0) - now(); + advance_time(time_until_next_month); + + // Start of the next month + advance_time(Duration::from_secs(1)); + + config_mocks(COLLATERAL_VALUE / 4); + assert_noop!( + Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE / 4) + ), + DispatchError::Other("payment overdue") + ); + }); + } + + #[test] + fn allow_borrow_again_after_repay_overdue_amount() { + new_test_ext().execute_with(|| { + let loan_id = create_cashflow_loan(); + + config_mocks(COLLATERAL_VALUE / 2); + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE / 2) + )); + + let cashflow = util::get_loan(loan_id).cashflow().unwrap(); + + let time_until_next_month = Duration::from_secs(cashflow[0].0) - now(); + advance_time(time_until_next_month); + + // Start of the next month + advance_time(Duration::from_secs(1)); + + // Repaying the overdue amount allow to borrow again + util::repay_loan(loan_id, PrincipalInput::Internal(COLLATERAL_VALUE / 2)); + + config_mocks(COLLATERAL_VALUE / 4); + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE / 4) + )); + }); + } +} diff --git a/pallets/loans/src/tests/mod.rs b/pallets/loans/src/tests/mod.rs index 4cae106046..3b69bdfdc0 100644 --- a/pallets/loans/src/tests/mod.rs +++ b/pallets/loans/src/tests/mod.rs @@ -20,7 +20,9 @@ use super::{ }, pallet::{ActiveLoans, CreatedLoan, Error, LastLoanId, PortfolioValuation}, types::{ - cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, + cashflow::{ + tests::secs_from_ymdhms, InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule, + }, policy::{WriteOffRule, WriteOffStatus, WriteOffTrigger}, valuation::{DiscountedCashFlow, ValuationMethod}, BorrowLoanError, BorrowRestrictions, CloseLoanError, CreateLoanError, LoanRestrictions, diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index d86f576fa4..e5f30869f4 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -78,7 +78,7 @@ fn seconds_to_date(date_in_seconds: Seconds) -> Result fn date_to_seconds(date: NaiveDate) -> Result { Ok(date - .and_hms_opt(0, 0, 0) + .and_hms_opt(23, 59, 59) // Until the last second on the day .ok_or(DispatchError::Other("Invalid h/m/s"))? .and_utc() .timestamp() @@ -142,6 +142,7 @@ impl RepaymentSchedule { _ => true, }; + #[allow(unreachable_patterns)] // Remove when pay_down_schedule has more than `None` let has_pay_down_schedule = match self.pay_down_schedule { PayDownSchedule::None => false, _ => true, @@ -200,8 +201,6 @@ impl RepaymentSchedule { { let cashflow = self.generate_cashflows(origination_date, principal, interest_rate)?; - let until_date = seconds_to_date(until)?; - let total_amount = cashflow .iter() .take_while(|(date, _)| *date < until) @@ -281,7 +280,7 @@ fn monthly_dates_intervals( } #[cfg(test)] -mod tests { +pub mod tests { use cfg_traits::interest::CompoundingSchedule; use frame_support::{assert_err, assert_ok}; use sp_runtime::traits::One; @@ -298,7 +297,7 @@ mod tests { secs_from_ymdhms(year, month, day, 0, 0, 0) } - fn secs_from_ymdhms( + pub fn secs_from_ymdhms( year: i32, month: u32, day: u32, From 4c56cb25c67e2baace62d1d193793817dd986b61 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Sat, 27 Apr 2024 05:11:47 +0100 Subject: [PATCH 11/28] compute principal and interest --- pallets/loans/src/entities/loans.rs | 2 +- pallets/loans/src/lib.rs | 4 +- pallets/loans/src/tests/borrow_loan.rs | 28 ++-- pallets/loans/src/tests/mod.rs | 3 +- pallets/loans/src/types/cashflow.rs | 219 +++++++++++++------------ 5 files changed, 134 insertions(+), 122 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index a35829b045..179d5b15b2 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -248,7 +248,7 @@ impl ActiveLoan { .ensure_sub(self.total_repaid.principal)?) } - pub fn cashflow(&self) -> Result, DispatchError> { + pub fn cashflow(&self) -> Result, DispatchError> { self.schedule.generate_cashflows( self.origination_date, self.principal()?, diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index 25683e10bb..812a815186 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -1199,7 +1199,7 @@ pub mod pallet { ) -> Result, DispatchError> { ActiveLoans::::get(pool_id) .into_iter() - .map(|(loan_id, loan)| Ok((loan_id, (pool_id, loan).try_into()?))) + .map(|(loan_id, loan)| Ok((loan_id, ActiveLoanInfo::try_from((pool_id, loan))?))) .collect() } @@ -1217,7 +1217,7 @@ pub mod pallet { pub fn cashflow( pool_id: T::PoolId, loan_id: T::LoanId, - ) -> Result, DispatchError> { + ) -> Result, DispatchError> { ActiveLoans::::get(pool_id) .into_iter() .find(|(id, _)| *id == loan_id) diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index ffa44c5118..d8bed3c41d 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -650,22 +650,24 @@ mod cashflow { ); assert_eq!(loan.maturity_date(), secs_from_ymdhms(1971, 1, 1, 0, 0, 10)); - let month_value = COLLATERAL_VALUE / 2 / 12 / 2 /* due to 0.5 of interest */; + let principal = COLLATERAL_VALUE / 2 / 12; + let interest = Rate::from_float(DEFAULT_INTEREST_RATE).saturating_mul_int(principal); + assert_ok!( loan.cashflow(), vec![ - (secs_from_ymdhms(1970, 2, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 3, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 4, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 5, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 6, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 7, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 8, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 9, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 10, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 11, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1970, 12, 1, 23, 59, 59), month_value), - (secs_from_ymdhms(1971, 1, 1, 23, 59, 59), month_value), + (last_secs_from_ymd(1970, 2, 1), principal, interest), + (last_secs_from_ymd(1970, 3, 1), principal, interest), + (last_secs_from_ymd(1970, 4, 1), principal, interest), + (last_secs_from_ymd(1970, 5, 1), principal, interest), + (last_secs_from_ymd(1970, 6, 1), principal, interest), + (last_secs_from_ymd(1970, 7, 1), principal, interest), + (last_secs_from_ymd(1970, 8, 1), principal, interest), + (last_secs_from_ymd(1970, 9, 1), principal, interest), + (last_secs_from_ymd(1970, 10, 1), principal, interest), + (last_secs_from_ymd(1970, 11, 1), principal, interest), + (last_secs_from_ymd(1970, 12, 1), principal, interest), + (last_secs_from_ymd(1971, 1, 1), principal, interest), ] ); }); diff --git a/pallets/loans/src/tests/mod.rs b/pallets/loans/src/tests/mod.rs index 3b69bdfdc0..0e6e78b4b5 100644 --- a/pallets/loans/src/tests/mod.rs +++ b/pallets/loans/src/tests/mod.rs @@ -21,7 +21,8 @@ use super::{ pallet::{ActiveLoans, CreatedLoan, Error, LastLoanId, PortfolioValuation}, types::{ cashflow::{ - tests::secs_from_ymdhms, InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule, + tests::{last_secs_from_ymd, secs_from_ymdhms}, + InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule, }, policy::{WriteOffRule, WriteOffStatus, WriteOffTrigger}, valuation::{DiscountedCashFlow, ValuationMethod}, diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index e5f30869f4..60ee600bb3 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -18,8 +18,7 @@ use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::{ traits::{ - EnsureAdd, EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureInto, EnsureSub, - EnsureSubAssign, + EnsureAdd, EnsureAddAssign, EnsureFixedPointNumber, EnsureInto, EnsureSub, EnsureSubAssign, }, ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, }; @@ -27,7 +26,7 @@ use sp_std::{cmp::min, vec, vec::Vec}; // By now only "day 1" of the month is supported for monthly cashflows. // Modifying this value would make `monthly_dates()` and -// `monthly_dates_intervals()` to no longer work as expected. +// `monthly_intervals()` to no longer work as expected. // Supporting more reference dates will imply more logic related to dates. const DAY_1: u32 = 1; @@ -70,30 +69,6 @@ impl Maturity { } } -fn seconds_to_date(date_in_seconds: Seconds) -> Result { - Ok(DateTime::from_timestamp(date_in_seconds.ensure_into()?, 0) - .ok_or(DispatchError::Other("Invalid date in seconds"))? - .date_naive()) -} - -fn date_to_seconds(date: NaiveDate) -> Result { - Ok(date - .and_hms_opt(23, 59, 59) // Until the last second on the day - .ok_or(DispatchError::Other("Invalid h/m/s"))? - .and_utc() - .timestamp() - .ensure_into()?) -} - -fn next_month_with_day(date: NaiveDate, day: u32) -> Option { - let (month, year) = match date.month() { - 12 => (1, date.year() + 1), - n => (n + 1, date.year()), - }; - - NaiveDate::from_ymd_opt(year, month, day) -} - /// Interest payment periods #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] pub enum InterestPayments { @@ -156,33 +131,36 @@ impl RepaymentSchedule { origination_date: Seconds, principal: Balance, interest_rate: &InterestRate, - ) -> Result, DispatchError> + ) -> Result, DispatchError> where Balance: FixedPointOperand, Rate: FixedPointNumber, { - let start_date = seconds_to_date(origination_date)?; - let end_date = seconds_to_date(self.maturity.date())?; + let start_date = date::from_seconds(origination_date)?; + let end_date = date::from_seconds(self.maturity.date())?; let (timeflow, periods_per_year) = match &self.interest_payments { InterestPayments::None => (vec![], 1), InterestPayments::Monthly(reference_day) => ( - monthly_dates_intervals::(start_date, end_date, (*reference_day).into())?, + date::monthly_intervals::(start_date, end_date, (*reference_day).into())?, 12, ), }; - let amount_per_period = interest_rate + let principal_per_period = + Rate::ensure_from_rational(1, periods_per_year)?.ensure_mul_int(principal)?; + + let interest_per_period = interest_rate .per_year() - .ensure_div(Rate::saturating_from_integer(periods_per_year))? - .ensure_mul_int(principal)?; + .ensure_mul_int(principal_per_period)?; timeflow .into_iter() .map(|(date, interval)| { Ok(( - date_to_seconds(date)?, - interval.ensure_mul_int(amount_per_period)?, + date::into_seconds(date)?, + interval.ensure_mul_int(principal_per_period)?, + interval.ensure_mul_int(interest_per_period)?, )) }) .collect() @@ -202,81 +180,112 @@ impl RepaymentSchedule { let cashflow = self.generate_cashflows(origination_date, principal, interest_rate)?; let total_amount = cashflow - .iter() - .take_while(|(date, _)| *date < until) - .map(|(_, amount)| amount) - .try_fold(Balance::zero(), |a, b| a.ensure_add(*b))?; + .into_iter() + .take_while(|(date, _, _)| *date < until) + .map(|(_, principal_amount, interest_amount)| { + principal_amount.ensure_add(interest_amount) + }) + .try_fold(Balance::zero(), |a, b| a.ensure_add(b?))?; Ok(total_amount) } } -fn monthly_dates( - start_date: NaiveDate, - end_date: NaiveDate, - reference_day: u32, -) -> Result, DispatchError> { - if start_date >= end_date { - return Err(DispatchError::Other("Cashflow must start before it ends")); +mod date { + use super::*; + + pub fn from_seconds(date_in_seconds: Seconds) -> Result { + Ok(DateTime::from_timestamp(date_in_seconds.ensure_into()?, 0) + .ok_or(DispatchError::Other("Invalid date in seconds"))? + .date_naive()) } - if reference_day != DAY_1 { - return Err(DispatchError::Other( - "Only day 1 as reference is supported by now", - )); + pub fn into_seconds(date: NaiveDate) -> Result { + Ok(date + .and_hms_opt(23, 59, 59) // Until the last second on the day + .ok_or(DispatchError::Other("Invalid h/m/s"))? + .and_utc() + .timestamp() + .ensure_into()?) } - let first_date = next_month_with_day(start_date, DAY_1).ok_or("it's a correct date, qed")?; + pub fn next_month_with_day(date: NaiveDate, day: u32) -> Option { + let (month, year) = match date.month() { + 12 => (1, date.year() + 1), + n => (n + 1, date.year()), + }; - let mut dates = vec![min(first_date, end_date)]; - loop { - let last = dates.last().ok_or("must be a last date, qed")?; - let next = next_month_with_day(*last, DAY_1).ok_or("it's a correct date, qed")?; + NaiveDate::from_ymd_opt(year, month, day) + } - if next >= end_date { - if *last < end_date { - dates.push(end_date); - } - break; + pub fn monthly( + start_date: NaiveDate, + end_date: NaiveDate, + reference_day: u32, + ) -> Result, DispatchError> { + if start_date >= end_date { + return Err(DispatchError::Other("Cashflow must start before it ends")); } - dates.push(next); - } + if reference_day != DAY_1 { + return Err(DispatchError::Other( + "Only day 1 as reference is supported by now", + )); + } - Ok(dates) -} + let first_date = + next_month_with_day(start_date, DAY_1).ok_or("it's a correct date, qed")?; + + let mut dates = vec![min(first_date, end_date)]; + loop { + let last = dates.last().ok_or("must be a last date, qed")?; + let next = next_month_with_day(*last, DAY_1).ok_or("it's a correct date, qed")?; -fn monthly_dates_intervals( - start_date: NaiveDate, - end_date: NaiveDate, - reference_day: u32, -) -> Result, DispatchError> { - let monthly_dates = monthly_dates(start_date, end_date, reference_day)?; - let last_index = monthly_dates.len().ensure_sub(1)?; - - monthly_dates - .clone() - .into_iter() - .enumerate() - .map(|(i, date)| { - let days = match i { - 0 if last_index == 0 => end_date.day().ensure_sub(DAY_1)?, - 0 if start_date.day() == DAY_1 => 30, - 0 => (date - start_date).num_days().ensure_into()?, - n if n == last_index && end_date.day() == DAY_1 => 30, - n if n == last_index => { - let prev_date = monthly_dates - .get(n.ensure_sub(1)?) - .ok_or(DispatchError::Other("n > 1. qed"))?; - - (date - *prev_date).num_days().ensure_into()? + if next >= end_date { + if *last < end_date { + dates.push(end_date); } - _ => 30, - }; + break; + } + + dates.push(next); + } + + Ok(dates) + } + + pub fn monthly_intervals( + start_date: NaiveDate, + end_date: NaiveDate, + reference_day: u32, + ) -> Result, DispatchError> { + let monthly_dates = monthly(start_date, end_date, reference_day)?; + let last_index = monthly_dates.len().ensure_sub(1)?; - Ok((date, Rate::saturating_from_rational(days, 30))) - }) - .collect() + monthly_dates + .clone() + .into_iter() + .enumerate() + .map(|(i, date)| { + let days = match i { + 0 if last_index == 0 => end_date.day().ensure_sub(DAY_1)?, + 0 if start_date.day() == DAY_1 => 30, + 0 => (date - start_date).num_days().ensure_into()?, + n if n == last_index && end_date.day() == DAY_1 => 30, + n if n == last_index => { + let prev_date = monthly_dates + .get(n.ensure_sub(1)?) + .ok_or(DispatchError::Other("n > 1. qed"))?; + + (date - *prev_date).num_days().ensure_into()? + } + _ => 30, + }; + + Ok((date, Rate::saturating_from_rational(days, 30))) + }) + .collect() + } } #[cfg(test)] @@ -293,25 +302,25 @@ pub mod tests { NaiveDate::from_ymd_opt(year, month, day).unwrap() } - fn secs_from_ymd(year: i32, month: u32, day: u32) -> Seconds { - secs_from_ymdhms(year, month, day, 0, 0, 0) - } - pub fn secs_from_ymdhms( year: i32, month: u32, day: u32, hour: u32, min: u32, - seconds: u32, + sec: u32, ) -> Seconds { from_ymd(year, month, day) - .and_hms_opt(hour, min, seconds) + .and_hms_opt(hour, min, sec) .unwrap() .and_utc() .timestamp() as Seconds } + pub fn last_secs_from_ymd(year: i32, month: u32, day: u32) -> Seconds { + secs_from_ymdhms(year, month, day, 23, 59, 59) + } + fn rate_per_year(rate: f32) -> InterestRate { InterestRate::Fixed { rate_per_year: Rate::from_float(0.1), @@ -328,7 +337,7 @@ pub mod tests { #[test] fn basic_list() { assert_ok!( - monthly_dates_intervals(from_ymd(2022, 1, 20), from_ymd(2022, 4, 20), 1), + date::monthly_intervals(from_ymd(2022, 1, 20), from_ymd(2022, 4, 20), 1), vec![ (from_ymd(2022, 2, 1), Rate::from((12, 30))), (from_ymd(2022, 3, 1), Rate::one()), @@ -341,7 +350,7 @@ pub mod tests { #[test] fn one_day() { assert_err!( - monthly_dates(from_ymd(2022, 1, 20), from_ymd(2022, 1, 20), 1), + date::monthly(from_ymd(2022, 1, 20), from_ymd(2022, 1, 20), 1), DispatchError::Other("Cashflow must start before it ends") ); } @@ -349,7 +358,7 @@ pub mod tests { #[test] fn unsupported_reference_day() { assert_err!( - monthly_dates(from_ymd(2022, 1, 20), from_ymd(2022, 4, 20), 2), + date::monthly(from_ymd(2022, 1, 20), from_ymd(2022, 4, 20), 2), DispatchError::Other("Only day 1 as reference is supported by now") ); } @@ -357,7 +366,7 @@ pub mod tests { #[test] fn start_and_end_same_day_as_reference_day() { assert_ok!( - monthly_dates_intervals(from_ymd(2022, 1, 1), from_ymd(2022, 3, 1), 1), + date::monthly_intervals(from_ymd(2022, 1, 1), from_ymd(2022, 3, 1), 1), vec![ (from_ymd(2022, 2, 1), Rate::one()), (from_ymd(2022, 3, 1), Rate::one()), @@ -368,7 +377,7 @@ pub mod tests { #[test] fn same_month() { assert_ok!( - monthly_dates_intervals(from_ymd(2022, 1, 1), from_ymd(2022, 1, 15), 1), + date::monthly_intervals(from_ymd(2022, 1, 1), from_ymd(2022, 1, 15), 1), vec![(from_ymd(2022, 1, 15), Rate::from((14, 30)))] ); } From 76de6d0a089bd3e9f3f04b077034e94a7e381a5d Mon Sep 17 00:00:00 2001 From: lemunozm Date: Tue, 7 May 2024 13:06:44 +0200 Subject: [PATCH 12/28] correct validation --- pallets/loans/src/entities/loans.rs | 2 +- pallets/loans/src/types/cashflow.rs | 45 ++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 179d5b15b2..2823a0e156 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -71,7 +71,7 @@ impl LoanInfo { T::InterestAccrual::validate_rate(&self.interest_rate)?; ensure!( - self.schedule.is_valid(now), + self.schedule.is_valid(now)?, Error::::from(CreateLoanError::InvalidRepaymentSchedule) ); diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index 60ee600bb3..4246c0d66f 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -107,8 +107,22 @@ pub struct RepaymentSchedule { } impl RepaymentSchedule { - pub fn is_valid(&self, now: Seconds) -> bool { - self.maturity.is_valid(now) + pub fn is_valid(&self, now: Seconds) -> Result { + match self.interest_payments { + InterestPayments::None => (), + InterestPayments::Monthly(_) => { + let start = date::from_seconds(now)?; + let end = date::from_seconds(self.maturity.date())?; + + // We want to avoid creating a loan with a cashflow consuming a lot of computing + // time Maximum 40 years, which means a cashflow list of 40 * 12 elements + if end.year() - start.year() > 40 { + return Ok(false); + } + } + } + + Ok(self.maturity.is_valid(now)) } pub fn has_cashflow(&self) -> bool { @@ -196,14 +210,14 @@ mod date { pub fn from_seconds(date_in_seconds: Seconds) -> Result { Ok(DateTime::from_timestamp(date_in_seconds.ensure_into()?, 0) - .ok_or(DispatchError::Other("Invalid date in seconds"))? + .ok_or("Invalid date in seconds, qed")? .date_naive()) } pub fn into_seconds(date: NaiveDate) -> Result { Ok(date .and_hms_opt(23, 59, 59) // Until the last second on the day - .ok_or(DispatchError::Other("Invalid h/m/s"))? + .ok_or("Invalid h/m/s, qed")? .and_utc() .timestamp() .ensure_into()?) @@ -328,6 +342,29 @@ pub mod tests { } } + #[test] + fn repayment_schedule_validation() { + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(last_secs_from_ymd(2040, 1, 1)), + interest_payments: InterestPayments::Monthly(1), + pay_down_schedule: PayDownSchedule::None, + } + .is_valid(last_secs_from_ymd(2000, 1, 1)), + true + ); + + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(last_secs_from_ymd(2041, 1, 1)), + interest_payments: InterestPayments::Monthly(1), + pay_down_schedule: PayDownSchedule::None, + } + .is_valid(last_secs_from_ymd(2000, 1, 1)), + false + ); + } + mod dates { use super::*; From b06d98056fe6366a4ec469f2fb54140a4ba0d383 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Tue, 7 May 2024 14:41:51 +0200 Subject: [PATCH 13/28] add CashflowPayment type --- pallets/loans/src/entities/loans.rs | 28 ++++++------- pallets/loans/src/lib.rs | 3 +- pallets/loans/src/tests/borrow_loan.rs | 14 ++++--- pallets/loans/src/types/cashflow.rs | 40 +++++++------------ runtime/altair/src/lib.rs | 3 +- runtime/centrifuge/src/lib.rs | 3 +- runtime/common/src/apis/loans.rs | 6 +-- runtime/development/src/lib.rs | 3 +- .../src/generic/cases/loans.rs | 2 +- .../integration-tests/src/generic/config.rs | 2 +- 10 files changed, 50 insertions(+), 54 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 2823a0e156..a0cc2322ca 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -27,7 +27,7 @@ use crate::{ }, pallet::{AssetOf, Config, Error}, types::{ - cashflow::RepaymentSchedule, + cashflow::{CashflowPayment, RepaymentSchedule}, policy::{WriteOffStatus, WriteOffTrigger}, BorrowLoanError, BorrowRestrictions, CloseLoanError, CreateLoanError, LoanRestrictions, MutationError, RepaidAmount, RepayLoanError, RepayRestrictions, @@ -248,7 +248,7 @@ impl ActiveLoan { .ensure_sub(self.total_repaid.principal)?) } - pub fn cashflow(&self) -> Result, DispatchError> { + pub fn cashflow(&self) -> Result>, DispatchError> { self.schedule.generate_cashflows( self.origination_date, self.principal()?, @@ -361,19 +361,17 @@ impl ActiveLoan { Error::::from(BorrowLoanError::MaturityDatePassed) ); - if self.schedule.has_cashflow() { - let expected_payment = self.schedule.expected_payment( - self.origination_date, - self.principal()?, - self.pricing.interest().rate(), - now, - )?; - - ensure!( - self.total_repaid.effective()? >= expected_payment, - DispatchError::Other("payment overdue") - ) - } + let expected_payment = self.schedule.expected_payment( + self.origination_date, + self.principal()?, + self.pricing.interest().rate(), + now, + )?; + + ensure!( + self.total_repaid.effective()? >= expected_payment, + DispatchError::Other("payment overdue") + ); Ok(()) } diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index 812a815186..aa32d30867 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -107,6 +107,7 @@ pub mod pallet { use sp_std::{collections::btree_map::BTreeMap, vec, vec::Vec}; use types::{ self, + cashflow::CashflowPayment, policy::{self, WriteOffRule, WriteOffStatus}, BorrowLoanError, CloseLoanError, CreateLoanError, MutationError, RepayLoanError, WrittenOffError, @@ -1217,7 +1218,7 @@ pub mod pallet { pub fn cashflow( pool_id: T::PoolId, loan_id: T::LoanId, - ) -> Result, DispatchError> { + ) -> Result>, DispatchError> { ActiveLoans::::get(pool_id) .into_iter() .find(|(id, _)| *id == loan_id) diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index d8bed3c41d..90645656ee 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -653,8 +653,12 @@ mod cashflow { let principal = COLLATERAL_VALUE / 2 / 12; let interest = Rate::from_float(DEFAULT_INTEREST_RATE).saturating_mul_int(principal); - assert_ok!( - loan.cashflow(), + assert_eq!( + loan.cashflow() + .unwrap() + .into_iter() + .map(|payment| (payment.when, payment.principal, payment.interest)) + .collect::>(), vec![ (last_secs_from_ymd(1970, 2, 1), principal, interest), (last_secs_from_ymd(1970, 3, 1), principal, interest), @@ -688,7 +692,7 @@ mod cashflow { let cashflow = util::get_loan(loan_id).cashflow().unwrap(); - let time_until_next_month = Duration::from_secs(cashflow[0].0) - now(); + let time_until_next_month = Duration::from_secs(cashflow[0].when) - now(); advance_time(time_until_next_month); config_mocks(COLLATERAL_VALUE / 4); @@ -716,7 +720,7 @@ mod cashflow { let cashflow = util::get_loan(loan_id).cashflow().unwrap(); - let time_until_next_month = Duration::from_secs(cashflow[0].0) - now(); + let time_until_next_month = Duration::from_secs(cashflow[0].when) - now(); advance_time(time_until_next_month); // Start of the next month @@ -750,7 +754,7 @@ mod cashflow { let cashflow = util::get_loan(loan_id).cashflow().unwrap(); - let time_until_next_month = Duration::from_secs(cashflow[0].0) - now(); + let time_until_next_month = Duration::from_secs(cashflow[0].when) - now(); advance_time(time_until_next_month); // Start of the next month diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index 4246c0d66f..77ade30dc7 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -92,6 +92,13 @@ pub enum PayDownSchedule { None, } +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub struct CashflowPayment { + pub when: Seconds, + pub principal: Balance, + pub interest: Balance, +} + /// Specify the repayment schedule of the loan #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] pub struct RepaymentSchedule { @@ -125,27 +132,12 @@ impl RepaymentSchedule { Ok(self.maturity.is_valid(now)) } - pub fn has_cashflow(&self) -> bool { - let has_interest_payments = match self.interest_payments { - InterestPayments::None => false, - _ => true, - }; - - #[allow(unreachable_patterns)] // Remove when pay_down_schedule has more than `None` - let has_pay_down_schedule = match self.pay_down_schedule { - PayDownSchedule::None => false, - _ => true, - }; - - has_interest_payments || has_pay_down_schedule - } - pub fn generate_cashflows( &self, origination_date: Seconds, principal: Balance, interest_rate: &InterestRate, - ) -> Result, DispatchError> + ) -> Result>, DispatchError> where Balance: FixedPointOperand, Rate: FixedPointNumber, @@ -171,11 +163,11 @@ impl RepaymentSchedule { timeflow .into_iter() .map(|(date, interval)| { - Ok(( - date::into_seconds(date)?, - interval.ensure_mul_int(principal_per_period)?, - interval.ensure_mul_int(interest_per_period)?, - )) + Ok(CashflowPayment { + when: date::into_seconds(date)?, + principal: interval.ensure_mul_int(principal_per_period)?, + interest: interval.ensure_mul_int(interest_per_period)?, + }) }) .collect() } @@ -195,10 +187,8 @@ impl RepaymentSchedule { let total_amount = cashflow .into_iter() - .take_while(|(date, _, _)| *date < until) - .map(|(_, principal_amount, interest_amount)| { - principal_amount.ensure_add(interest_amount) - }) + .take_while(|payment| payment.when < until) + .map(|payment| payment.principal.ensure_add(payment.interest)) .try_fold(Balance::zero(), |a, b| a.ensure_add(b?))?; Ok(total_amount) diff --git a/runtime/altair/src/lib.rs b/runtime/altair/src/lib.rs index f41605438c..349c007514 100644 --- a/runtime/altair/src/lib.rs +++ b/runtime/altair/src/lib.rs @@ -77,6 +77,7 @@ use pallet_investments::OrderType; use pallet_liquidity_pools::hooks::{ CollectedForeignInvestmentHook, CollectedForeignRedemptionHook, DecreasedForeignInvestOrderHook, }; +use pallet_loans::types::cashflow::CashflowPayment; use pallet_pool_system::{ pool_types::{PoolDetails, ScheduledUpdateDetails}, tranches::{TrancheIndex, TrancheLoc, TrancheSolution}, @@ -2351,7 +2352,7 @@ impl_runtime_apis! { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } - fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result, DispatchError> { + fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { Loans::cashflow(pool_id, loan_id) } } diff --git a/runtime/centrifuge/src/lib.rs b/runtime/centrifuge/src/lib.rs index c372a136f3..659445dc1c 100644 --- a/runtime/centrifuge/src/lib.rs +++ b/runtime/centrifuge/src/lib.rs @@ -78,6 +78,7 @@ use pallet_investments::OrderType; use pallet_liquidity_pools::hooks::{ CollectedForeignInvestmentHook, CollectedForeignRedemptionHook, DecreasedForeignInvestOrderHook, }; +use pallet_loans::types::cashflow::CashflowPayment; use pallet_pool_system::{ pool_types::{PoolDetails, ScheduledUpdateDetails}, tranches::{TrancheIndex, TrancheLoc, TrancheSolution}, @@ -2399,7 +2400,7 @@ impl_runtime_apis! { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } - fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result, DispatchError> { + fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { Loans::cashflow(pool_id, loan_id) } } diff --git a/runtime/common/src/apis/loans.rs b/runtime/common/src/apis/loans.rs index 6bb298a917..9ddb1dba94 100644 --- a/runtime/common/src/apis/loans.rs +++ b/runtime/common/src/apis/loans.rs @@ -11,7 +11,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -use cfg_traits::Seconds; +use pallet_loans::types::cashflow::CashflowPayment; use parity_scale_codec::Codec; use sp_api::decl_runtime_apis; use sp_runtime::DispatchError; @@ -26,11 +26,11 @@ decl_runtime_apis! { LoanId: Codec, Loan: Codec, Balance: Codec, - PriceCollectionInput: Codec + PriceCollectionInput: Codec, { fn portfolio(pool_id: PoolId) -> Vec<(LoanId, Loan)>; fn portfolio_loan(pool_id: PoolId, loan_id: LoanId) -> Option; fn portfolio_valuation(pool_id: PoolId, input_prices: PriceCollectionInput) -> Result; - fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result, DispatchError>; + fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError>; } } diff --git a/runtime/development/src/lib.rs b/runtime/development/src/lib.rs index eb2412a37b..5bbb800f9c 100644 --- a/runtime/development/src/lib.rs +++ b/runtime/development/src/lib.rs @@ -82,6 +82,7 @@ use pallet_investments::OrderType; use pallet_liquidity_pools::hooks::{ CollectedForeignInvestmentHook, CollectedForeignRedemptionHook, DecreasedForeignInvestOrderHook, }; +use pallet_loans::types::cashflow::CashflowPayment; use pallet_pool_system::{ pool_types::{PoolDetails, ScheduledUpdateDetails}, tranches::{TrancheIndex, TrancheLoc, TrancheSolution}, @@ -2438,7 +2439,7 @@ impl_runtime_apis! { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } - fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result, DispatchError> { + fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { Loans::cashflow(pool_id, loan_id) } } diff --git a/runtime/integration-tests/src/generic/cases/loans.rs b/runtime/integration-tests/src/generic/cases/loans.rs index 73500af361..0f4ac2c306 100644 --- a/runtime/integration-tests/src/generic/cases/loans.rs +++ b/runtime/integration-tests/src/generic/cases/loans.rs @@ -27,7 +27,7 @@ use pallet_loans::{ }, }; use runtime_common::{ - apis::{runtime_decl_for_loans_api::LoansApiV2, runtime_decl_for_pools_api::PoolsApiV1}, + apis::{runtime_decl_for_loans_api::LoansApiV3, runtime_decl_for_pools_api::PoolsApiV1}, oracle::Feeder, }; use sp_runtime::FixedPointNumber; diff --git a/runtime/integration-tests/src/generic/config.rs b/runtime/integration-tests/src/generic/config.rs index ffc587c207..d169d143de 100644 --- a/runtime/integration-tests/src/generic/config.rs +++ b/runtime/integration-tests/src/generic/config.rs @@ -286,7 +286,7 @@ pub trait Runtime: /// You can extend this bounds to give extra API support type Api: sp_api::runtime_decl_for_core::CoreV4 + sp_block_builder::runtime_decl_for_block_builder::BlockBuilderV6 - + apis::runtime_decl_for_loans_api::LoansApiV2< + + apis::runtime_decl_for_loans_api::LoansApiV3< Self::BlockExt, PoolId, LoanId, From 8c41d5d20589deeb38404fa85f59d5231e07b59b Mon Sep 17 00:00:00 2001 From: lemunozm Date: Tue, 7 May 2024 15:20:47 +0200 Subject: [PATCH 14/28] add variant error --- pallets/loans/src/entities/loans.rs | 2 +- pallets/loans/src/tests/borrow_loan.rs | 2 +- pallets/loans/src/types/mod.rs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index a0cc2322ca..2deabecc8a 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -370,7 +370,7 @@ impl ActiveLoan { ensure!( self.total_repaid.effective()? >= expected_payment, - DispatchError::Other("payment overdue") + Error::::from(BorrowLoanError::PaymentOverdue) ); Ok(()) diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index 90645656ee..fcaa8d7a16 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -734,7 +734,7 @@ mod cashflow { loan_id, PrincipalInput::Internal(COLLATERAL_VALUE / 4) ), - DispatchError::Other("payment overdue") + Error::::from(BorrowLoanError::PaymentOverdue) ); }); } diff --git a/pallets/loans/src/types/mod.rs b/pallets/loans/src/types/mod.rs index ee9def50ed..2d279fb9e9 100644 --- a/pallets/loans/src/types/mod.rs +++ b/pallets/loans/src/types/mod.rs @@ -44,6 +44,8 @@ pub enum BorrowLoanError { Restriction, /// Emits when maturity has passed and borrower tried to borrow more MaturityDatePassed, + /// Emits when the cashflow payment is overdue + PaymentOverdue, } /// Error related to loan borrowing From 1d60312e5260cc34542439caaf920ee2e8fd073e Mon Sep 17 00:00:00 2001 From: lemunozm Date: Wed, 8 May 2024 09:45:13 +0200 Subject: [PATCH 15/28] fix interest computation when months are partial --- pallets/loans/src/tests/borrow_loan.rs | 5 +- pallets/loans/src/types/cashflow.rs | 96 ++++++++++++++++---------- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index fcaa8d7a16..7f490c372d 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -650,8 +650,9 @@ mod cashflow { ); assert_eq!(loan.maturity_date(), secs_from_ymdhms(1971, 1, 1, 0, 0, 10)); - let principal = COLLATERAL_VALUE / 2 / 12; - let interest = Rate::from_float(DEFAULT_INTEREST_RATE).saturating_mul_int(principal); + let principal = (COLLATERAL_VALUE / 2) / 12; + let interest_rate_per_month = DEFAULT_INTEREST_RATE / 12.0; + let interest = Rate::from_float(interest_rate_per_month).saturating_mul_int(principal); assert_eq!( loan.cashflow() diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index 77ade30dc7..8c85f964d1 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -18,7 +18,8 @@ use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::{ traits::{ - EnsureAdd, EnsureAddAssign, EnsureFixedPointNumber, EnsureInto, EnsureSub, EnsureSubAssign, + EnsureAdd, EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureInto, EnsureSub, + EnsureSubAssign, }, ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, }; @@ -139,8 +140,8 @@ impl RepaymentSchedule { interest_rate: &InterestRate, ) -> Result>, DispatchError> where - Balance: FixedPointOperand, - Rate: FixedPointNumber, + Balance: FixedPointOperand + EnsureAdd + EnsureDiv, + Rate: FixedPointNumber + EnsureDiv, { let start_date = date::from_seconds(origination_date)?; let end_date = date::from_seconds(self.maturity.date())?; @@ -153,20 +154,25 @@ impl RepaymentSchedule { ), }; - let principal_per_period = - Rate::ensure_from_rational(1, periods_per_year)?.ensure_mul_int(principal)?; + let total = timeflow + .iter() + .map(|(_, interval)| interval) + .try_fold(Rate::zero(), |a, b| a.ensure_add(*b))?; let interest_per_period = interest_rate .per_year() - .ensure_mul_int(principal_per_period)?; + .ensure_div(Rate::saturating_from_integer(periods_per_year))?; timeflow .into_iter() .map(|(date, interval)| { + let principal = interval.ensure_div(total)?.ensure_mul_int(principal)?; + let interest = interest_per_period.ensure_mul_int(principal)?; + Ok(CashflowPayment { when: date::into_seconds(date)?, - principal: interval.ensure_mul_int(principal_per_period)?, - interest: interval.ensure_mul_int(interest_per_period)?, + principal, + interest, }) }) .collect() @@ -180,7 +186,7 @@ impl RepaymentSchedule { until: Seconds, ) -> Result where - Balance: FixedPointOperand + EnsureAdd, + Balance: FixedPointOperand + EnsureAdd + EnsureDiv, Rate: FixedPointNumber, { let cashflow = self.generate_cashflows(origination_date, principal, interest_rate)?; @@ -325,40 +331,17 @@ pub mod tests { secs_from_ymdhms(year, month, day, 23, 59, 59) } - fn rate_per_year(rate: f32) -> InterestRate { + fn rate_per_year(rate: f64) -> InterestRate { InterestRate::Fixed { - rate_per_year: Rate::from_float(0.1), + rate_per_year: Rate::from_float(rate), compounding: CompoundingSchedule::Secondly, } } - #[test] - fn repayment_schedule_validation() { - assert_ok!( - RepaymentSchedule { - maturity: Maturity::fixed(last_secs_from_ymd(2040, 1, 1)), - interest_payments: InterestPayments::Monthly(1), - pay_down_schedule: PayDownSchedule::None, - } - .is_valid(last_secs_from_ymd(2000, 1, 1)), - true - ); - - assert_ok!( - RepaymentSchedule { - maturity: Maturity::fixed(last_secs_from_ymd(2041, 1, 1)), - interest_payments: InterestPayments::Monthly(1), - pay_down_schedule: PayDownSchedule::None, - } - .is_valid(last_secs_from_ymd(2000, 1, 1)), - false - ); - } - - mod dates { + mod months { use super::*; - mod months { + mod dates { use super::*; #[test] @@ -409,5 +392,46 @@ pub mod tests { ); } } + + #[test] + fn repayment_schedule_validation() { + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(last_secs_from_ymd(2040, 1, 1)), + interest_payments: InterestPayments::Monthly(1), + pay_down_schedule: PayDownSchedule::None, + } + .is_valid(last_secs_from_ymd(2000, 1, 1)), + true + ); + + assert_ok!( + RepaymentSchedule { + maturity: Maturity::fixed(last_secs_from_ymd(2041, 1, 1)), + interest_payments: InterestPayments::Monthly(1), + pay_down_schedule: PayDownSchedule::None, + } + .is_valid(last_secs_from_ymd(2000, 1, 1)), + false // Exceeds the limit of a 40 years cashflow + ); + } + + #[test] + fn correct_amounts() { + // Note that an interest rate of 0.12 corresponds to 0.01 monthly. + assert_eq!( + RepaymentSchedule { + maturity: Maturity::fixed(last_secs_from_ymd(2022, 7, 1)), + interest_payments: InterestPayments::Monthly(1), + pay_down_schedule: PayDownSchedule::None, + } + .generate_cashflows(last_secs_from_ymd(2022, 4, 16), 25000, &rate_per_year(0.12)) + .unwrap() + .into_iter() + .map(|payment| (payment.principal, payment.interest)) + .collect::>(), + vec![(5000, 50), (10000, 100), (10000, 100)] + ) + } } } From df15e66da6cf073be23f13eefd9114af1afeff59 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Wed, 8 May 2024 09:57:33 +0200 Subject: [PATCH 16/28] remove Rate usage and use weight --- pallets/loans/src/types/cashflow.rs | 60 +++++++++++++---------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index 8c85f964d1..3fbcabcce4 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -21,7 +21,7 @@ use sp_runtime::{ EnsureAdd, EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureInto, EnsureSub, EnsureSubAssign, }, - ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, + ArithmeticError, DispatchError, FixedPointNumber, FixedPointOperand, FixedU128, }; use sp_std::{cmp::min, vec, vec::Vec}; @@ -149,15 +149,15 @@ impl RepaymentSchedule { let (timeflow, periods_per_year) = match &self.interest_payments { InterestPayments::None => (vec![], 1), InterestPayments::Monthly(reference_day) => ( - date::monthly_intervals::(start_date, end_date, (*reference_day).into())?, + date::monthly_intervals(start_date, end_date, (*reference_day).into())?, 12, ), }; - let total = timeflow + let total_weight = timeflow .iter() - .map(|(_, interval)| interval) - .try_fold(Rate::zero(), |a, b| a.ensure_add(*b))?; + .map(|(_, weight)| weight) + .try_fold(0, |a, b| a.ensure_add(*b))?; let interest_per_period = interest_rate .per_year() @@ -165,8 +165,9 @@ impl RepaymentSchedule { timeflow .into_iter() - .map(|(date, interval)| { - let principal = interval.ensure_div(total)?.ensure_mul_int(principal)?; + .map(|(date, weight)| { + let proportion = FixedU128::ensure_from_rational(weight, total_weight)?; + let principal = proportion.ensure_mul_int(principal)?; let interest = interest_per_period.ensure_mul_int(principal)?; Ok(CashflowPayment { @@ -264,11 +265,11 @@ mod date { Ok(dates) } - pub fn monthly_intervals( + pub fn monthly_intervals( start_date: NaiveDate, end_date: NaiveDate, reference_day: u32, - ) -> Result, DispatchError> { + ) -> Result, DispatchError> { let monthly_dates = monthly(start_date, end_date, reference_day)?; let last_index = monthly_dates.len().ensure_sub(1)?; @@ -277,22 +278,19 @@ mod date { .into_iter() .enumerate() .map(|(i, date)| { - let days = match i { + let weight = match i { 0 if last_index == 0 => end_date.day().ensure_sub(DAY_1)?, 0 if start_date.day() == DAY_1 => 30, 0 => (date - start_date).num_days().ensure_into()?, n if n == last_index && end_date.day() == DAY_1 => 30, n if n == last_index => { - let prev_date = monthly_dates - .get(n.ensure_sub(1)?) - .ok_or(DispatchError::Other("n > 1. qed"))?; - + let prev_date = monthly_dates.get(n.ensure_sub(1)?).ok_or("n > 1. qed")?; (date - *prev_date).num_days().ensure_into()? } _ => 30, }; - Ok((date, Rate::saturating_from_rational(days, 30))) + Ok((date, weight)) }) .collect() } @@ -331,13 +329,6 @@ pub mod tests { secs_from_ymdhms(year, month, day, 23, 59, 59) } - fn rate_per_year(rate: f64) -> InterestRate { - InterestRate::Fixed { - rate_per_year: Rate::from_float(rate), - compounding: CompoundingSchedule::Secondly, - } - } - mod months { use super::*; @@ -349,10 +340,10 @@ pub mod tests { assert_ok!( date::monthly_intervals(from_ymd(2022, 1, 20), from_ymd(2022, 4, 20), 1), vec![ - (from_ymd(2022, 2, 1), Rate::from((12, 30))), - (from_ymd(2022, 3, 1), Rate::one()), - (from_ymd(2022, 4, 1), Rate::one()), - (from_ymd(2022, 4, 20), Rate::from((19, 30))), + (from_ymd(2022, 2, 1), 12), + (from_ymd(2022, 3, 1), 30), + (from_ymd(2022, 4, 1), 30), + (from_ymd(2022, 4, 20), 19), ] ); } @@ -377,10 +368,7 @@ pub mod tests { fn start_and_end_same_day_as_reference_day() { assert_ok!( date::monthly_intervals(from_ymd(2022, 1, 1), from_ymd(2022, 3, 1), 1), - vec![ - (from_ymd(2022, 2, 1), Rate::one()), - (from_ymd(2022, 3, 1), Rate::one()), - ] + vec![(from_ymd(2022, 2, 1), 30), (from_ymd(2022, 3, 1), 30),] ); } @@ -388,7 +376,7 @@ pub mod tests { fn same_month() { assert_ok!( date::monthly_intervals(from_ymd(2022, 1, 1), from_ymd(2022, 1, 15), 1), - vec![(from_ymd(2022, 1, 15), Rate::from((14, 30)))] + vec![(from_ymd(2022, 1, 15), 14)] ); } } @@ -418,14 +406,20 @@ pub mod tests { #[test] fn correct_amounts() { - // Note that an interest rate of 0.12 corresponds to 0.01 monthly. assert_eq!( RepaymentSchedule { maturity: Maturity::fixed(last_secs_from_ymd(2022, 7, 1)), interest_payments: InterestPayments::Monthly(1), pay_down_schedule: PayDownSchedule::None, } - .generate_cashflows(last_secs_from_ymd(2022, 4, 16), 25000, &rate_per_year(0.12)) + .generate_cashflows( + last_secs_from_ymd(2022, 4, 16), + 25000, /* principal */ + &InterestRate::Fixed { + rate_per_year: Rate::from_float(0.01 * 12.0), + compounding: CompoundingSchedule::Secondly, + } + ) .unwrap() .into_iter() .map(|payment| (payment.principal, payment.interest)) From a60d0fdc842c07cc571acef8a259f51f81ce8a2d Mon Sep 17 00:00:00 2001 From: lemunozm Date: Wed, 8 May 2024 10:36:09 +0200 Subject: [PATCH 17/28] fix start date for cashflows --- pallets/loans/src/entities/loans.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 2deabecc8a..5566919176 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -250,7 +250,7 @@ impl ActiveLoan { pub fn cashflow(&self) -> Result>, DispatchError> { self.schedule.generate_cashflows( - self.origination_date, + self.repayments_on_schedule_until, self.principal()?, self.pricing.interest().rate(), ) @@ -362,7 +362,7 @@ impl ActiveLoan { ); let expected_payment = self.schedule.expected_payment( - self.origination_date, + self.repayments_on_schedule_until, self.principal()?, self.pricing.interest().rate(), now, From a864ad49dce61550b4deff38ca56fb98c66f59b5 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Wed, 8 May 2024 10:41:59 +0200 Subject: [PATCH 18/28] rename api name --- pallets/loans/src/entities/loans.rs | 2 +- pallets/loans/src/lib.rs | 4 ++-- pallets/loans/src/tests/borrow_loan.rs | 8 ++++---- runtime/altair/src/lib.rs | 5 ++--- runtime/centrifuge/src/lib.rs | 4 ++-- runtime/common/src/apis/loans.rs | 2 +- runtime/development/src/lib.rs | 4 ++-- 7 files changed, 14 insertions(+), 15 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 5566919176..4731100fdb 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -248,7 +248,7 @@ impl ActiveLoan { .ensure_sub(self.total_repaid.principal)?) } - pub fn cashflow(&self) -> Result>, DispatchError> { + pub fn expected_cashflows(&self) -> Result>, DispatchError> { self.schedule.generate_cashflows( self.repayments_on_schedule_until, self.principal()?, diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index aa32d30867..c6d6dfed31 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -1215,14 +1215,14 @@ pub mod pallet { .transpose() } - pub fn cashflow( + pub fn expected_cashflows( pool_id: T::PoolId, loan_id: T::LoanId, ) -> Result>, DispatchError> { ActiveLoans::::get(pool_id) .into_iter() .find(|(id, _)| *id == loan_id) - .map(|(_, loan)| loan.cashflow()) + .map(|(_, loan)| loan.expected_cashflows()) .ok_or(Error::::LoanNotActiveOrNotFound)? } } diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index 7f490c372d..c8eea0974e 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -655,7 +655,7 @@ mod cashflow { let interest = Rate::from_float(interest_rate_per_month).saturating_mul_int(principal); assert_eq!( - loan.cashflow() + loan.expected_cashflows() .unwrap() .into_iter() .map(|payment| (payment.when, payment.principal, payment.interest)) @@ -691,7 +691,7 @@ mod cashflow { PrincipalInput::Internal(COLLATERAL_VALUE / 2) )); - let cashflow = util::get_loan(loan_id).cashflow().unwrap(); + let cashflow = util::get_loan(loan_id).expected_cashflows().unwrap(); let time_until_next_month = Duration::from_secs(cashflow[0].when) - now(); advance_time(time_until_next_month); @@ -719,7 +719,7 @@ mod cashflow { PrincipalInput::Internal(COLLATERAL_VALUE / 2) )); - let cashflow = util::get_loan(loan_id).cashflow().unwrap(); + let cashflow = util::get_loan(loan_id).expected_cashflows().unwrap(); let time_until_next_month = Duration::from_secs(cashflow[0].when) - now(); advance_time(time_until_next_month); @@ -753,7 +753,7 @@ mod cashflow { PrincipalInput::Internal(COLLATERAL_VALUE / 2) )); - let cashflow = util::get_loan(loan_id).cashflow().unwrap(); + let cashflow = util::get_loan(loan_id).expected_cashflows().unwrap(); let time_until_next_month = Duration::from_secs(cashflow[0].when) - now(); advance_time(time_until_next_month); diff --git a/runtime/altair/src/lib.rs b/runtime/altair/src/lib.rs index 349c007514..6ad1b1fde6 100644 --- a/runtime/altair/src/lib.rs +++ b/runtime/altair/src/lib.rs @@ -2352,12 +2352,11 @@ impl_runtime_apis! { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } - fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { - Loans::cashflow(pool_id, loan_id) + fn expected_cashflows(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { + Loans::expected_cashflows(pool_id, loan_id) } } - // Investment Runtime APIs impl runtime_common::apis::InvestmentsApi> for Runtime { fn investment_portfolio(account_id: AccountId) -> Vec<(TrancheCurrency, InvestmentPortfolio)> { diff --git a/runtime/centrifuge/src/lib.rs b/runtime/centrifuge/src/lib.rs index 659445dc1c..e2fee79f02 100644 --- a/runtime/centrifuge/src/lib.rs +++ b/runtime/centrifuge/src/lib.rs @@ -2400,8 +2400,8 @@ impl_runtime_apis! { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } - fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { - Loans::cashflow(pool_id, loan_id) + fn expected_cashflows(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { + Loans::expected_cashflows(pool_id, loan_id) } } diff --git a/runtime/common/src/apis/loans.rs b/runtime/common/src/apis/loans.rs index 9ddb1dba94..82a2e970f6 100644 --- a/runtime/common/src/apis/loans.rs +++ b/runtime/common/src/apis/loans.rs @@ -31,6 +31,6 @@ decl_runtime_apis! { fn portfolio(pool_id: PoolId) -> Vec<(LoanId, Loan)>; fn portfolio_loan(pool_id: PoolId, loan_id: LoanId) -> Option; fn portfolio_valuation(pool_id: PoolId, input_prices: PriceCollectionInput) -> Result; - fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError>; + fn expected_cashflows(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError>; } } diff --git a/runtime/development/src/lib.rs b/runtime/development/src/lib.rs index 5bbb800f9c..7f7f32ce31 100644 --- a/runtime/development/src/lib.rs +++ b/runtime/development/src/lib.rs @@ -2439,8 +2439,8 @@ impl_runtime_apis! { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } - fn cashflow(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { - Loans::cashflow(pool_id, loan_id) + fn expected_cashflows(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { + Loans::expected_cashflows(pool_id, loan_id) } } From 39eb12d88d8fda6798ae8ce8941bc0de94950886 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 9 May 2024 10:53:43 +0200 Subject: [PATCH 19/28] fix benchmarks --- pallets/loans/src/benchmarking.rs | 31 +++++++++++++++++++++++++---- pallets/loans/src/types/cashflow.rs | 1 - 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/pallets/loans/src/benchmarking.rs b/pallets/loans/src/benchmarking.rs index 39c72e1e4f..e8832538fa 100644 --- a/pallets/loans/src/benchmarking.rs +++ b/pallets/loans/src/benchmarking.rs @@ -16,7 +16,7 @@ use cfg_traits::{ benchmarking::FundedPoolBenchmarkHelper, changes::ChangeGuard, interest::{CompoundingSchedule, InterestAccrual, InterestRate}, - Permissions, PoolWriteOffPolicyMutate, Seconds, TimeAsSecs, ValueProvider, + Permissions, PoolWriteOffPolicyMutate, TimeAsSecs, ValueProvider, }; use cfg_types::{ adjustments::Adjustment, @@ -46,7 +46,6 @@ use crate::{ }, }; -const OFFSET: Seconds = 120; const COLLECION_ID: u16 = 42; const COLLATERAL_VALUE: u128 = 1_000_000; const FUNDS: u128 = 1_000_000_000; @@ -126,7 +125,7 @@ where fn base_loan(item_id: T::ItemId) -> LoanInfo { LoanInfo { schedule: RepaymentSchedule { - maturity: Maturity::fixed(T::Time::now() + OFFSET), + maturity: Maturity::fixed(T::Time::now() + 120), interest_payments: InterestPayments::None, pay_down_schedule: PayDownSchedule::None, }, @@ -171,6 +170,30 @@ where LastLoanId::::get(pool_id) } + fn create_cashflow_loan(pool_id: T::PoolId, item_id: T::ItemId) -> T::LoanId { + let borrower = account("borrower", 0, 0); + + T::NonFungible::mint_into(&COLLECION_ID.into(), &item_id, &borrower).unwrap(); + + let maturity_offset = 40 * 365 * 24 * 3600; // 40 years + + Pallet::::create( + RawOrigin::Signed(borrower).into(), + pool_id, + LoanInfo { + schedule: RepaymentSchedule { + maturity: Maturity::fixed(T::Time::now() + maturity_offset), + interest_payments: InterestPayments::Monthly(1), + pay_down_schedule: PayDownSchedule::None, + }, + ..Self::base_loan(item_id) + }, + ) + .unwrap(); + + LastLoanId::::get(pool_id) + } + fn borrow_loan(pool_id: T::PoolId, loan_id: T::LoanId) { let borrower = account("borrower", 0, 0); Pallet::::borrow( @@ -342,7 +365,7 @@ benchmarks! { let borrower = account("borrower", 0, 0); let pool_id = Helper::::initialize_active_state(n); - let loan_id = Helper::::create_loan(pool_id, u16::MAX.into()); + let loan_id = Helper::::create_cashflow_loan(pool_id, u16::MAX.into()); }: _(RawOrigin::Signed(borrower), pool_id, loan_id, PrincipalInput::Internal(10.into())) diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index 3fbcabcce4..cc7e7ccf5a 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -300,7 +300,6 @@ mod date { pub mod tests { use cfg_traits::interest::CompoundingSchedule; use frame_support::{assert_err, assert_ok}; - use sp_runtime::traits::One; use super::*; From 010469d0950ff7fefd4ab259954da9f95d6236c4 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 9 May 2024 10:54:02 +0200 Subject: [PATCH 20/28] taplo fmt --- pallets/loans/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pallets/loans/Cargo.toml b/pallets/loans/Cargo.toml index f505a74dbe..fb0589df35 100644 --- a/pallets/loans/Cargo.toml +++ b/pallets/loans/Cargo.toml @@ -13,9 +13,9 @@ documentation.workspace = true targets = ["x86_64-unknown-linux-gnu"] [dependencies] +chrono = { workspace = true } parity-scale-codec = { workspace = true } scale-info = { workspace = true } -chrono = { workspace = true } frame-support = { workspace = true } frame-system = { workspace = true } From 8216ddaad231c4161a918e27af6311a6cc508c3a Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 9 May 2024 11:17:55 +0200 Subject: [PATCH 21/28] using a lower discount rate to simply benchmarking --- pallets/loans/src/benchmarking.rs | 34 ++++++------------------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/pallets/loans/src/benchmarking.rs b/pallets/loans/src/benchmarking.rs index e8832538fa..e691df9543 100644 --- a/pallets/loans/src/benchmarking.rs +++ b/pallets/loans/src/benchmarking.rs @@ -123,10 +123,12 @@ where } fn base_loan(item_id: T::ItemId) -> LoanInfo { + let maturity_offset = 40 * 365 * 24 * 3600; // 40 years + LoanInfo { schedule: RepaymentSchedule { - maturity: Maturity::fixed(T::Time::now() + 120), - interest_payments: InterestPayments::None, + maturity: Maturity::fixed(T::Time::now() + maturity_offset), + interest_payments: InterestPayments::Monthly(1), pay_down_schedule: PayDownSchedule::None, }, collateral: (COLLECION_ID.into(), item_id), @@ -143,7 +145,7 @@ where probability_of_default: T::Rate::zero(), loss_given_default: T::Rate::zero(), discount_rate: InterestRate::Fixed { - rate_per_year: T::Rate::one(), + rate_per_year: T::Rate::saturating_from_rational(1, 5000), compounding: CompoundingSchedule::Secondly, }, }), @@ -170,30 +172,6 @@ where LastLoanId::::get(pool_id) } - fn create_cashflow_loan(pool_id: T::PoolId, item_id: T::ItemId) -> T::LoanId { - let borrower = account("borrower", 0, 0); - - T::NonFungible::mint_into(&COLLECION_ID.into(), &item_id, &borrower).unwrap(); - - let maturity_offset = 40 * 365 * 24 * 3600; // 40 years - - Pallet::::create( - RawOrigin::Signed(borrower).into(), - pool_id, - LoanInfo { - schedule: RepaymentSchedule { - maturity: Maturity::fixed(T::Time::now() + maturity_offset), - interest_payments: InterestPayments::Monthly(1), - pay_down_schedule: PayDownSchedule::None, - }, - ..Self::base_loan(item_id) - }, - ) - .unwrap(); - - LastLoanId::::get(pool_id) - } - fn borrow_loan(pool_id: T::PoolId, loan_id: T::LoanId) { let borrower = account("borrower", 0, 0); Pallet::::borrow( @@ -365,7 +343,7 @@ benchmarks! { let borrower = account("borrower", 0, 0); let pool_id = Helper::::initialize_active_state(n); - let loan_id = Helper::::create_cashflow_loan(pool_id, u16::MAX.into()); + let loan_id = Helper::::create_loan(pool_id, u16::MAX.into()); }: _(RawOrigin::Signed(borrower), pool_id, loan_id, PrincipalInput::Internal(10.into())) From 77dada68c61c44f1af1ad42d5cb51d2330b4596c Mon Sep 17 00:00:00 2001 From: lemunozm Date: Mon, 13 May 2024 12:10:04 +0200 Subject: [PATCH 22/28] rewrite doc line --- pallets/loans/src/types/cashflow.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index cc7e7ccf5a..8f8a406746 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -88,8 +88,7 @@ pub enum InterestPayments { /// 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 + /// No restrictions on how the paydown should be done. None, } From 338cece62869412c44e220664c3f4d887594b307 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 6 Jun 2024 09:59:04 +0200 Subject: [PATCH 23/28] interest computed at maturity --- pallets/loans/src/tests/borrow_loan.rs | 14 +++++++-- pallets/loans/src/tests/mod.rs | 4 +-- pallets/loans/src/tests/util.rs | 17 +++++------ pallets/loans/src/types/cashflow.rs | 40 +++++++++++++++----------- 4 files changed, 45 insertions(+), 30 deletions(-) diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index 67b0648271..d9d1798a40 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -653,9 +653,17 @@ mod cashflow { Some(secs_from_ymdhms(1971, 1, 1, 0, 0, 10)) ); - let principal = (COLLATERAL_VALUE / 2) / 12; - let interest_rate_per_month = DEFAULT_INTEREST_RATE / 12.0; - let interest = Rate::from_float(interest_rate_per_month).saturating_mul_int(principal); + let total_principal = COLLATERAL_VALUE / 2; + let acc_interest_rate_per_year = checked_pow( + util::default_interest_rate().per_sec().unwrap(), + SECONDS_PER_YEAR as usize, + ) + .unwrap(); + let total_interest = + acc_interest_rate_per_year.saturating_mul_int(total_principal) - total_principal; + + let principal = total_principal / 12; + let interest = total_interest / 12; assert_eq!( loan.expected_cashflows() diff --git a/pallets/loans/src/tests/mod.rs b/pallets/loans/src/tests/mod.rs index b2f6c3f4b6..f333a8d571 100644 --- a/pallets/loans/src/tests/mod.rs +++ b/pallets/loans/src/tests/mod.rs @@ -1,12 +1,12 @@ use std::time::Duration; use cfg_mocks::pallet_mock_data::util::MockDataCollection; -use cfg_primitives::SECONDS_PER_DAY; +use cfg_primitives::{SECONDS_PER_DAY, SECONDS_PER_YEAR}; use cfg_traits::interest::{CompoundingSchedule, InterestRate}; use cfg_types::permissions::{PermissionScope, PoolRole, Role}; use frame_support::{assert_noop, assert_ok, storage::bounded_vec::BoundedVec}; use sp_runtime::{ - traits::{BadOrigin, One}, + traits::{checked_pow, BadOrigin, One}, DispatchError, FixedPointNumber, }; diff --git a/pallets/loans/src/tests/util.rs b/pallets/loans/src/tests/util.rs index c12c180bdc..baab1849d8 100644 --- a/pallets/loans/src/tests/util.rs +++ b/pallets/loans/src/tests/util.rs @@ -103,6 +103,13 @@ pub fn dcf_internal_loan() -> LoanInfo { } } +pub fn default_interest_rate() -> InterestRate { + InterestRate::Fixed { + rate_per_year: Rate::from_float(DEFAULT_INTEREST_RATE), + compounding: CompoundingSchedule::Secondly, + } +} + pub fn base_internal_loan() -> LoanInfo { LoanInfo { schedule: RepaymentSchedule { @@ -113,10 +120,7 @@ pub fn base_internal_loan() -> LoanInfo { interest_payments: InterestPayments::None, pay_down_schedule: PayDownSchedule::None, }, - interest_rate: InterestRate::Fixed { - rate_per_year: Rate::from_float(DEFAULT_INTEREST_RATE), - compounding: CompoundingSchedule::Secondly, - }, + interest_rate: default_interest_rate(), collateral: ASSET_AA, pricing: Pricing::Internal(base_internal_pricing()), restrictions: LoanRestrictions { @@ -143,10 +147,7 @@ pub fn base_external_loan() -> LoanInfo { interest_payments: InterestPayments::None, pay_down_schedule: PayDownSchedule::None, }, - interest_rate: InterestRate::Fixed { - rate_per_year: Rate::from_float(DEFAULT_INTEREST_RATE), - compounding: CompoundingSchedule::Secondly, - }, + interest_rate: default_interest_rate(), collateral: ASSET_AA, pricing: Pricing::External(base_external_pricing()), restrictions: LoanRestrictions { diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index c3b7d61aab..a2635e6e8f 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -18,7 +18,7 @@ use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::{ traits::{ - EnsureAdd, EnsureAddAssign, EnsureDiv, EnsureFixedPointNumber, EnsureInto, EnsureSub, + ensure_pow, EnsureAdd, EnsureAddAssign, EnsureFixedPointNumber, EnsureInto, EnsureSub, EnsureSubAssign, }, DispatchError, FixedPointNumber, FixedPointOperand, FixedU128, @@ -151,8 +151,8 @@ impl RepaymentSchedule { interest_rate: &InterestRate, ) -> Result>, DispatchError> where - Balance: FixedPointOperand + EnsureAdd + EnsureDiv, - Rate: FixedPointNumber + EnsureDiv, + Balance: FixedPointOperand + EnsureAdd + EnsureSub + std::fmt::Debug, + Rate: FixedPointNumber, { let Some(maturity) = self.maturity.date() else { return Ok(Vec::new()); @@ -161,12 +161,11 @@ impl RepaymentSchedule { let start_date = date::from_seconds(origination_date)?; let end_date = date::from_seconds(maturity)?; - let (timeflow, periods_per_year) = match &self.interest_payments { - InterestPayments::None => (vec![], 1), - InterestPayments::Monthly(reference_day) => ( - date::monthly_intervals(start_date, end_date, (*reference_day).into())?, - 12, - ), + let timeflow = match &self.interest_payments { + InterestPayments::None => vec![], + InterestPayments::Monthly(reference_day) => { + date::monthly_intervals(start_date, end_date, (*reference_day).into())? + } }; let total_weight = timeflow @@ -174,16 +173,18 @@ impl RepaymentSchedule { .map(|(_, weight)| weight) .try_fold(0, |a, b| a.ensure_add(*b))?; - let interest_per_period = interest_rate - .per_year() - .ensure_div(Rate::saturating_from_integer(periods_per_year))?; + let lifetime = maturity.ensure_sub(origination_date)?.ensure_into()?; + let interest_rate_per_lifetime = ensure_pow(interest_rate.per_sec()?, lifetime)?; + let interest_at_maturity = interest_rate_per_lifetime + .ensure_mul_int(principal)? + .ensure_sub(principal)?; timeflow .into_iter() .map(|(date, weight)| { let proportion = FixedU128::ensure_from_rational(weight, total_weight)?; let principal = proportion.ensure_mul_int(principal)?; - let interest = interest_per_period.ensure_mul_int(principal)?; + let interest = proportion.ensure_mul_int(interest_at_maturity)?; Ok(CashflowPayment { when: date::into_seconds(date)?, @@ -202,7 +203,7 @@ impl RepaymentSchedule { until: Seconds, ) -> Result where - Balance: FixedPointOperand + EnsureAdd + EnsureDiv, + Balance: FixedPointOperand + EnsureAdd + EnsureSub + std::fmt::Debug, Rate: FixedPointNumber, { let cashflow = self.generate_cashflows(origination_date, principal, interest_rate)?; @@ -420,6 +421,11 @@ pub mod tests { #[test] fn correct_amounts() { + // To understand the expected interest amounts: + // A rate per year of 0.12 means each month you nearly pay with a rate of 0.01. + // 0.01 of the total principal is 25000 * 0.01 = 250 each month. + // A minor extra amount comes from the secondly compounding interest during 2.5 + // months. assert_eq!( RepaymentSchedule { maturity: Maturity::fixed(last_secs_from_ymd(2022, 7, 1)), @@ -428,9 +434,9 @@ pub mod tests { } .generate_cashflows( last_secs_from_ymd(2022, 4, 16), - 25000, /* principal */ + 25000u128, /* principal */ &InterestRate::Fixed { - rate_per_year: Rate::from_float(0.01 * 12.0), + rate_per_year: Rate::from_float(0.12), compounding: CompoundingSchedule::Secondly, } ) @@ -438,7 +444,7 @@ pub mod tests { .into_iter() .map(|payment| (payment.principal, payment.interest)) .collect::>(), - vec![(5000, 50), (10000, 100), (10000, 100)] + vec![(5000, 126), (10000, 252), (10000, 252)] ) } } From 2c4a18081807a9cec26f30851cc6c974d4b6cf69 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 6 Jun 2024 10:01:32 +0200 Subject: [PATCH 24/28] remove borrow support --- pallets/loans/src/entities/loans.rs | 12 ------------ pallets/loans/src/tests/borrow_loan.rs | 13 +++++++++++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 2ae2e6c90a..51f16d6359 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -363,18 +363,6 @@ impl ActiveLoan { Error::::from(BorrowLoanError::MaturityDatePassed) ); - let expected_payment = self.schedule.expected_payment( - self.repayments_on_schedule_until, - self.principal()?, - self.pricing.interest().rate(), - now, - )?; - - ensure!( - self.total_repaid.effective()? >= expected_payment, - Error::::from(BorrowLoanError::PaymentOverdue) - ); - Ok(()) } diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index d9d1798a40..80b53150ce 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -739,6 +739,10 @@ mod cashflow { advance_time(Duration::from_secs(1)); config_mocks(COLLATERAL_VALUE / 4); + + /* + // NOTE: uncomment when https://github.com/centrifuge/centrifuge-chain/pull/1797#issuecomment-2149262096 + // be added again assert_noop!( Loans::borrow( RuntimeOrigin::signed(BORROWER), @@ -748,6 +752,15 @@ mod cashflow { ), Error::::from(BorrowLoanError::PaymentOverdue) ); + */ + + // No restriction to borrow again by now + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE / 4) + )); }); } From 70ea49c726d0cb0789005ccd2820fd769c1a2dca Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 6 Jun 2024 11:42:02 +0200 Subject: [PATCH 25/28] fix compilation --- pallets/loans/src/types/cashflow.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index a2635e6e8f..ca5bc46034 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -151,7 +151,7 @@ impl RepaymentSchedule { interest_rate: &InterestRate, ) -> Result>, DispatchError> where - Balance: FixedPointOperand + EnsureAdd + EnsureSub + std::fmt::Debug, + Balance: FixedPointOperand + EnsureAdd + EnsureSub, Rate: FixedPointNumber, { let Some(maturity) = self.maturity.date() else { @@ -203,7 +203,7 @@ impl RepaymentSchedule { until: Seconds, ) -> Result where - Balance: FixedPointOperand + EnsureAdd + EnsureSub + std::fmt::Debug, + Balance: FixedPointOperand + EnsureAdd + EnsureSub, Rate: FixedPointNumber, { let cashflow = self.generate_cashflows(origination_date, principal, interest_rate)?; From f9db4fcfe2211c47133005b0640df5437000235c Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 6 Jun 2024 12:02:07 +0200 Subject: [PATCH 26/28] None to OnceAtMaturity variant --- pallets/loans/src/tests/create_loan.rs | 2 +- pallets/loans/src/tests/mutate_loan.rs | 5 +-- pallets/loans/src/tests/util.rs | 4 +-- pallets/loans/src/types/cashflow.rs | 45 +++++++++++++++++++++----- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/pallets/loans/src/tests/create_loan.rs b/pallets/loans/src/tests/create_loan.rs index d74a7af815..37b87acdf9 100644 --- a/pallets/loans/src/tests/create_loan.rs +++ b/pallets/loans/src/tests/create_loan.rs @@ -92,7 +92,7 @@ fn with_wrong_schedule() { let loan = LoanInfo { schedule: RepaymentSchedule { maturity: Maturity::fixed(now().as_secs()), - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, ..util::base_internal_loan() diff --git a/pallets/loans/src/tests/mutate_loan.rs b/pallets/loans/src/tests/mutate_loan.rs index b85964c263..7f4d5f2e83 100644 --- a/pallets/loans/src/tests/mutate_loan.rs +++ b/pallets/loans/src/tests/mutate_loan.rs @@ -1,6 +1,7 @@ use super::*; -const DEFAULT_MUTATION: LoanMutation = LoanMutation::InterestPayments(InterestPayments::None); +const DEFAULT_MUTATION: LoanMutation = + LoanMutation::InterestPayments(InterestPayments::OnceAtMaturity); fn config_mocks(loan_id: LoanId, loan_mutation: &LoanMutation) { MockPermissions::mock_has(|scope, who, role| { @@ -213,7 +214,7 @@ fn with_successful_mutation_application() { date: (now() + YEAR).as_secs(), extension: YEAR.as_secs(), }, - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, interest_rate: InterestRate::Fixed { diff --git a/pallets/loans/src/tests/util.rs b/pallets/loans/src/tests/util.rs index baab1849d8..f58f29e849 100644 --- a/pallets/loans/src/tests/util.rs +++ b/pallets/loans/src/tests/util.rs @@ -117,7 +117,7 @@ pub fn base_internal_loan() -> LoanInfo { date: (now() + YEAR).as_secs(), extension: (YEAR / 2).as_secs(), }, - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, interest_rate: default_interest_rate(), @@ -144,7 +144,7 @@ pub fn base_external_loan() -> LoanInfo { LoanInfo { schedule: RepaymentSchedule { maturity: Maturity::fixed((now() + YEAR).as_secs()), - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, interest_rate: default_interest_rate(), diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index ca5bc46034..dcd0485c9a 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -82,7 +82,7 @@ impl Maturity { #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] pub enum InterestPayments { /// All interest is expected to be paid at the maturity date - None, + OnceAtMaturity, /// Interest is expected to be paid monthly /// The associated value correspond to the paydown day in the month, @@ -124,7 +124,7 @@ pub struct RepaymentSchedule { impl RepaymentSchedule { pub fn is_valid(&self, now: Seconds) -> Result { let valid = match self.interest_payments { - InterestPayments::None => true, + InterestPayments::OnceAtMaturity => true, InterestPayments::Monthly(_) => { match self.maturity.date() { Some(maturity) => { @@ -162,7 +162,7 @@ impl RepaymentSchedule { let end_date = date::from_seconds(maturity)?; let timeflow = match &self.interest_payments { - InterestPayments::None => vec![], + InterestPayments::OnceAtMaturity => vec![(end_date, 1)], InterestPayments::Monthly(reference_day) => { date::monthly_intervals(start_date, end_date, (*reference_day).into())? } @@ -344,6 +344,39 @@ pub mod tests { secs_from_ymdhms(year, month, day, 23, 59, 59) } + mod once_at_maturity { + use super::*; + + #[test] + fn correct_amounts() { + // To understand the expected interest amounts: + // A rate per year of 0.12 means each month you nearly pay with a rate of 0.01. + // 0.01 of the total principal is 25000 * 0.01 = 250 each month. + // A minor extra amount comes from the secondly compounding interest during 2.5 + // months. + assert_eq!( + RepaymentSchedule { + maturity: Maturity::fixed(last_secs_from_ymd(2022, 7, 1)), + interest_payments: InterestPayments::OnceAtMaturity, + pay_down_schedule: PayDownSchedule::None, + } + .generate_cashflows( + last_secs_from_ymd(2022, 4, 16), + 25000u128, /* principal */ + &InterestRate::Fixed { + rate_per_year: Rate::from_float(0.12), + compounding: CompoundingSchedule::Secondly, + } + ) + .unwrap() + .into_iter() + .map(|payment| (payment.principal, payment.interest)) + .collect::>(), + vec![(25000, 632)] + ) + } + } + mod months { use super::*; @@ -421,11 +454,7 @@ pub mod tests { #[test] fn correct_amounts() { - // To understand the expected interest amounts: - // A rate per year of 0.12 means each month you nearly pay with a rate of 0.01. - // 0.01 of the total principal is 25000 * 0.01 = 250 each month. - // A minor extra amount comes from the secondly compounding interest during 2.5 - // months. + // See comment at once_at_maturity::correct_amounts() to know about the numbers assert_eq!( RepaymentSchedule { maturity: Maturity::fixed(last_secs_from_ymd(2022, 7, 1)), From 12affccf97346ae75cdb8b8ca65a09a83a03c003 Mon Sep 17 00:00:00 2001 From: lemunozm Date: Thu, 6 Jun 2024 12:10:16 +0200 Subject: [PATCH 27/28] compilation fixes --- pallets/loans/src/benchmarking.rs | 2 +- runtime/integration-tests/src/generic/cases/loans.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pallets/loans/src/benchmarking.rs b/pallets/loans/src/benchmarking.rs index 7ce84d45a5..84c26e2a3a 100644 --- a/pallets/loans/src/benchmarking.rs +++ b/pallets/loans/src/benchmarking.rs @@ -200,7 +200,7 @@ where } fn create_mutation() -> LoanMutation { - LoanMutation::InterestPayments(InterestPayments::None) + LoanMutation::InterestPayments(InterestPayments::OnceAtMaturity) } fn propose_mutation(pool_id: T::PoolId, loan_id: T::LoanId) -> T::Hash { diff --git a/runtime/integration-tests/src/generic/cases/loans.rs b/runtime/integration-tests/src/generic/cases/loans.rs index 26fc9fb16f..dc995d7b6e 100644 --- a/runtime/integration-tests/src/generic/cases/loans.rs +++ b/runtime/integration-tests/src/generic/cases/loans.rs @@ -134,7 +134,7 @@ mod common { date: now + SECONDS_PER_MINUTE, extension: SECONDS_PER_MINUTE / 2, }, - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, interest_rate: InterestRate::Fixed { From b7ee63518282c8a3ef0d74a1582f4ead899df473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Enrique=20Mu=C3=B1oz=20Mart=C3=ADn?= Date: Fri, 7 Jun 2024 09:20:16 +0200 Subject: [PATCH 28/28] Loans: multi cashflows fix external loan (#1864) * correct principal/interest for both kind of loans * support for external prices * add external test --- pallets/loans/src/entities/loans.rs | 6 +- .../loans/src/entities/pricing/external.rs | 16 +-- pallets/loans/src/lib.rs | 5 +- pallets/loans/src/tests/borrow_loan.rs | 104 +++++++++++++++--- pallets/loans/src/tests/mod.rs | 5 +- pallets/loans/src/tests/util.rs | 7 ++ pallets/loans/src/types/cashflow.rs | 9 +- 7 files changed, 122 insertions(+), 30 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 51f16d6359..4fe6eecda0 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -253,6 +253,10 @@ impl ActiveLoan { self.schedule.generate_cashflows( self.repayments_on_schedule_until, self.principal()?, + match &self.pricing { + ActivePricing::Internal(_) => self.principal()?, + ActivePricing::External(inner) => inner.outstanding_notional_principal()?, + }, self.pricing.interest().rate(), ) } @@ -580,7 +584,7 @@ impl TryFrom<(T::PoolId, ActiveLoan)> for ActiveLoanInfo { Self { present_value, - outstanding_principal: inner.outstanding_principal(pool_id, maturity)?, + outstanding_principal: inner.outstanding_priced_principal(pool_id, maturity)?, outstanding_interest: inner.outstanding_interest()?, current_price: Some(inner.current_price(pool_id, maturity)?), active_loan, diff --git a/pallets/loans/src/entities/pricing/external.rs b/pallets/loans/src/entities/pricing/external.rs index e9652ac518..00d15a7532 100644 --- a/pallets/loans/src/entities/pricing/external.rs +++ b/pallets/loans/src/entities/pricing/external.rs @@ -208,7 +208,13 @@ impl ExternalActivePricing { } } - pub fn outstanding_principal( + pub fn outstanding_notional_principal(&self) -> Result { + Ok(self + .outstanding_quantity + .ensure_mul_int(self.info.notional)?) + } + + pub fn outstanding_priced_principal( &self, pool_id: T::PoolId, maturity: Option, @@ -218,12 +224,8 @@ impl ExternalActivePricing { } pub fn outstanding_interest(&self) -> Result { - let outstanding_notional = self - .outstanding_quantity - .ensure_mul_int(self.info.notional)?; - let debt = self.interest.current_debt()?; - Ok(debt.ensure_sub(outstanding_notional)?) + Ok(debt.ensure_sub(self.outstanding_notional_principal()?)?) } pub fn present_value( @@ -231,7 +233,7 @@ impl ExternalActivePricing { pool_id: T::PoolId, maturity: Option, ) -> Result { - self.outstanding_principal(pool_id, maturity) + self.outstanding_priced_principal(pool_id, maturity) } pub fn present_value_cached( diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index f3c3bceae7..fabd13a85f 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -59,11 +59,10 @@ pub mod util; mod weights; -#[cfg(test)] -mod tests; - #[cfg(feature = "runtime-benchmarks")] mod benchmarking; +#[cfg(test)] +mod tests; pub use pallet::*; pub use weights::WeightInfo; diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index f4006bacf8..343f20478d 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -615,24 +615,24 @@ fn increase_debt_does_not_withdraw() { mod cashflow { use super::*; - fn create_cashflow_loan() -> LoanId { - util::create_loan(LoanInfo { - schedule: RepaymentSchedule { - maturity: Maturity::Fixed { - date: (now() + YEAR).as_secs(), - extension: 0, - }, - interest_payments: InterestPayments::Monthly(1), - pay_down_schedule: PayDownSchedule::None, + fn monthly_schedule() -> RepaymentSchedule { + RepaymentSchedule { + maturity: Maturity::Fixed { + date: (now() + YEAR).as_secs(), + extension: 0, }, - ..util::base_internal_loan() - }) + interest_payments: InterestPayments::Monthly(1), + pay_down_schedule: PayDownSchedule::None, + } } #[test] - fn computed_correctly() { + fn computed_correctly_internal_pricing() { new_test_ext().execute_with(|| { - let loan_id = create_cashflow_loan(); + let loan_id = util::create_loan(LoanInfo { + schedule: monthly_schedule(), + ..util::base_internal_loan() + }); config_mocks(COLLATERAL_VALUE / 2); assert_ok!(Loans::borrow( @@ -689,10 +689,76 @@ mod cashflow { }); } + #[test] + fn computed_correctly_external_pricing() { + new_test_ext().execute_with(|| { + let loan_id = util::create_loan(LoanInfo { + schedule: monthly_schedule(), + ..util::base_external_loan() + }); + + let amount = ExternalAmount::new(QUANTITY / 2.into(), PRICE_VALUE); + config_mocks(amount.balance().unwrap()); + + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::External(amount.clone()) + )); + + let loan = util::get_loan(loan_id); + + let total_principal = amount.balance().unwrap(); + dbg!(total_principal); + let acc_interest_rate_per_year = checked_pow( + util::default_interest_rate().per_sec().unwrap(), + SECONDS_PER_YEAR as usize, + ) + .unwrap(); + + let outstanding_notional = util::current_extenal_pricing(loan_id) + .outstanding_notional_principal() + .unwrap(); + + let total_interest = acc_interest_rate_per_year.saturating_mul_int(total_principal) + - outstanding_notional; + + // The -1 comes from a precission issue when computing cashflows. + let principal = (total_principal - 1) / 12; + let interest = total_interest / 12; + + assert_eq!( + loan.expected_cashflows() + .unwrap() + .into_iter() + .map(|payment| (payment.when, payment.principal, payment.interest)) + .collect::>(), + vec![ + (last_secs_from_ymd(1970, 2, 1), principal, interest), + (last_secs_from_ymd(1970, 3, 1), principal, interest), + (last_secs_from_ymd(1970, 4, 1), principal, interest), + (last_secs_from_ymd(1970, 5, 1), principal, interest), + (last_secs_from_ymd(1970, 6, 1), principal, interest), + (last_secs_from_ymd(1970, 7, 1), principal, interest), + (last_secs_from_ymd(1970, 8, 1), principal, interest), + (last_secs_from_ymd(1970, 9, 1), principal, interest), + (last_secs_from_ymd(1970, 10, 1), principal, interest), + (last_secs_from_ymd(1970, 11, 1), principal, interest), + (last_secs_from_ymd(1970, 12, 1), principal, interest), + (last_secs_from_ymd(1971, 1, 1), principal, interest), + ] + ); + }); + } + #[test] fn borrow_twice_same_month() { new_test_ext().execute_with(|| { - let loan_id = create_cashflow_loan(); + let loan_id = util::create_loan(LoanInfo { + schedule: monthly_schedule(), + ..util::base_internal_loan() + }); config_mocks(COLLATERAL_VALUE / 2); assert_ok!(Loans::borrow( @@ -720,7 +786,10 @@ mod cashflow { #[test] fn payment_overdue() { new_test_ext().execute_with(|| { - let loan_id = create_cashflow_loan(); + let loan_id = util::create_loan(LoanInfo { + schedule: monthly_schedule(), + ..util::base_internal_loan() + }); config_mocks(COLLATERAL_VALUE / 2); assert_ok!(Loans::borrow( @@ -767,7 +836,10 @@ mod cashflow { #[test] fn allow_borrow_again_after_repay_overdue_amount() { new_test_ext().execute_with(|| { - let loan_id = create_cashflow_loan(); + let loan_id = util::create_loan(LoanInfo { + schedule: monthly_schedule(), + ..util::base_internal_loan() + }); config_mocks(COLLATERAL_VALUE / 2); assert_ok!(Loans::borrow( diff --git a/pallets/loans/src/tests/mod.rs b/pallets/loans/src/tests/mod.rs index f333a8d571..d1c02069a1 100644 --- a/pallets/loans/src/tests/mod.rs +++ b/pallets/loans/src/tests/mod.rs @@ -16,7 +16,10 @@ use super::{ input::{PrincipalInput, RepaidInput}, loans::{ActiveLoan, ActiveLoanInfo, LoanInfo}, pricing::{ - external::{ExternalAmount, ExternalPricing, MaxBorrowAmount as ExtMaxBorrowAmount}, + external::{ + ExternalActivePricing, ExternalAmount, ExternalPricing, + MaxBorrowAmount as ExtMaxBorrowAmount, + }, internal::{InternalPricing, MaxBorrowAmount as IntMaxBorrowAmount}, ActivePricing, Pricing, }, diff --git a/pallets/loans/src/tests/util.rs b/pallets/loans/src/tests/util.rs index f58f29e849..4bd83467c6 100644 --- a/pallets/loans/src/tests/util.rs +++ b/pallets/loans/src/tests/util.rs @@ -27,6 +27,13 @@ pub fn current_loan_debt(loan_id: LoanId) -> Balance { } } +pub fn current_extenal_pricing(loan_id: LoanId) -> ExternalActivePricing { + match get_loan(loan_id).pricing() { + ActivePricing::Internal(_) => panic!("expected external pricing"), + ActivePricing::External(pricing) => pricing.clone(), + } +} + pub fn borrower(loan_id: LoanId) -> AccountId { match CreatedLoan::::get(POOL_A, loan_id) { Some(created_loan) => *created_loan.borrower(), diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs index dcd0485c9a..4384a50b26 100644 --- a/pallets/loans/src/types/cashflow.rs +++ b/pallets/loans/src/types/cashflow.rs @@ -148,6 +148,7 @@ impl RepaymentSchedule { &self, origination_date: Seconds, principal: Balance, + principal_base: Balance, interest_rate: &InterestRate, ) -> Result>, DispatchError> where @@ -177,7 +178,7 @@ impl RepaymentSchedule { let interest_rate_per_lifetime = ensure_pow(interest_rate.per_sec()?, lifetime)?; let interest_at_maturity = interest_rate_per_lifetime .ensure_mul_int(principal)? - .ensure_sub(principal)?; + .ensure_sub(principal_base)?; timeflow .into_iter() @@ -199,6 +200,7 @@ impl RepaymentSchedule { &self, origination_date: Seconds, principal: Balance, + principal_base: Balance, interest_rate: &InterestRate, until: Seconds, ) -> Result @@ -206,7 +208,8 @@ impl RepaymentSchedule { Balance: FixedPointOperand + EnsureAdd + EnsureSub, Rate: FixedPointNumber, { - let cashflow = self.generate_cashflows(origination_date, principal, interest_rate)?; + let cashflow = + self.generate_cashflows(origination_date, principal, principal_base, interest_rate)?; let total_amount = cashflow .into_iter() @@ -363,6 +366,7 @@ pub mod tests { .generate_cashflows( last_secs_from_ymd(2022, 4, 16), 25000u128, /* principal */ + 25000u128, /* principal as base */ &InterestRate::Fixed { rate_per_year: Rate::from_float(0.12), compounding: CompoundingSchedule::Secondly, @@ -464,6 +468,7 @@ pub mod tests { .generate_cashflows( last_secs_from_ymd(2022, 4, 16), 25000u128, /* principal */ + 25000u128, /* principal as base */ &InterestRate::Fixed { rate_per_year: Rate::from_float(0.12), compounding: CompoundingSchedule::Secondly,