diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 0489f22cf7..da94865ca3 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -222,7 +222,7 @@ impl ActiveLoan { &self.borrower } - pub fn maturity_date(&self) -> Seconds { + pub fn maturity_date(&self) -> Option { self.schedule.maturity.date() } @@ -259,9 +259,11 @@ impl ActiveLoan { ) -> Result { let now = T::Time::now(); match trigger { - WriteOffTrigger::PrincipalOverdue(overdue_secs) => { - Ok(now >= self.maturity_date().ensure_add(*overdue_secs)?) - } + WriteOffTrigger::PrincipalOverdue(overdue_secs) => Ok(now + > self + .maturity_date() + .unwrap_or(T::Time::now()) + .ensure_add(*overdue_secs)?), WriteOffTrigger::PriceOutdated(secs) => match &self.pricing { ActivePricing::External(pricing) => { Ok(now >= pricing.last_updated(pool_id).ensure_add(*secs)?) diff --git a/pallets/loans/src/entities/pricing/external.rs b/pallets/loans/src/entities/pricing/external.rs index d0ecc8e897..ea6d691c43 100644 --- a/pallets/loans/src/entities/pricing/external.rs +++ b/pallets/loans/src/entities/pricing/external.rs @@ -161,8 +161,10 @@ impl ExternalActivePricing { pub fn current_price( &self, pool_id: T::PoolId, - maturity: Seconds, + maturity: Option, ) -> Result { + let maturity = maturity.unwrap_or(T::Time::now()); + Ok(match T::PriceRegistry::get(&self.info.price_id, &pool_id) { Ok(data) => data.0, Err(_) => self.linear_accrual_price(maturity)?, @@ -172,7 +174,7 @@ impl ExternalActivePricing { pub fn outstanding_principal( &self, pool_id: T::PoolId, - maturity: Seconds, + maturity: Option, ) -> Result { let price = self.current_price(pool_id, maturity)?; Ok(self.outstanding_quantity.ensure_mul_int(price)?) @@ -190,7 +192,7 @@ impl ExternalActivePricing { pub fn present_value( &self, pool_id: T::PoolId, - maturity: Seconds, + maturity: Option, ) -> Result { self.outstanding_principal(pool_id, maturity) } @@ -198,11 +200,14 @@ impl ExternalActivePricing { pub fn present_value_cached( &self, cache: &BTreeMap, - maturity: Seconds, + maturity: Option, ) -> Result { let price = match cache.get(&self.info.price_id) { Some(data) => *data, - None => self.linear_accrual_price(maturity)?, + None => { + let maturity = maturity.unwrap_or(T::Time::now()); + self.linear_accrual_price(maturity)? + } }; Ok(self.outstanding_quantity.ensure_mul_int(price)?) } diff --git a/pallets/loans/src/entities/pricing/internal.rs b/pallets/loans/src/entities/pricing/internal.rs index 1c285ce261..10ea0d779a 100644 --- a/pallets/loans/src/entities/pricing/internal.rs +++ b/pallets/loans/src/entities/pricing/internal.rs @@ -90,10 +90,13 @@ impl InternalActivePricing { &self, debt: T::Balance, origination_date: Seconds, - maturity_date: Seconds, + maturity_date: Option, ) -> Result { match &self.info.valuation_method { ValuationMethod::DiscountedCashFlow(dcf) => { + let maturity_date = + maturity_date.ok_or(Error::::MaturityDateNeededForValuationMethod)?; + let now = T::Time::now(); Ok(dcf.compute_present_value( debt, @@ -110,7 +113,7 @@ impl InternalActivePricing { pub fn present_value( &self, origination_date: Seconds, - maturity_date: Seconds, + maturity_date: Option, ) -> Result { let debt = self.interest.current_debt()?; self.compute_present_value(debt, origination_date, maturity_date) @@ -120,7 +123,7 @@ impl InternalActivePricing { &self, cache: &Rates, origination_date: Seconds, - maturity_date: Seconds, + maturity_date: Option, ) -> Result where Rates: RateCollection, diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index 7283fc62ad..c1648aca70 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -398,6 +398,10 @@ pub mod pallet { TransferDebtToSameLoan, /// Emits when debt is transfered with different repaid/borrow amounts TransferDebtAmountMismatched, + /// Emits when the loan has no maturity date set, but the valuation + /// method needs one. Making valuation and maturity settings + /// incompatible. + MaturityDateNeededForValuationMethod, } impl From for Error { diff --git a/pallets/loans/src/tests/mock.rs b/pallets/loans/src/tests/mock.rs index c2cc4e8ce3..c000cf79f6 100644 --- a/pallets/loans/src/tests/mock.rs +++ b/pallets/loans/src/tests/mock.rs @@ -64,6 +64,9 @@ pub const POOL_OTHER_ACCOUNT: AccountId = 100; pub const COLLATERAL_VALUE: Balance = 10000; pub const DEFAULT_INTEREST_RATE: f64 = 0.5; +pub const DEFAULT_DISCOUNT_RATE: f64 = 0.02; +pub const DEFAULT_PROBABILITY_OF_DEFAULT: f64 = 0.1; +pub const DEFAULT_LOSS_GIVEN_DEFAULT: f64 = 0.5; pub const POLICY_PERCENTAGE: f64 = 0.5; pub const POLICY_PENALTY: f64 = 0.5; pub const REGISTER_PRICE_ID: PriceId = 42; diff --git a/pallets/loans/src/tests/portfolio_valuation.rs b/pallets/loans/src/tests/portfolio_valuation.rs index af1b548723..2d0f4c68d4 100644 --- a/pallets/loans/src/tests/portfolio_valuation.rs +++ b/pallets/loans/src/tests/portfolio_valuation.rs @@ -218,3 +218,62 @@ fn empty_portfolio_with_current_timestamp() { ); }); } + +#[test] +fn internal_dcf_with_no_maturity() { + new_test_ext().execute_with(|| { + let mut internal = util::dcf_internal_loan(); + internal.schedule.maturity = Maturity::None; + + let loan_id = util::create_loan(LoanInfo { + collateral: ASSET_BA, + ..internal + }); + + MockPools::mock_withdraw(|_, _, _| Ok(())); + + assert_noop!( + Loans::borrow( + RuntimeOrigin::signed(util::borrower(loan_id)), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE), + ), + Error::::MaturityDateNeededForValuationMethod + ); + }); +} + +#[test] +fn internal_oustanding_debt_with_no_maturity() { + new_test_ext().execute_with(|| { + let mut internal = util::base_internal_loan(); + internal.schedule.maturity = Maturity::None; + + let loan_id = util::create_loan(LoanInfo { + collateral: ASSET_BA, + ..internal + }); + util::borrow_loan(loan_id, PrincipalInput::Internal(COLLATERAL_VALUE)); + + config_mocks(); + let pv = util::current_loan_pv(loan_id); + update_portfolio(); + expected_portfolio(pv); + + advance_time(YEAR); + + update_portfolio(); + expected_portfolio( + Rate::from_float(util::interest_for(DEFAULT_INTEREST_RATE, YEAR)) + .checked_mul_int(COLLATERAL_VALUE) + .unwrap(), + ); + + util::repay_loan(loan_id, PrincipalInput::Internal(COLLATERAL_VALUE)); + + config_mocks(); + update_portfolio(); + expected_portfolio(0); + }); +} diff --git a/pallets/loans/src/tests/util.rs b/pallets/loans/src/tests/util.rs index 086d4c6a3a..5cb6b7bac2 100644 --- a/pallets/loans/src/tests/util.rs +++ b/pallets/loans/src/tests/util.rs @@ -81,6 +81,44 @@ pub fn base_internal_pricing() -> InternalPricing { } } +pub fn dcf_internal_pricing() -> InternalPricing { + InternalPricing { + collateral_value: COLLATERAL_VALUE, + max_borrow_amount: util::total_borrowed_rate(1.0), + valuation_method: ValuationMethod::DiscountedCashFlow(DiscountedCashFlow { + probability_of_default: Rate::from_float(DEFAULT_PROBABILITY_OF_DEFAULT), + loss_given_default: Rate::from_float(DEFAULT_LOSS_GIVEN_DEFAULT), + discount_rate: InterestRate::Fixed { + rate_per_year: Rate::from_float(DEFAULT_DISCOUNT_RATE), + compounding: CompoundingSchedule::Secondly, + }, + }), + } +} + +pub fn dcf_internal_loan() -> LoanInfo { + LoanInfo { + schedule: RepaymentSchedule { + maturity: Maturity::Fixed { + date: (now() + YEAR).as_secs(), + extension: (YEAR / 2).as_secs(), + }, + 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, + }, + collateral: ASSET_AA, + pricing: Pricing::Internal(dcf_internal_pricing()), + restrictions: LoanRestrictions { + borrows: BorrowRestrictions::NotWrittenOff, + repayments: RepayRestrictions::None, + }, + } +} + pub fn base_internal_loan() -> LoanInfo { LoanInfo { schedule: RepaymentSchedule { diff --git a/pallets/loans/src/types/mod.rs b/pallets/loans/src/types/mod.rs index 922f3cda39..01a740d8d4 100644 --- a/pallets/loans/src/types/mod.rs +++ b/pallets/loans/src/types/mod.rs @@ -95,6 +95,8 @@ pub enum Maturity { /// Extension in secs, without special permissions extension: Seconds, }, + /// No Maturity date + None, } impl Maturity { @@ -102,15 +104,17 @@ impl Maturity { Self::Fixed { date, extension: 0 } } - pub fn date(&self) -> Seconds { + pub fn date(&self) -> Option { match self { - Maturity::Fixed { date, .. } => *date, + Maturity::Fixed { date, .. } => Some(*date), + Maturity::None => None, } } pub fn is_valid(&self, now: Seconds) -> bool { match self { Maturity::Fixed { date, .. } => *date > now, + Maturity::None => true, } } @@ -120,6 +124,7 @@ impl Maturity { date.ensure_add_assign(value)?; extension.ensure_sub_assign(value) } + Maturity::None => Err(ArithmeticError::Overflow), } } }