mirror of
https://github.com/XRPLF/rippled.git
synced 2025-12-06 17:27:55 +00:00
2001 lines
76 KiB
C++
2001 lines
76 KiB
C++
#include <xrpld/app/misc/LendingHelpers.h>
|
|
// DO NOT REMOVE forces header file include to sort first
|
|
#include <xrpld/app/tx/detail/VaultCreate.h>
|
|
|
|
namespace ripple {
|
|
|
|
bool
|
|
checkLendingProtocolDependencies(PreflightContext const& ctx)
|
|
{
|
|
return ctx.rules.enabled(featureSingleAssetVault) &&
|
|
VaultCreate::checkExtraFeatures(ctx);
|
|
}
|
|
|
|
LoanPaymentParts&
|
|
LoanPaymentParts::operator+=(LoanPaymentParts const& other)
|
|
{
|
|
XRPL_ASSERT(
|
|
|
|
other.principalPaid >= beast::zero,
|
|
"ripple::LoanPaymentParts::operator+= : other principal "
|
|
"non-negative");
|
|
XRPL_ASSERT(
|
|
other.interestPaid >= beast::zero,
|
|
"ripple::LoanPaymentParts::operator+= : other interest paid "
|
|
"non-negative");
|
|
XRPL_ASSERT(
|
|
other.feePaid >= beast::zero,
|
|
"ripple::LoanPaymentParts::operator+= : other fee paid "
|
|
"non-negative");
|
|
|
|
principalPaid += other.principalPaid;
|
|
interestPaid += other.interestPaid;
|
|
valueChange += other.valueChange;
|
|
feePaid += other.feePaid;
|
|
return *this;
|
|
}
|
|
|
|
bool
|
|
LoanPaymentParts::operator==(LoanPaymentParts const& other) const
|
|
{
|
|
return principalPaid == other.principalPaid &&
|
|
interestPaid == other.interestPaid &&
|
|
valueChange == other.valueChange && feePaid == other.feePaid;
|
|
}
|
|
|
|
/* Converts annualized interest rate to per-payment-period rate.
|
|
* The rate is prorated based on the payment interval in seconds.
|
|
*
|
|
* Equation (1) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Number
|
|
loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval)
|
|
{
|
|
// Need floating point math, since we're dividing by a large number
|
|
return tenthBipsOfValue(Number(paymentInterval), interestRate) /
|
|
secondsInYear;
|
|
}
|
|
|
|
/* Checks if a value is already rounded to the specified scale.
|
|
* Returns true if rounding down and rounding up produce the same result,
|
|
* indicating no further precision exists beyond the scale.
|
|
*/
|
|
bool
|
|
isRounded(Asset const& asset, Number const& value, std::int32_t scale)
|
|
{
|
|
return roundToAsset(asset, value, scale, Number::downward) ==
|
|
roundToAsset(asset, value, scale, Number::upward);
|
|
}
|
|
|
|
namespace detail {
|
|
|
|
void
|
|
LoanStateDeltas::nonNegative()
|
|
{
|
|
if (principal < beast::zero)
|
|
principal = numZero;
|
|
if (interest < beast::zero)
|
|
interest = numZero;
|
|
if (managementFee < beast::zero)
|
|
managementFee = numZero;
|
|
}
|
|
|
|
/* Computes (1 + periodicRate)^paymentsRemaining for amortization calculations.
|
|
*
|
|
* Equation (5) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Number
|
|
computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining)
|
|
{
|
|
return power(1 + periodicRate, paymentsRemaining);
|
|
}
|
|
|
|
/* Computes the payment factor used in standard amortization formulas.
|
|
* This factor converts principal to periodic payment amount.
|
|
*
|
|
* Equation (6) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Number
|
|
computePaymentFactor(
|
|
Number const& periodicRate,
|
|
std::uint32_t paymentsRemaining)
|
|
{
|
|
if (paymentsRemaining == 0)
|
|
return numZero;
|
|
|
|
// For zero interest, payment factor is simply 1/paymentsRemaining
|
|
if (periodicRate == beast::zero)
|
|
return Number{1} / paymentsRemaining;
|
|
|
|
Number const raisedRate =
|
|
computeRaisedRate(periodicRate, paymentsRemaining);
|
|
|
|
return (periodicRate * raisedRate) / (raisedRate - 1);
|
|
}
|
|
|
|
/* Calculates the periodic payment amount using standard amortization formula.
|
|
* For interest-free loans, returns principal divided equally across payments.
|
|
*
|
|
* Equation (7) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Number
|
|
loanPeriodicPayment(
|
|
Number const& principalOutstanding,
|
|
Number const& periodicRate,
|
|
std::uint32_t paymentsRemaining)
|
|
{
|
|
if (principalOutstanding == 0 || paymentsRemaining == 0)
|
|
return 0;
|
|
|
|
// Interest-free loans: equal principal payments
|
|
if (periodicRate == beast::zero)
|
|
return principalOutstanding / paymentsRemaining;
|
|
|
|
return principalOutstanding *
|
|
computePaymentFactor(periodicRate, paymentsRemaining);
|
|
}
|
|
|
|
/* Reverse-calculates principal from periodic payment amount.
|
|
* Used to determine theoretical principal at any point in the schedule.
|
|
*
|
|
* Equation (10) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Number
|
|
loanPrincipalFromPeriodicPayment(
|
|
Number const& periodicPayment,
|
|
Number const& periodicRate,
|
|
std::uint32_t paymentsRemaining)
|
|
{
|
|
if (paymentsRemaining == 0)
|
|
return numZero;
|
|
|
|
if (periodicRate == 0)
|
|
return periodicPayment * paymentsRemaining;
|
|
|
|
return periodicPayment /
|
|
computePaymentFactor(periodicRate, paymentsRemaining);
|
|
}
|
|
|
|
/*
|
|
* Computes the interest and management fee parts from interest amount.
|
|
*
|
|
* Equation (33) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
std::pair<Number, Number>
|
|
computeInterestAndFeeParts(
|
|
Asset const& asset,
|
|
Number const& interest,
|
|
TenthBips16 managementFeeRate,
|
|
std::int32_t loanScale)
|
|
{
|
|
auto const fee =
|
|
computeManagementFee(asset, interest, managementFeeRate, loanScale);
|
|
|
|
return std::make_pair(interest - fee, fee);
|
|
}
|
|
|
|
/* Calculates penalty interest accrued on overdue payments.
|
|
* Returns 0 if payment is not late.
|
|
*
|
|
* Equation (16) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Number
|
|
loanLatePaymentInterest(
|
|
Number const& principalOutstanding,
|
|
TenthBips32 lateInterestRate,
|
|
NetClock::time_point parentCloseTime,
|
|
std::uint32_t nextPaymentDueDate)
|
|
{
|
|
if (principalOutstanding == beast::zero)
|
|
return numZero;
|
|
|
|
if (lateInterestRate == TenthBips32{0})
|
|
return numZero;
|
|
|
|
auto const now = parentCloseTime.time_since_epoch().count();
|
|
|
|
// If the payment is not late by any amount of time, then there's no late
|
|
// interest
|
|
if (now <= nextPaymentDueDate)
|
|
return 0;
|
|
|
|
// Equation (3) from XLS-66 spec, Section A-2 Equation Glossary
|
|
auto const secondsOverdue = now - nextPaymentDueDate;
|
|
|
|
auto const rate = loanPeriodicRate(lateInterestRate, secondsOverdue);
|
|
|
|
return principalOutstanding * rate;
|
|
}
|
|
|
|
/* Calculates interest accrued since the last payment based on time elapsed.
|
|
* Returns 0 if loan is paid ahead of schedule.
|
|
*
|
|
* Equation (27) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Number
|
|
loanAccruedInterest(
|
|
Number const& principalOutstanding,
|
|
Number const& periodicRate,
|
|
NetClock::time_point parentCloseTime,
|
|
std::uint32_t startDate,
|
|
std::uint32_t prevPaymentDate,
|
|
std::uint32_t paymentInterval)
|
|
{
|
|
if (periodicRate == beast::zero)
|
|
return numZero;
|
|
|
|
if (paymentInterval == 0)
|
|
return numZero;
|
|
|
|
auto const lastPaymentDate = std::max(prevPaymentDate, startDate);
|
|
auto const now = parentCloseTime.time_since_epoch().count();
|
|
|
|
// If the loan has been paid ahead, then "lastPaymentDate" is in the future,
|
|
// and no interest has accrued.
|
|
if (now <= lastPaymentDate)
|
|
return numZero;
|
|
|
|
// Equation (4) from XLS-66 spec, Section A-2 Equation Glossary
|
|
auto const secondsSinceLastPayment = now - lastPaymentDate;
|
|
|
|
// Division is more likely to introduce rounding errors, which will then get
|
|
// amplified by multiplication. Therefore, we first multiply, and only then
|
|
// divide.
|
|
return principalOutstanding * periodicRate * secondsSinceLastPayment /
|
|
paymentInterval;
|
|
}
|
|
|
|
/* Applies a payment to the loan state and returns the breakdown of amounts
|
|
* paid.
|
|
*
|
|
* This is the core function that updates the Loan ledger object fields based on
|
|
* a computed payment.
|
|
|
|
* The function is templated to work with both direct Number/uint32_t values
|
|
* (for testing/simulation) and ValueProxy types (for actual ledger updates).
|
|
*/
|
|
template <class NumberProxy, class UInt32Proxy, class UInt32OptionalProxy>
|
|
LoanPaymentParts
|
|
doPayment(
|
|
ExtendedPaymentComponents const& payment,
|
|
NumberProxy& totalValueOutstandingProxy,
|
|
NumberProxy& principalOutstandingProxy,
|
|
NumberProxy& managementFeeOutstandingProxy,
|
|
UInt32Proxy& paymentRemainingProxy,
|
|
UInt32Proxy& prevPaymentDateProxy,
|
|
UInt32OptionalProxy& nextDueDateProxy,
|
|
std::uint32_t paymentInterval)
|
|
{
|
|
XRPL_ASSERT_PARTS(
|
|
nextDueDateProxy,
|
|
"ripple::detail::doPayment",
|
|
"Next due date proxy set");
|
|
|
|
if (payment.specialCase == PaymentSpecialCase::final)
|
|
{
|
|
XRPL_ASSERT_PARTS(
|
|
principalOutstandingProxy == payment.trackedPrincipalDelta,
|
|
"ripple::detail::doPayment",
|
|
"Full principal payment");
|
|
XRPL_ASSERT_PARTS(
|
|
totalValueOutstandingProxy == payment.trackedValueDelta,
|
|
"ripple::detail::doPayment",
|
|
"Full value payment");
|
|
XRPL_ASSERT_PARTS(
|
|
managementFeeOutstandingProxy == payment.trackedManagementFeeDelta,
|
|
"ripple::detail::doPayment",
|
|
"Full management fee payment");
|
|
|
|
// Mark the loan as complete
|
|
paymentRemainingProxy = 0;
|
|
|
|
// Record when the final payment was made
|
|
prevPaymentDateProxy = *nextDueDateProxy;
|
|
|
|
// Clear the next due date. Setting it to 0 causes
|
|
// it to be removed from the Loan ledger object, saving space.
|
|
nextDueDateProxy = 0;
|
|
|
|
// Zero out all tracked loan balances to mark the loan as paid off.
|
|
// These will be removed from the Loan object since they're default
|
|
// values.
|
|
principalOutstandingProxy = 0;
|
|
totalValueOutstandingProxy = 0;
|
|
managementFeeOutstandingProxy = 0;
|
|
}
|
|
else
|
|
{
|
|
// For regular payments (not overpayments), advance the payment schedule
|
|
if (payment.specialCase != PaymentSpecialCase::extra)
|
|
{
|
|
paymentRemainingProxy -= 1;
|
|
|
|
prevPaymentDateProxy = nextDueDateProxy;
|
|
nextDueDateProxy += paymentInterval;
|
|
}
|
|
XRPL_ASSERT_PARTS(
|
|
principalOutstandingProxy > payment.trackedPrincipalDelta,
|
|
"ripple::detail::doPayment",
|
|
"Partial principal payment");
|
|
XRPL_ASSERT_PARTS(
|
|
totalValueOutstandingProxy > payment.trackedValueDelta,
|
|
"ripple::detail::doPayment",
|
|
"Partial value payment");
|
|
// Management fees are expected to be relatively small, and could get to
|
|
// zero before the loan is paid off
|
|
XRPL_ASSERT_PARTS(
|
|
managementFeeOutstandingProxy >= payment.trackedManagementFeeDelta,
|
|
"ripple::detail::doPayment",
|
|
"Valid management fee");
|
|
|
|
// Apply the payment deltas to reduce the outstanding balances
|
|
principalOutstandingProxy -= payment.trackedPrincipalDelta;
|
|
totalValueOutstandingProxy -= payment.trackedValueDelta;
|
|
managementFeeOutstandingProxy -= payment.trackedManagementFeeDelta;
|
|
}
|
|
|
|
// Principal can never exceed total value (principal is part of total value)
|
|
XRPL_ASSERT_PARTS(
|
|
// Use an explicit cast because the template parameter can be
|
|
// ValueProxy<Number> or Number
|
|
static_cast<Number>(principalOutstandingProxy) <=
|
|
static_cast<Number>(totalValueOutstandingProxy),
|
|
"ripple::detail::doPayment",
|
|
"principal does not exceed total");
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
// Use an explicit cast because the template parameter can be
|
|
// ValueProxy<Number> or Number
|
|
static_cast<Number>(managementFeeOutstandingProxy) >= beast::zero,
|
|
"ripple::detail::doPayment",
|
|
"fee outstanding stays valid");
|
|
|
|
return LoanPaymentParts{
|
|
// Principal paid is straightforward - it's the tracked delta
|
|
.principalPaid = payment.trackedPrincipalDelta,
|
|
|
|
// Interest paid combines:
|
|
// 1. Tracked interest from the amortization schedule
|
|
// (derived from the tracked deltas)
|
|
// 2. Untracked interest (e.g., late payment penalties)
|
|
.interestPaid =
|
|
payment.trackedInterestPart() + payment.untrackedInterest,
|
|
|
|
// Value change represents how the loan's total value changed beyond
|
|
// normal amortization.
|
|
.valueChange = payment.untrackedInterest,
|
|
|
|
// Fee paid combines:
|
|
// 1. Tracked management fees from the amortization schedule
|
|
// 2. Untracked fees (e.g., late payment fees, service fees)
|
|
.feePaid =
|
|
payment.trackedManagementFeeDelta + payment.untrackedManagementFee};
|
|
}
|
|
|
|
/* Simulates an overpayment to validate it won't break the loan's amortization.
|
|
*
|
|
* When a borrower pays more than the scheduled amount, the loan needs to be
|
|
* re-amortized with a lower principal. This function performs that calculation
|
|
* in a "sandbox" using temporary variables, allowing the caller to validate
|
|
* the result before committing changes to the actual ledger.
|
|
*
|
|
* The function preserves accumulated rounding errors across the re-amortization
|
|
* to ensure the loan state remains consistent with its payment history.
|
|
*/
|
|
Expected<LoanPaymentParts, TER>
|
|
tryOverpayment(
|
|
Asset const& asset,
|
|
std::int32_t loanScale,
|
|
ExtendedPaymentComponents const& overpaymentComponents,
|
|
Number& totalValueOutstanding,
|
|
Number& principalOutstanding,
|
|
Number& managementFeeOutstanding,
|
|
Number& periodicPayment,
|
|
TenthBips32 interestRate,
|
|
std::uint32_t paymentInterval,
|
|
Number const& periodicRate,
|
|
std::uint32_t paymentRemaining,
|
|
std::uint32_t prevPaymentDate,
|
|
std::optional<std::uint32_t> nextDueDate,
|
|
TenthBips16 const managementFeeRate,
|
|
beast::Journal j)
|
|
{
|
|
// Calculate what the loan state SHOULD be theoretically (at full precision)
|
|
auto const raw = computeRawLoanState(
|
|
periodicPayment, periodicRate, paymentRemaining, managementFeeRate);
|
|
|
|
// Get the actual loan state (with accumulated rounding from past payments)
|
|
auto const rounded = constructLoanState(
|
|
totalValueOutstanding, principalOutstanding, managementFeeOutstanding);
|
|
|
|
// Calculate the accumulated rounding errors. These need to be preserved
|
|
// across the re-amortization to maintain consistency with the loan's
|
|
// payment history. Without preserving these errors, the loan could end
|
|
// up with a different total value than what the borrower has actually paid.
|
|
auto const errors = rounded - raw;
|
|
|
|
// Compute the new principal by applying the overpayment to the raw
|
|
// (theoretical) principal. Use max with 0 to ensure we never go negative.
|
|
auto const newRawPrincipal = std::max(
|
|
raw.principalOutstanding - overpaymentComponents.trackedPrincipalDelta,
|
|
Number{0});
|
|
|
|
// Compute new loan properties based on the reduced principal. This
|
|
// recalculates the periodic payment, total value, and management fees
|
|
// for the remaining payment schedule.
|
|
auto newLoanProperties = computeLoanProperties(
|
|
asset,
|
|
newRawPrincipal,
|
|
interestRate,
|
|
paymentInterval,
|
|
paymentRemaining,
|
|
managementFeeRate,
|
|
loanScale,
|
|
j);
|
|
|
|
JLOG(j.debug()) << "new periodic payment: "
|
|
<< newLoanProperties.periodicPayment
|
|
<< ", new total value: "
|
|
<< newLoanProperties.totalValueOutstanding
|
|
<< ", first payment principal: "
|
|
<< newLoanProperties.firstPaymentPrincipal;
|
|
|
|
// Calculate what the new loan state should be with the new periodic payment
|
|
auto const newRaw = computeRawLoanState(
|
|
newLoanProperties.periodicPayment,
|
|
periodicRate,
|
|
paymentRemaining,
|
|
managementFeeRate) +
|
|
errors;
|
|
|
|
JLOG(j.debug()) << "new raw value: " << newRaw.valueOutstanding
|
|
<< ", principal: " << newRaw.principalOutstanding
|
|
<< ", interest gross: " << newRaw.interestOutstanding();
|
|
// Update the loan state variables with the new values PLUS the preserved
|
|
// rounding errors. This ensures the loan's tracked state remains
|
|
// consistent with its payment history.
|
|
|
|
principalOutstanding = std::clamp(
|
|
roundToAsset(
|
|
asset, newRaw.principalOutstanding, loanScale, Number::upward),
|
|
numZero,
|
|
rounded.principalOutstanding);
|
|
totalValueOutstanding = std::clamp(
|
|
roundToAsset(
|
|
asset,
|
|
principalOutstanding + newRaw.interestOutstanding(),
|
|
loanScale,
|
|
Number::upward),
|
|
numZero,
|
|
rounded.valueOutstanding);
|
|
managementFeeOutstanding = std::clamp(
|
|
roundToAsset(asset, newRaw.managementFeeDue, loanScale),
|
|
numZero,
|
|
rounded.managementFeeDue);
|
|
|
|
auto const newRounded = constructLoanState(
|
|
totalValueOutstanding, principalOutstanding, managementFeeOutstanding);
|
|
|
|
// Update newLoanProperties so that checkLoanGuards can make an accurate
|
|
// evaluation.
|
|
newLoanProperties.totalValueOutstanding = newRounded.valueOutstanding;
|
|
|
|
JLOG(j.debug()) << "new rounded value: " << newRounded.valueOutstanding
|
|
<< ", principal: " << newRounded.principalOutstanding
|
|
<< ", interest gross: " << newRounded.interestOutstanding();
|
|
|
|
// Update the periodic payment to reflect the re-amortized schedule
|
|
periodicPayment = newLoanProperties.periodicPayment;
|
|
|
|
// check that the loan is still valid
|
|
if (auto const ter = checkLoanGuards(
|
|
asset,
|
|
principalOutstanding,
|
|
// The loan may have been created with interest, but for
|
|
// small interest amounts, that may have already been paid
|
|
// off. Check what's still outstanding. This should
|
|
// guarantee that the interest checks pass.
|
|
newRounded.interestOutstanding() != beast::zero,
|
|
paymentRemaining,
|
|
newLoanProperties,
|
|
j))
|
|
{
|
|
JLOG(j.warn()) << "Principal overpayment would cause the loan to be in "
|
|
"an invalid state. Ignore the overpayment";
|
|
|
|
return Unexpected(tesSUCCESS);
|
|
}
|
|
|
|
// Validate that all computed properties are reasonable. These checks should
|
|
// never fail under normal circumstances, but we validate defensively.
|
|
if (newLoanProperties.periodicPayment <= 0 ||
|
|
newLoanProperties.totalValueOutstanding <= 0 ||
|
|
newLoanProperties.managementFeeOwedToBroker < 0)
|
|
{
|
|
// LCOV_EXCL_START
|
|
JLOG(j.warn()) << "Overpayment not allowed: Computed loan "
|
|
"properties are invalid. Does "
|
|
"not compute. TotalValueOutstanding: "
|
|
<< newLoanProperties.totalValueOutstanding
|
|
<< ", PeriodicPayment : "
|
|
<< newLoanProperties.periodicPayment
|
|
<< ", ManagementFeeOwedToBroker: "
|
|
<< newLoanProperties.managementFeeOwedToBroker;
|
|
return Unexpected(tesSUCCESS);
|
|
// LCOV_EXCL_STOP
|
|
}
|
|
|
|
auto const deltas = rounded - newRounded;
|
|
|
|
// The change in loan management fee is equal to the change between the old
|
|
// and the new outstanding management fees
|
|
XRPL_ASSERT_PARTS(
|
|
deltas.managementFee ==
|
|
rounded.managementFeeDue - managementFeeOutstanding,
|
|
"ripple::detail::tryOverpayment",
|
|
"no fee change");
|
|
|
|
auto const hypotheticalValueOutstanding =
|
|
rounded.valueOutstanding - deltas.principal;
|
|
|
|
// Calculate how the loan's value changed due to the overpayment.
|
|
// This should be negative (value decreased) or zero. A principal
|
|
// overpayment should never increase the loan's value.
|
|
auto const valueChange = newRounded.valueOutstanding -
|
|
hypotheticalValueOutstanding - deltas.managementFee;
|
|
if (valueChange > 0)
|
|
{
|
|
JLOG(j.warn()) << "Principal overpayment would increase the value of "
|
|
"the loan. Ignore the overpayment";
|
|
return Unexpected(tesSUCCESS);
|
|
}
|
|
return LoanPaymentParts{
|
|
// Principal paid is the reduction in principal outstanding
|
|
.principalPaid = deltas.principal,
|
|
// Interest paid is the reduction in interest due
|
|
.interestPaid =
|
|
deltas.interest + overpaymentComponents.untrackedInterest,
|
|
// Value change includes both the reduction from paying down principal
|
|
// (negative) and any untracked interest penalties (positive, e.g., if
|
|
// the overpayment itself incurs a fee)
|
|
.valueChange =
|
|
valueChange + overpaymentComponents.trackedInterestPart(),
|
|
// Fee paid includes both the reduction in tracked management fees and
|
|
// any untracked fees on the overpayment itself
|
|
.feePaid = deltas.managementFee +
|
|
overpaymentComponents.untrackedManagementFee};
|
|
}
|
|
|
|
/* Validates and applies an overpayment to the loan state.
|
|
*
|
|
* This function acts as a wrapper around tryOverpayment(), performing the
|
|
* re-amortization calculation in a sandbox (using temporary copies of the
|
|
* loan state), then validating the results before committing them to the
|
|
* actual ledger via the proxy objects.
|
|
*
|
|
* The two-step process (try in sandbox, then commit) ensures that if the
|
|
* overpayment would leave the loan in an invalid state, we can reject it
|
|
* gracefully without corrupting the ledger data.
|
|
*/
|
|
template <class NumberProxy>
|
|
Expected<LoanPaymentParts, TER>
|
|
doOverpayment(
|
|
Asset const& asset,
|
|
std::int32_t loanScale,
|
|
ExtendedPaymentComponents const& overpaymentComponents,
|
|
NumberProxy& totalValueOutstandingProxy,
|
|
NumberProxy& principalOutstandingProxy,
|
|
NumberProxy& managementFeeOutstandingProxy,
|
|
NumberProxy& periodicPaymentProxy,
|
|
TenthBips32 const interestRate,
|
|
std::uint32_t const paymentInterval,
|
|
Number const& periodicRate,
|
|
std::uint32_t const paymentRemaining,
|
|
std::uint32_t const prevPaymentDate,
|
|
std::optional<std::uint32_t> const nextDueDate,
|
|
TenthBips16 const managementFeeRate,
|
|
beast::Journal j)
|
|
{
|
|
// Create temporary copies of the loan state that can be safely modified
|
|
// and discarded if the overpayment doesn't work out. This prevents
|
|
// corrupting the actual ledger data if validation fails.
|
|
Number totalValueOutstanding = totalValueOutstandingProxy;
|
|
Number principalOutstanding = principalOutstandingProxy;
|
|
Number managementFeeOutstanding = managementFeeOutstandingProxy;
|
|
Number periodicPayment = periodicPaymentProxy;
|
|
|
|
JLOG(j.debug())
|
|
<< "overpayment components:"
|
|
<< ", totalValue before: " << *totalValueOutstandingProxy
|
|
<< ", valueDelta: " << overpaymentComponents.trackedValueDelta
|
|
<< ", principalDelta: " << overpaymentComponents.trackedPrincipalDelta
|
|
<< ", managementFeeDelta: "
|
|
<< overpaymentComponents.trackedManagementFeeDelta
|
|
<< ", interestPart: " << overpaymentComponents.trackedInterestPart()
|
|
<< ", untrackedInterest: " << overpaymentComponents.untrackedInterest
|
|
<< ", totalDue: " << overpaymentComponents.totalDue
|
|
<< ", payments remaining :" << paymentRemaining;
|
|
|
|
// Attempt to re-amortize the loan with the overpayment applied.
|
|
// This modifies the temporary copies, leaving the proxies unchanged.
|
|
auto const ret = tryOverpayment(
|
|
asset,
|
|
loanScale,
|
|
overpaymentComponents,
|
|
totalValueOutstanding,
|
|
principalOutstanding,
|
|
managementFeeOutstanding,
|
|
periodicPayment,
|
|
interestRate,
|
|
paymentInterval,
|
|
periodicRate,
|
|
paymentRemaining,
|
|
prevPaymentDate,
|
|
nextDueDate,
|
|
managementFeeRate,
|
|
j);
|
|
if (!ret)
|
|
return Unexpected(ret.error());
|
|
|
|
auto const& loanPaymentParts = *ret;
|
|
|
|
// Safety check: the principal must have decreased. If it didn't (or
|
|
// increased!), something went wrong in the calculation and we should
|
|
// reject the overpayment.
|
|
if (principalOutstandingProxy <= principalOutstanding)
|
|
{
|
|
// LCOV_EXCL_START
|
|
JLOG(j.warn()) << "Overpayment not allowed: principal "
|
|
<< "outstanding did not decrease. Before: "
|
|
<< *principalOutstandingProxy
|
|
<< ". After: " << principalOutstanding;
|
|
return Unexpected(tesSUCCESS);
|
|
// LCOV_EXCL_STOP
|
|
}
|
|
|
|
// The proxies still hold the original (pre-overpayment) values, which
|
|
// allows us to compute deltas and verify they match what we expect
|
|
// from the overpaymentComponents and loanPaymentParts.
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
overpaymentComponents.trackedPrincipalDelta ==
|
|
principalOutstandingProxy - principalOutstanding,
|
|
"ripple::detail::doOverpayment",
|
|
"principal change agrees");
|
|
|
|
// I'm not 100% sure the following asserts are correct. If in doubt, and
|
|
// everything else works, remove any that cause trouble.
|
|
|
|
JLOG(j.debug()) << "valueChange: " << loanPaymentParts.valueChange
|
|
<< ", totalValue before: " << *totalValueOutstandingProxy
|
|
<< ", totalValue after: " << totalValueOutstanding
|
|
<< ", totalValue delta: "
|
|
<< (totalValueOutstandingProxy - totalValueOutstanding)
|
|
<< ", principalDelta: "
|
|
<< overpaymentComponents.trackedPrincipalDelta
|
|
<< ", principalPaid: " << loanPaymentParts.principalPaid
|
|
<< ", Computed difference: "
|
|
<< overpaymentComponents.trackedPrincipalDelta -
|
|
(totalValueOutstandingProxy - totalValueOutstanding);
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
loanPaymentParts.valueChange ==
|
|
totalValueOutstanding -
|
|
(totalValueOutstandingProxy -
|
|
overpaymentComponents.trackedPrincipalDelta) +
|
|
overpaymentComponents.trackedInterestPart(),
|
|
"ripple::detail::doOverpayment",
|
|
"interest paid agrees");
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
overpaymentComponents.trackedPrincipalDelta ==
|
|
loanPaymentParts.principalPaid,
|
|
"ripple::detail::doOverpayment",
|
|
"principal payment matches");
|
|
|
|
// All validations passed, so update the proxy objects (which will
|
|
// modify the actual Loan ledger object)
|
|
totalValueOutstandingProxy = totalValueOutstanding;
|
|
principalOutstandingProxy = principalOutstanding;
|
|
managementFeeOutstandingProxy = managementFeeOutstanding;
|
|
periodicPaymentProxy = periodicPayment;
|
|
|
|
return loanPaymentParts;
|
|
}
|
|
|
|
/* Computes the payment components for a late payment.
|
|
*
|
|
* A late payment is made after the grace period has expired and includes:
|
|
* 1. All components of a regular periodic payment
|
|
* 2. Late payment penalty interest (accrued since the due date)
|
|
* 3. Late payment fee charged by the broker
|
|
*
|
|
* The late penalty interest increases the loan's total value (the borrower
|
|
* owes more than scheduled), while the regular payment components follow
|
|
* the normal amortization schedule.
|
|
*
|
|
* Implements equation (15) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Expected<ExtendedPaymentComponents, TER>
|
|
computeLatePayment(
|
|
Asset const& asset,
|
|
ApplyView const& view,
|
|
Number const& principalOutstanding,
|
|
std::int32_t nextDueDate,
|
|
ExtendedPaymentComponents const& periodic,
|
|
TenthBips32 lateInterestRate,
|
|
std::int32_t loanScale,
|
|
Number const& latePaymentFee,
|
|
STAmount const& amount,
|
|
TenthBips16 managementFeeRate,
|
|
beast::Journal j)
|
|
{
|
|
// Check if the due date has passed. If not, reject the payment as
|
|
// being too soon
|
|
if (!hasExpired(view, nextDueDate))
|
|
return Unexpected(tecTOO_SOON);
|
|
|
|
// Calculate the penalty interest based on how long the payment is overdue.
|
|
auto const latePaymentInterest = loanLatePaymentInterest(
|
|
principalOutstanding,
|
|
lateInterestRate,
|
|
view.parentCloseTime(),
|
|
nextDueDate);
|
|
|
|
// Round the late interest and split it between the vault (net interest)
|
|
// and the broker (management fee portion). This lambda ensures we
|
|
// round before splitting to maintain precision.
|
|
auto const [roundedLateInterest, roundedLateManagementFee] = [&]() {
|
|
auto const interest =
|
|
roundToAsset(asset, latePaymentInterest, loanScale);
|
|
return computeInterestAndFeeParts(
|
|
asset, interest, managementFeeRate, loanScale);
|
|
}();
|
|
|
|
XRPL_ASSERT(
|
|
roundedLateInterest >= 0,
|
|
"ripple::detail::computeLatePayment : valid late interest");
|
|
XRPL_ASSERT_PARTS(
|
|
periodic.specialCase != PaymentSpecialCase::extra,
|
|
"ripple::detail::computeLatePayment",
|
|
"no extra parts to this payment");
|
|
|
|
// Create the late payment components by copying the regular periodic
|
|
// payment and adding the late penalties. We use a lambda to construct
|
|
// this to keep the logic clear. This preserves all the other fields without
|
|
// having to enumerate them.
|
|
|
|
ExtendedPaymentComponents const late = [&]() {
|
|
auto inner = periodic;
|
|
|
|
return ExtendedPaymentComponents{
|
|
inner,
|
|
// Untracked management fee includes:
|
|
// 1. Regular service fee (from periodic.untrackedManagementFee)
|
|
// 2. Late payment fee (fixed penalty)
|
|
// 3. Management fee portion of late interest
|
|
periodic.untrackedManagementFee + latePaymentFee +
|
|
roundedLateManagementFee,
|
|
|
|
// Untracked interest includes:
|
|
// 1. Any untracked interest from the regular payment (usually 0)
|
|
// 2. Late penalty interest (increases loan value)
|
|
// This positive value indicates the loan's value increased due
|
|
// to the late payment.
|
|
periodic.untrackedInterest + roundedLateInterest};
|
|
}();
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
isRounded(asset, late.totalDue, loanScale),
|
|
"ripple::detail::computeLatePayment",
|
|
"total due is rounded");
|
|
|
|
// Check that the borrower provided enough funds to cover the late payment.
|
|
// The late payment is more expensive than a regular payment due to the
|
|
// penalties.
|
|
if (amount < late.totalDue)
|
|
{
|
|
JLOG(j.warn()) << "Late loan payment amount is insufficient. Due: "
|
|
<< late.totalDue << ", paid: " << amount;
|
|
return Unexpected(tecINSUFFICIENT_PAYMENT);
|
|
}
|
|
|
|
return late;
|
|
}
|
|
|
|
/* Computes payment components for paying off a loan early (before final
|
|
* payment).
|
|
*
|
|
* A full payment closes the loan immediately, paying off all outstanding
|
|
* balances plus a prepayment penalty and any accrued interest since the last
|
|
* payment. This is different from the final scheduled payment, which has no
|
|
* prepayment penalty.
|
|
*
|
|
* The function calculates:
|
|
* - Accrued interest since last payment (time-based)
|
|
* - Prepayment penalty (percentage of remaining principal)
|
|
* - Close payment fee (fixed fee for early closure)
|
|
* - All remaining principal and outstanding fees
|
|
*
|
|
* The loan's value may increase or decrease depending on whether the prepayment
|
|
* penalty exceeds the scheduled interest that would have been paid.
|
|
*
|
|
* Implements equation (26) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Expected<ExtendedPaymentComponents, TER>
|
|
computeFullPayment(
|
|
Asset const& asset,
|
|
ApplyView& view,
|
|
Number const& principalOutstanding,
|
|
Number const& managementFeeOutstanding,
|
|
Number const& periodicPayment,
|
|
std::uint32_t paymentRemaining,
|
|
std::uint32_t prevPaymentDate,
|
|
std::uint32_t const startDate,
|
|
std::uint32_t const paymentInterval,
|
|
TenthBips32 const closeInterestRate,
|
|
std::int32_t loanScale,
|
|
Number const& totalInterestOutstanding,
|
|
Number const& periodicRate,
|
|
Number const& closePaymentFee,
|
|
STAmount const& amount,
|
|
TenthBips16 managementFeeRate,
|
|
beast::Journal j)
|
|
{
|
|
// Full payment must be made before the final scheduled payment.
|
|
if (paymentRemaining <= 1)
|
|
{
|
|
// If this is the last payment, it has to be a regular payment
|
|
JLOG(j.warn()) << "Last payment cannot be a full payment.";
|
|
return Unexpected(tecKILLED);
|
|
}
|
|
|
|
// Calculate the theoretical principal based on the payment schedule.
|
|
// This raw (unrounded) value is used to compute interest and penalties
|
|
// accurately.
|
|
Number const rawPrincipalOutstanding = loanPrincipalFromPeriodicPayment(
|
|
periodicPayment, periodicRate, paymentRemaining);
|
|
|
|
// Full payment interest includes both accrued interest (time since last
|
|
// payment) and prepayment penalty (for closing early).
|
|
auto const fullPaymentInterest = computeFullPaymentInterest(
|
|
rawPrincipalOutstanding,
|
|
periodicRate,
|
|
view.parentCloseTime(),
|
|
paymentInterval,
|
|
prevPaymentDate,
|
|
startDate,
|
|
closeInterestRate);
|
|
|
|
// Split the full payment interest into net interest (to vault) and
|
|
// management fee (to broker), applying proper rounding.
|
|
auto const [roundedFullInterest, roundedFullManagementFee] = [&]() {
|
|
auto const interest = roundToAsset(
|
|
asset, fullPaymentInterest, loanScale, Number::downward);
|
|
auto const parts = computeInterestAndFeeParts(
|
|
asset, interest, managementFeeRate, loanScale);
|
|
return std::make_tuple(parts.first, parts.second);
|
|
}();
|
|
|
|
ExtendedPaymentComponents const full{
|
|
PaymentComponents{
|
|
// Pay off all tracked outstanding balances: principal, interest,
|
|
// and fees.
|
|
// This marks the loan as complete (final payment).
|
|
.trackedValueDelta = principalOutstanding +
|
|
totalInterestOutstanding + managementFeeOutstanding,
|
|
.trackedPrincipalDelta = principalOutstanding,
|
|
|
|
// All outstanding management fees are paid. This zeroes out the
|
|
// tracked fee balance.
|
|
.trackedManagementFeeDelta = managementFeeOutstanding,
|
|
.specialCase = PaymentSpecialCase::final,
|
|
},
|
|
|
|
// Untracked management fee includes:
|
|
// 1. Close payment fee (fixed fee for early closure)
|
|
// 2. Management fee on the full payment interest
|
|
// 3. Minus the outstanding tracked fee (already accounted for above)
|
|
// This can be negative because the outstanding fee is subtracted, but
|
|
// it gets combined with trackedManagementFeeDelta in the final
|
|
// accounting.
|
|
closePaymentFee + roundedFullManagementFee - managementFeeOutstanding,
|
|
|
|
// Value change represents the difference between what the loan was
|
|
// expected to earn (totalInterestOutstanding) and what it actually
|
|
// earns (roundedFullInterest with prepayment penalty).
|
|
// - Positive: Prepayment penalty exceeds scheduled interest (loan value
|
|
// increases)
|
|
// - Negative: Prepayment penalty is less than scheduled interest (loan
|
|
// value decreases)
|
|
roundedFullInterest - totalInterestOutstanding,
|
|
};
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
isRounded(asset, full.totalDue, loanScale),
|
|
"ripple::detail::computeFullPayment",
|
|
"total due is rounded");
|
|
|
|
JLOG(j.trace()) << "computeFullPayment result: periodicPayment: "
|
|
<< periodicPayment << ", periodicRate: " << periodicRate
|
|
<< ", paymentRemaining: " << paymentRemaining
|
|
<< ", rawPrincipalOutstanding: " << rawPrincipalOutstanding
|
|
<< ", fullPaymentInterest: " << fullPaymentInterest
|
|
<< ", roundedFullInterest: " << roundedFullInterest
|
|
<< ", roundedFullManagementFee: "
|
|
<< roundedFullManagementFee
|
|
<< ", untrackedInterest: " << full.untrackedInterest;
|
|
|
|
if (amount < full.totalDue)
|
|
// If the payment is less than the full payment amount, it's not
|
|
// sufficient to be a full payment.
|
|
return Unexpected(tecINSUFFICIENT_PAYMENT);
|
|
|
|
return full;
|
|
}
|
|
|
|
Number
|
|
PaymentComponents::trackedInterestPart() const
|
|
{
|
|
return trackedValueDelta -
|
|
(trackedPrincipalDelta + trackedManagementFeeDelta);
|
|
}
|
|
|
|
/* Computes the breakdown of a regular periodic payment into principal,
|
|
* interest, and management fee components.
|
|
*
|
|
* This function determines how a single scheduled payment should be split among
|
|
* the three tracked loan components. The calculation accounts for accumulated
|
|
* rounding errors.
|
|
*
|
|
* The algorithm:
|
|
* 1. Calculate what the loan state SHOULD be after this payment (target)
|
|
* 2. Compare current state to target to get deltas
|
|
* 3. Adjust deltas to handle rounding artifacts and edge cases
|
|
* 4. Ensure deltas don't exceed available balances or payment amount
|
|
*
|
|
* Special handling for the final payment: all remaining balances are paid off
|
|
* regardless of the periodic payment amount.
|
|
*/
|
|
PaymentComponents
|
|
computePaymentComponents(
|
|
Asset const& asset,
|
|
std::int32_t scale,
|
|
Number const& totalValueOutstanding,
|
|
Number const& principalOutstanding,
|
|
Number const& managementFeeOutstanding,
|
|
Number const& periodicPayment,
|
|
Number const& periodicRate,
|
|
std::uint32_t paymentRemaining,
|
|
TenthBips16 managementFeeRate)
|
|
{
|
|
XRPL_ASSERT_PARTS(
|
|
isRounded(asset, totalValueOutstanding, scale) &&
|
|
isRounded(asset, principalOutstanding, scale) &&
|
|
isRounded(asset, managementFeeOutstanding, scale),
|
|
"ripple::detail::computePaymentComponents",
|
|
"Outstanding values are rounded");
|
|
XRPL_ASSERT_PARTS(
|
|
paymentRemaining > 0,
|
|
"ripple::detail::computePaymentComponents",
|
|
"some payments remaining");
|
|
|
|
auto const roundedPeriodicPayment =
|
|
roundPeriodicPayment(asset, periodicPayment, scale);
|
|
|
|
// Final payment: pay off everything remaining, ignoring the normal
|
|
// periodic payment amount. This ensures the loan completes cleanly.
|
|
if (paymentRemaining == 1 ||
|
|
totalValueOutstanding <= roundedPeriodicPayment)
|
|
{
|
|
// If there's only one payment left, we need to pay off each of the loan
|
|
// parts.
|
|
return PaymentComponents{
|
|
.trackedValueDelta = totalValueOutstanding,
|
|
.trackedPrincipalDelta = principalOutstanding,
|
|
.trackedManagementFeeDelta = managementFeeOutstanding,
|
|
.specialCase = PaymentSpecialCase::final};
|
|
}
|
|
|
|
// Calculate what the loan state SHOULD be after this payment (the target).
|
|
// This is computed at full precision using the theoretical amortization.
|
|
LoanState const trueTarget = computeRawLoanState(
|
|
periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate);
|
|
|
|
// Round the target to the loan's scale to match how actual loan values
|
|
// are stored.
|
|
LoanState const roundedTarget = LoanState{
|
|
.valueOutstanding =
|
|
roundToAsset(asset, trueTarget.valueOutstanding, scale),
|
|
.principalOutstanding =
|
|
roundToAsset(asset, trueTarget.principalOutstanding, scale),
|
|
.interestDue = roundToAsset(asset, trueTarget.interestDue, scale),
|
|
.managementFeeDue =
|
|
roundToAsset(asset, trueTarget.managementFeeDue, scale)};
|
|
|
|
// Get the current actual loan state from the ledger values
|
|
LoanState const currentLedgerState = constructLoanState(
|
|
totalValueOutstanding, principalOutstanding, managementFeeOutstanding);
|
|
|
|
// The difference between current and target states gives us the payment
|
|
// components. Any discrepancies from accumulated rounding are captured
|
|
// here.
|
|
|
|
LoanStateDeltas deltas = currentLedgerState - roundedTarget;
|
|
|
|
// Rounding can occasionally produce negative deltas. Zero them out.
|
|
deltas.nonNegative();
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
deltas.principal <= currentLedgerState.principalOutstanding,
|
|
"ripple::detail::computePaymentComponents",
|
|
"principal delta not greater than outstanding");
|
|
|
|
// Cap each component to never exceed what's actually outstanding
|
|
deltas.principal =
|
|
std::min(deltas.principal, currentLedgerState.principalOutstanding);
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
deltas.interest <= currentLedgerState.interestDue,
|
|
"ripple::detail::computePaymentComponents",
|
|
"interest due delta not greater than outstanding");
|
|
|
|
// Cap interest to both the outstanding amount AND what's left of the
|
|
// periodic payment after principal is paid
|
|
deltas.interest = std::min(
|
|
{deltas.interest,
|
|
std::max(numZero, roundedPeriodicPayment - deltas.principal),
|
|
currentLedgerState.interestDue});
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
deltas.managementFee <= currentLedgerState.managementFeeDue,
|
|
"ripple::detail::computePaymentComponents",
|
|
"management fee due delta not greater than outstanding");
|
|
|
|
// Cap management fee to both the outstanding amount AND what's left of the
|
|
// periodic payment after principal and interest are paid
|
|
deltas.managementFee = std::min(
|
|
{deltas.managementFee,
|
|
roundedPeriodicPayment - (deltas.principal + deltas.interest),
|
|
currentLedgerState.managementFeeDue});
|
|
|
|
// The shortage must never be negative, which indicates that the parts are
|
|
// trying to take more than the whole payment. The excess can be positive,
|
|
// which indicates that we're not going to take the whole payment amount,
|
|
// but if so, it must be small.
|
|
auto takeFrom = [](Number& component, Number& excess) {
|
|
if (excess > beast::zero)
|
|
{
|
|
auto part = std::min(component, excess);
|
|
component -= part;
|
|
excess -= part;
|
|
}
|
|
XRPL_ASSERT_PARTS(
|
|
excess >= beast::zero,
|
|
"ripple::detail::computePaymentComponents",
|
|
"excess non-negative");
|
|
};
|
|
// Helper to reduce deltas when they collectively exceed a limit.
|
|
// Order matters: we prefer to reduce interest first (most flexible),
|
|
// then management fee, then principal (least flexible).
|
|
auto addressExcess = [&takeFrom](LoanStateDeltas& deltas, Number& excess) {
|
|
// This order is based on where errors are the least problematic
|
|
takeFrom(deltas.interest, excess);
|
|
takeFrom(deltas.managementFee, excess);
|
|
takeFrom(deltas.principal, excess);
|
|
};
|
|
|
|
// Check if deltas exceed the total outstanding value. This should never
|
|
// happen due to earlier caps, but handle it defensively.
|
|
Number totalOverpayment =
|
|
deltas.total() - currentLedgerState.valueOutstanding;
|
|
|
|
if (totalOverpayment > beast::zero)
|
|
{
|
|
// LCOV_EXCL_START
|
|
UNREACHABLE(
|
|
"ripple::detail::computePaymentComponents : payment exceeded loan "
|
|
"state");
|
|
addressExcess(deltas, totalOverpayment);
|
|
// LCOV_EXCL_STOP
|
|
}
|
|
|
|
// Check if deltas exceed the periodic payment amount. Reduce if needed.
|
|
Number shortage = roundedPeriodicPayment - deltas.total();
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
isRounded(asset, shortage, scale),
|
|
"ripple::detail::computePaymentComponents",
|
|
"shortage is rounded");
|
|
|
|
if (shortage < beast::zero)
|
|
{
|
|
// Deltas exceed payment amount - reduce them proportionally
|
|
Number excess = -shortage;
|
|
addressExcess(deltas, excess);
|
|
shortage = -excess;
|
|
}
|
|
|
|
// At this point, shortage >= 0 means we're paying less than the full
|
|
// periodic payment (due to rounding or component caps).
|
|
// shortage < 0 would mean we're trying to pay more than allowed (bug).
|
|
XRPL_ASSERT_PARTS(
|
|
shortage >= beast::zero,
|
|
"ripple::detail::computePaymentComponents",
|
|
"no shortage or excess");
|
|
|
|
// Final validation that all components are valid
|
|
XRPL_ASSERT_PARTS(
|
|
deltas.total() ==
|
|
deltas.principal + deltas.interest + deltas.managementFee,
|
|
"ripple::detail::computePaymentComponents",
|
|
"total value adds up");
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
deltas.principal >= beast::zero &&
|
|
deltas.principal <= currentLedgerState.principalOutstanding,
|
|
"ripple::detail::computePaymentComponents",
|
|
"valid principal result");
|
|
XRPL_ASSERT_PARTS(
|
|
deltas.interest >= beast::zero &&
|
|
deltas.interest <= currentLedgerState.interestDue,
|
|
"ripple::detail::computePaymentComponents",
|
|
"valid interest result");
|
|
XRPL_ASSERT_PARTS(
|
|
deltas.managementFee >= beast::zero &&
|
|
deltas.managementFee <= currentLedgerState.managementFeeDue,
|
|
"ripple::detail::computePaymentComponents",
|
|
"valid fee result");
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
deltas.principal + deltas.interest + deltas.managementFee > beast::zero,
|
|
"ripple::detail::computePaymentComponents",
|
|
"payment parts add to payment");
|
|
|
|
// Final safety clamp to ensure no value exceeds its outstanding balance
|
|
return PaymentComponents{
|
|
.trackedValueDelta = std::clamp(
|
|
deltas.total(), numZero, currentLedgerState.valueOutstanding),
|
|
.trackedPrincipalDelta = std::clamp(
|
|
deltas.principal, numZero, currentLedgerState.principalOutstanding),
|
|
.trackedManagementFeeDelta = std::clamp(
|
|
deltas.managementFee, numZero, currentLedgerState.managementFeeDue),
|
|
};
|
|
}
|
|
|
|
/* Computes payment components for an overpayment scenario.
|
|
*
|
|
* An overpayment occurs when a borrower pays more than the scheduled periodic
|
|
* payment amount. The overpayment is treated as extra principal reduction,
|
|
* but incurs a fee and potentially a penalty interest charge.
|
|
*
|
|
* The calculation (Section 3.2.4.2.3 from XLS-66 spec):
|
|
* 1. Calculate gross penalty interest on the overpayment amount
|
|
* 2. Split the gross interest into net interest and management fee
|
|
* 3. Calculate the penalty fee
|
|
* 4. Determine the principal portion by subtracting the interest (gross) and
|
|
* management fee from the overpayment amount
|
|
*
|
|
* Unlike regular payments which follow the amortization schedule, overpayments
|
|
* apply to principal, reducing the loan balance and future interest costs.
|
|
*
|
|
* Equations (20), (21) and (22) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
ExtendedPaymentComponents
|
|
computeOverpaymentComponents(
|
|
Asset const& asset,
|
|
int32_t const loanScale,
|
|
Number const& overpayment,
|
|
TenthBips32 const overpaymentInterestRate,
|
|
TenthBips32 const overpaymentFeeRate,
|
|
TenthBips16 const managementFeeRate)
|
|
{
|
|
XRPL_ASSERT(
|
|
overpayment > 0 && isRounded(asset, overpayment, loanScale),
|
|
"ripple::detail::computeOverpaymentComponents : valid overpayment "
|
|
"amount");
|
|
|
|
// First, deduct the fixed overpayment fee from the total amount.
|
|
// This reduces the effective payment that will be applied to the loan.
|
|
// Equation (22) from XLS-66 spec, Section A-2 Equation Glossary
|
|
Number const overpaymentFee = roundToAsset(
|
|
asset, tenthBipsOfValue(overpayment, overpaymentFeeRate), loanScale);
|
|
|
|
// Calculate the penalty interest on the effective payment amount.
|
|
// This interest doesn't follow the normal amortization schedule - it's
|
|
// a one-time charge for paying early.
|
|
// Equation (20) and (21) from XLS-66 spec, Section A-2 Equation Glossary
|
|
auto const [roundedOverpaymentInterest, roundedOverpaymentManagementFee] =
|
|
[&]() {
|
|
auto const interest = roundToAsset(
|
|
asset,
|
|
tenthBipsOfValue(overpayment, overpaymentInterestRate),
|
|
loanScale);
|
|
return detail::computeInterestAndFeeParts(
|
|
asset, interest, managementFeeRate, loanScale);
|
|
}();
|
|
|
|
auto const result = detail::ExtendedPaymentComponents{
|
|
// Build the payment components, after fees and penalty
|
|
// interest are deducted, the remainder goes entirely to principal
|
|
// reduction.
|
|
detail::PaymentComponents{
|
|
.trackedValueDelta = overpayment - overpaymentFee,
|
|
.trackedPrincipalDelta = overpayment - roundedOverpaymentInterest -
|
|
roundedOverpaymentManagementFee - overpaymentFee,
|
|
.trackedManagementFeeDelta = roundedOverpaymentManagementFee,
|
|
.specialCase = detail::PaymentSpecialCase::extra},
|
|
// Untracked management fee is the fixed overpayment fee
|
|
overpaymentFee,
|
|
// Untracked interest is the penalty interest charged for
|
|
// overpaying.
|
|
// This is positive, representing a one-time cost, but it's
|
|
// typically
|
|
// much smaller than the interest savings from reducing
|
|
// principal.
|
|
roundedOverpaymentInterest};
|
|
XRPL_ASSERT_PARTS(
|
|
result.trackedInterestPart() == roundedOverpaymentInterest,
|
|
"ripple::detail::computeOverpaymentComponents",
|
|
"valid interest computation");
|
|
return result;
|
|
}
|
|
|
|
} // namespace detail
|
|
|
|
detail::LoanStateDeltas
|
|
operator-(LoanState const& lhs, LoanState const& rhs)
|
|
{
|
|
detail::LoanStateDeltas result{
|
|
.principal = lhs.principalOutstanding - rhs.principalOutstanding,
|
|
.interest = lhs.interestDue - rhs.interestDue,
|
|
.managementFee = lhs.managementFeeDue - rhs.managementFeeDue,
|
|
};
|
|
|
|
return result;
|
|
}
|
|
|
|
LoanState
|
|
operator-(LoanState const& lhs, detail::LoanStateDeltas const& rhs)
|
|
{
|
|
LoanState result{
|
|
.valueOutstanding = lhs.valueOutstanding - rhs.total(),
|
|
.principalOutstanding = lhs.principalOutstanding - rhs.principal,
|
|
.interestDue = lhs.interestDue - rhs.interest,
|
|
.managementFeeDue = lhs.managementFeeDue - rhs.managementFee,
|
|
};
|
|
|
|
return result;
|
|
}
|
|
|
|
LoanState
|
|
operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs)
|
|
{
|
|
LoanState result{
|
|
.valueOutstanding = lhs.valueOutstanding + rhs.total(),
|
|
.principalOutstanding = lhs.principalOutstanding + rhs.principal,
|
|
.interestDue = lhs.interestDue + rhs.interest,
|
|
.managementFeeDue = lhs.managementFeeDue + rhs.managementFee,
|
|
};
|
|
|
|
return result;
|
|
}
|
|
|
|
TER
|
|
checkLoanGuards(
|
|
Asset const& vaultAsset,
|
|
Number const& principalRequested,
|
|
bool expectInterest,
|
|
std::uint32_t paymentTotal,
|
|
LoanProperties const& properties,
|
|
beast::Journal j)
|
|
{
|
|
auto const totalInterestOutstanding =
|
|
properties.totalValueOutstanding - principalRequested;
|
|
// Guard 1: if there is no computed total interest over the life of the
|
|
// loan for a non-zero interest rate, we cannot properly amortize the
|
|
// loan
|
|
if (expectInterest && totalInterestOutstanding <= 0)
|
|
{
|
|
// Unless this is a zero-interest loan, there must be some interest
|
|
// due on the loan, even if it's (measurable) dust
|
|
JLOG(j.warn()) << "Loan for " << principalRequested
|
|
<< " with interest has no interest due";
|
|
return tecPRECISION_LOSS;
|
|
}
|
|
// Guard 1a: If there is any interest computed over the life of the
|
|
// loan, for a zero interest rate, something went sideways.
|
|
if (!expectInterest && totalInterestOutstanding > 0)
|
|
{
|
|
// LCOV_EXCL_START
|
|
JLOG(j.warn()) << "Loan for " << principalRequested
|
|
<< " with no interest has interest due";
|
|
return tecINTERNAL;
|
|
// LCOV_EXCL_STOP
|
|
}
|
|
|
|
// Guard 2: if the principal portion of the first periodic payment is
|
|
// too small to be accurately represented with the given rounding mode,
|
|
// raise an error
|
|
if (properties.firstPaymentPrincipal <= 0)
|
|
{
|
|
// Check that some true (unrounded) principal is paid each period.
|
|
// Since the first payment pays the least principal, if it's good,
|
|
// they'll all be good. Note that the outstanding principal is
|
|
// rounded, and may not change right away.
|
|
JLOG(j.warn()) << "Loan is unable to pay principal.";
|
|
return tecPRECISION_LOSS;
|
|
}
|
|
|
|
// Guard 3: If the periodic payment is so small that it can't even be
|
|
// rounded to a representable value, then the loan can't be paid. Also,
|
|
// avoids dividing by 0.
|
|
auto const roundedPayment = roundPeriodicPayment(
|
|
vaultAsset, properties.periodicPayment, properties.loanScale);
|
|
if (roundedPayment == beast::zero)
|
|
{
|
|
JLOG(j.warn()) << "Loan Periodic payment ("
|
|
<< properties.periodicPayment << ") rounds to 0. ";
|
|
return tecPRECISION_LOSS;
|
|
}
|
|
|
|
// Guard 4: if the rounded periodic payment is large enough that the
|
|
// loan can't be amortized in the specified number of payments, raise an
|
|
// error
|
|
{
|
|
NumberRoundModeGuard mg(Number::upward);
|
|
|
|
if (std::int64_t const computedPayments{
|
|
properties.totalValueOutstanding / roundedPayment};
|
|
computedPayments != paymentTotal)
|
|
{
|
|
JLOG(j.warn()) << "Loan Periodic payment ("
|
|
<< properties.periodicPayment << ") rounding ("
|
|
<< roundedPayment << ") on a total value of "
|
|
<< properties.totalValueOutstanding
|
|
<< " can not complete the loan in the specified "
|
|
"number of payments ("
|
|
<< computedPayments << " != " << paymentTotal << ")";
|
|
return tecPRECISION_LOSS;
|
|
}
|
|
}
|
|
return tesSUCCESS;
|
|
}
|
|
|
|
/*
|
|
* This function calculates the full payment interest accrued since the last
|
|
* payment, plus any prepayment penalty.
|
|
*
|
|
* Equations (27) and (28) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Number
|
|
computeFullPaymentInterest(
|
|
Number const& rawPrincipalOutstanding,
|
|
Number const& periodicRate,
|
|
NetClock::time_point parentCloseTime,
|
|
std::uint32_t paymentInterval,
|
|
std::uint32_t prevPaymentDate,
|
|
std::uint32_t startDate,
|
|
TenthBips32 closeInterestRate)
|
|
{
|
|
auto const accruedInterest = detail::loanAccruedInterest(
|
|
rawPrincipalOutstanding,
|
|
periodicRate,
|
|
parentCloseTime,
|
|
startDate,
|
|
prevPaymentDate,
|
|
paymentInterval);
|
|
XRPL_ASSERT(
|
|
accruedInterest >= 0,
|
|
"ripple::detail::computeFullPaymentInterest : valid accrued "
|
|
"interest");
|
|
|
|
// Equation (28) from XLS-66 spec, Section A-2 Equation Glossary
|
|
auto const prepaymentPenalty = closeInterestRate == beast::zero
|
|
? Number{}
|
|
: tenthBipsOfValue(rawPrincipalOutstanding, closeInterestRate);
|
|
|
|
XRPL_ASSERT(
|
|
prepaymentPenalty >= 0,
|
|
"ripple::detail::computeFullPaymentInterest : valid prepayment "
|
|
"interest");
|
|
|
|
// Part of equation (27) from XLS-66 spec, Section A-2 Equation Glossary
|
|
return accruedInterest + prepaymentPenalty;
|
|
}
|
|
|
|
/* Calculates the theoretical loan state at maximum precision for a given point
|
|
* in the amortization schedule.
|
|
*
|
|
* This function computes what the loan's outstanding balances should be based
|
|
* on the periodic payment amount and number of payments remaining,
|
|
* without considering any rounding that may have been applied to the actual
|
|
* Loan object's state. This "raw" (unrounded) state is used as a target for
|
|
* computing payment components and validating that the loan's tracked state
|
|
* hasn't drifted too far from the theoretical values.
|
|
*
|
|
* The raw state serves several purposes:
|
|
* 1. Computing the expected payment breakdown (principal, interest, fees)
|
|
* 2. Detecting and correcting rounding errors that accumulate over time
|
|
* 3. Validating that overpayments are calculated correctly
|
|
* 4. Ensuring the loan will be fully paid off at the end of its term
|
|
*
|
|
* If paymentRemaining is 0, returns a fully zeroed-out LoanState,
|
|
* representing a completely paid-off loan.
|
|
*/
|
|
LoanState
|
|
computeRawLoanState(
|
|
Number const& periodicPayment,
|
|
Number const& periodicRate,
|
|
std::uint32_t const paymentRemaining,
|
|
TenthBips32 const managementFeeRate)
|
|
{
|
|
if (paymentRemaining == 0)
|
|
{
|
|
return LoanState{
|
|
.valueOutstanding = 0,
|
|
.principalOutstanding = 0,
|
|
.interestDue = 0,
|
|
.managementFeeDue = 0};
|
|
}
|
|
|
|
// Equation (30) from XLS-66 spec, Section A-2 Equation Glossary
|
|
Number const rawTotalValueOutstanding = periodicPayment * paymentRemaining;
|
|
|
|
Number const rawPrincipalOutstanding =
|
|
detail::loanPrincipalFromPeriodicPayment(
|
|
periodicPayment, periodicRate, paymentRemaining);
|
|
|
|
// Equation (31) from XLS-66 spec, Section A-2 Equation Glossary
|
|
Number const rawInterestOutstandingGross =
|
|
rawTotalValueOutstanding - rawPrincipalOutstanding;
|
|
|
|
// Equation (32) from XLS-66 spec, Section A-2 Equation Glossary
|
|
Number const rawManagementFeeOutstanding =
|
|
tenthBipsOfValue(rawInterestOutstandingGross, managementFeeRate);
|
|
|
|
// Equation (33) from XLS-66 spec, Section A-2 Equation Glossary
|
|
Number const rawInterestOutstandingNet =
|
|
rawInterestOutstandingGross - rawManagementFeeOutstanding;
|
|
|
|
return LoanState{
|
|
.valueOutstanding = rawTotalValueOutstanding,
|
|
.principalOutstanding = rawPrincipalOutstanding,
|
|
.interestDue = rawInterestOutstandingNet,
|
|
.managementFeeDue = rawManagementFeeOutstanding};
|
|
};
|
|
|
|
/* Constructs a LoanState from rounded Loan ledger object values.
|
|
*
|
|
* This function creates a LoanState structure from the three tracked values
|
|
* stored in a Loan ledger object. Unlike calculateRawLoanState(), which
|
|
* computes theoretical unrounded values, this function works with values
|
|
* that have already been rounded to the loan's scale.
|
|
*
|
|
* The key difference from calculateRawLoanState():
|
|
* - calculateRawLoanState: Computes theoretical values at full precision
|
|
* - constructRoundedLoanState: Builds state from actual rounded ledger values
|
|
*
|
|
* The interestDue field is derived from the other three values rather than
|
|
* stored directly, since it can be calculated as:
|
|
* interestDue = totalValueOutstanding - principalOutstanding -
|
|
* managementFeeOutstanding
|
|
*
|
|
* This ensures consistency across the codebase and prevents copy-paste errors
|
|
* when creating LoanState objects from Loan ledger data.
|
|
*/
|
|
LoanState
|
|
constructLoanState(
|
|
Number const& totalValueOutstanding,
|
|
Number const& principalOutstanding,
|
|
Number const& managementFeeOutstanding)
|
|
{
|
|
// This implementation is pretty trivial, but ensures the calculations
|
|
// are consistent everywhere, and reduces copy/paste errors.
|
|
return LoanState{
|
|
.valueOutstanding = totalValueOutstanding,
|
|
.principalOutstanding = principalOutstanding,
|
|
.interestDue = totalValueOutstanding - principalOutstanding -
|
|
managementFeeOutstanding,
|
|
.managementFeeDue = managementFeeOutstanding};
|
|
}
|
|
|
|
LoanState
|
|
constructRoundedLoanState(SLE::const_ref loan)
|
|
{
|
|
return constructLoanState(
|
|
loan->at(sfTotalValueOutstanding),
|
|
loan->at(sfPrincipalOutstanding),
|
|
loan->at(sfManagementFeeOutstanding));
|
|
}
|
|
|
|
/*
|
|
* This function calculates the fee owed to the broker based on the asset,
|
|
* value, and management fee rate.
|
|
*
|
|
* Equation (32) from XLS-66 spec, Section A-2 Equation Glossary
|
|
*/
|
|
Number
|
|
computeManagementFee(
|
|
Asset const& asset,
|
|
Number const& value,
|
|
TenthBips32 managementFeeRate,
|
|
std::int32_t scale)
|
|
{
|
|
return roundToAsset(
|
|
asset,
|
|
tenthBipsOfValue(value, managementFeeRate),
|
|
scale,
|
|
Number::downward);
|
|
}
|
|
|
|
/*
|
|
* Given the loan parameters, compute the derived properties of the loan.
|
|
*/
|
|
LoanProperties
|
|
computeLoanProperties(
|
|
Asset const& asset,
|
|
Number principalOutstanding,
|
|
TenthBips32 interestRate,
|
|
std::uint32_t paymentInterval,
|
|
std::uint32_t paymentsRemaining,
|
|
TenthBips32 managementFeeRate,
|
|
std::int32_t minimumScale,
|
|
beast::Journal j)
|
|
{
|
|
auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval);
|
|
XRPL_ASSERT(
|
|
interestRate == 0 || periodicRate > 0,
|
|
"ripple::computeLoanProperties : valid rate");
|
|
|
|
auto const periodicPayment = detail::loanPeriodicPayment(
|
|
principalOutstanding, periodicRate, paymentsRemaining);
|
|
|
|
auto const [totalValueOutstanding, loanScale] = [&]() {
|
|
// only round up if there should be interest
|
|
NumberRoundModeGuard mg(
|
|
periodicRate == 0 ? Number::to_nearest : Number::upward);
|
|
// Use STAmount's internal rounding instead of roundToAsset, because
|
|
// we're going to use this result to determine the scale for all the
|
|
// other rounding.
|
|
|
|
// Equation (30) from XLS-66 spec, Section A-2 Equation Glossary
|
|
STAmount amount{asset, periodicPayment * paymentsRemaining};
|
|
JLOG(j.debug()) << "computeLoanProperties:" << " Principal requested: "
|
|
<< principalOutstanding
|
|
<< ". Periodic payment: " << periodicPayment
|
|
<< ". Payments remaining: " << paymentsRemaining
|
|
<< ". Raw total value: "
|
|
<< periodicPayment * paymentsRemaining
|
|
<< ". Candidate total value: " << amount << std::endl;
|
|
|
|
// Base the loan scale on the total value, since that's going to be
|
|
// the biggest number involved (barring unusual parameters for late,
|
|
// full, or over payments)
|
|
auto const loanScale = std::max(minimumScale, amount.exponent());
|
|
XRPL_ASSERT_PARTS(
|
|
(amount.integral() && loanScale == 0) ||
|
|
(!amount.integral() &&
|
|
loanScale >= static_cast<Number>(amount).exponent()),
|
|
"ripple::computeLoanProperties",
|
|
"loanScale value fits expectations");
|
|
|
|
// We may need to truncate the total value because of the minimum
|
|
// scale
|
|
amount = roundToAsset(asset, amount, loanScale);
|
|
|
|
JLOG(j.debug()) << "computeLoanProperties: Loan scale:" << loanScale
|
|
<< ". Actual total value: " << amount << std::endl;
|
|
|
|
return std::make_pair(amount, loanScale);
|
|
}();
|
|
|
|
// Since we just figured out the loan scale, we haven't been able to
|
|
// validate that the principal fits in it, so to allow this function to
|
|
// succeed, round it here, and let the caller do the validation.
|
|
principalOutstanding = roundToAsset(
|
|
asset, principalOutstanding, loanScale, Number::to_nearest);
|
|
|
|
// E<quation (31) from XLS-66 spec, Section A-2 Equation Glossary
|
|
auto const totalInterestOutstanding =
|
|
totalValueOutstanding - principalOutstanding;
|
|
auto const feeOwedToBroker = computeManagementFee(
|
|
asset, totalInterestOutstanding, managementFeeRate, loanScale);
|
|
|
|
// Compute the principal part of the first payment. This is needed
|
|
// because the principal part may be rounded down to zero, which
|
|
// would prevent the principal from ever being paid down.
|
|
auto const firstPaymentPrincipal = [&]() {
|
|
// Compute the parts for the first payment. Ensure that the
|
|
// principal payment will actually change the principal.
|
|
auto const startingState = computeRawLoanState(
|
|
periodicPayment,
|
|
periodicRate,
|
|
paymentsRemaining,
|
|
managementFeeRate);
|
|
|
|
auto const firstPaymentState = computeRawLoanState(
|
|
periodicPayment,
|
|
periodicRate,
|
|
paymentsRemaining - 1,
|
|
managementFeeRate);
|
|
|
|
// The unrounded principal part needs to be large enough to affect
|
|
// the principal. What to do if not is left to the caller
|
|
return startingState.principalOutstanding -
|
|
firstPaymentState.principalOutstanding;
|
|
}();
|
|
|
|
return LoanProperties{
|
|
.periodicPayment = periodicPayment,
|
|
.totalValueOutstanding = totalValueOutstanding,
|
|
.managementFeeOwedToBroker = feeOwedToBroker,
|
|
.loanScale = loanScale,
|
|
.firstPaymentPrincipal = firstPaymentPrincipal};
|
|
}
|
|
|
|
/*
|
|
* This is the main function to make a loan payment.
|
|
* This function handles regular, late, full, and overpayments.
|
|
* It is an implementation of the make_payment function from the XLS-66
|
|
* spec. Section 3.2.4.4
|
|
*/
|
|
Expected<LoanPaymentParts, TER>
|
|
loanMakePayment(
|
|
Asset const& asset,
|
|
ApplyView& view,
|
|
SLE::ref loan,
|
|
SLE::const_ref brokerSle,
|
|
STAmount const& amount,
|
|
LoanPaymentType const paymentType,
|
|
beast::Journal j)
|
|
{
|
|
using namespace Lending;
|
|
|
|
auto principalOutstandingProxy = loan->at(sfPrincipalOutstanding);
|
|
auto paymentRemainingProxy = loan->at(sfPaymentRemaining);
|
|
|
|
if (paymentRemainingProxy == 0 || principalOutstandingProxy == 0)
|
|
{
|
|
// Loan complete this is already checked in LoanPay::preclaim()
|
|
// LCOV_EXCL_START
|
|
JLOG(j.warn()) << "Loan is already paid off.";
|
|
return Unexpected(tecKILLED);
|
|
// LCOV_EXCL_STOP
|
|
}
|
|
|
|
auto totalValueOutstandingProxy = loan->at(sfTotalValueOutstanding);
|
|
auto managementFeeOutstandingProxy = loan->at(sfManagementFeeOutstanding);
|
|
|
|
// Next payment due date must be set unless the loan is complete
|
|
auto nextDueDateProxy = loan->at(sfNextPaymentDueDate);
|
|
if (*nextDueDateProxy == 0)
|
|
{
|
|
JLOG(j.warn()) << "Loan next payment due date is not set.";
|
|
return Unexpected(tecINTERNAL);
|
|
}
|
|
|
|
std::int32_t const loanScale = loan->at(sfLoanScale);
|
|
|
|
TenthBips32 const interestRate{loan->at(sfInterestRate)};
|
|
|
|
Number const serviceFee = loan->at(sfLoanServiceFee);
|
|
TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
|
|
|
|
Number const periodicPayment = loan->at(sfPeriodicPayment);
|
|
|
|
auto prevPaymentDateProxy = loan->at(sfPreviousPaymentDate);
|
|
std::uint32_t const startDate = loan->at(sfStartDate);
|
|
|
|
std::uint32_t const paymentInterval = loan->at(sfPaymentInterval);
|
|
|
|
// Compute the periodic rate that will be used for calculations
|
|
// throughout
|
|
Number const periodicRate = loanPeriodicRate(interestRate, paymentInterval);
|
|
XRPL_ASSERT(
|
|
interestRate == 0 || periodicRate > 0,
|
|
"ripple::loanMakePayment : valid rate");
|
|
|
|
XRPL_ASSERT(
|
|
*totalValueOutstandingProxy > 0,
|
|
"ripple::loanMakePayment : valid total value");
|
|
|
|
view.update(loan);
|
|
|
|
// -------------------------------------------------------------
|
|
// A late payment not flagged as late overrides all other options.
|
|
if (paymentType != LoanPaymentType::late &&
|
|
hasExpired(view, nextDueDateProxy))
|
|
{
|
|
// If the payment is late, and the late flag was not set, it's not
|
|
// valid
|
|
JLOG(j.warn()) << "Loan payment is overdue. Use the tfLoanLatePayment "
|
|
"transaction "
|
|
"flag to make a late payment. Loan was created on "
|
|
<< startDate << ", prev payment due date is "
|
|
<< prevPaymentDateProxy << ", next payment due date is "
|
|
<< nextDueDateProxy << ", ledger time is "
|
|
<< view.parentCloseTime().time_since_epoch().count();
|
|
return Unexpected(tecEXPIRED);
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// full payment handling
|
|
if (paymentType == LoanPaymentType::full)
|
|
{
|
|
TenthBips32 const closeInterestRate{loan->at(sfCloseInterestRate)};
|
|
Number const closePaymentFee =
|
|
roundToAsset(asset, loan->at(sfClosePaymentFee), loanScale);
|
|
|
|
LoanState const roundedLoanState = constructLoanState(
|
|
totalValueOutstandingProxy,
|
|
principalOutstandingProxy,
|
|
managementFeeOutstandingProxy);
|
|
|
|
if (auto const fullPaymentComponents = detail::computeFullPayment(
|
|
asset,
|
|
view,
|
|
principalOutstandingProxy,
|
|
managementFeeOutstandingProxy,
|
|
periodicPayment,
|
|
paymentRemainingProxy,
|
|
prevPaymentDateProxy,
|
|
startDate,
|
|
paymentInterval,
|
|
closeInterestRate,
|
|
loanScale,
|
|
roundedLoanState.interestDue,
|
|
periodicRate,
|
|
closePaymentFee,
|
|
amount,
|
|
managementFeeRate,
|
|
j))
|
|
{
|
|
return doPayment(
|
|
*fullPaymentComponents,
|
|
totalValueOutstandingProxy,
|
|
principalOutstandingProxy,
|
|
managementFeeOutstandingProxy,
|
|
paymentRemainingProxy,
|
|
prevPaymentDateProxy,
|
|
nextDueDateProxy,
|
|
paymentInterval);
|
|
}
|
|
else if (fullPaymentComponents.error())
|
|
// error() will be the TER returned if a payment is not made. It
|
|
// will only evaluate to true if it's unsuccessful. Otherwise,
|
|
// tesSUCCESS means nothing was done, so continue.
|
|
return Unexpected(fullPaymentComponents.error());
|
|
|
|
// LCOV_EXCL_START
|
|
UNREACHABLE("ripple::loanMakePayment : invalid full payment result");
|
|
JLOG(j.error()) << "Full payment computation failed unexpectedly.";
|
|
return Unexpected(tecINTERNAL);
|
|
// LCOV_EXCL_STOP
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// compute the periodic payment info that will be needed whether the
|
|
// payment is late or regular
|
|
detail::ExtendedPaymentComponents periodic{
|
|
detail::computePaymentComponents(
|
|
asset,
|
|
loanScale,
|
|
totalValueOutstandingProxy,
|
|
principalOutstandingProxy,
|
|
managementFeeOutstandingProxy,
|
|
periodicPayment,
|
|
periodicRate,
|
|
paymentRemainingProxy,
|
|
managementFeeRate),
|
|
serviceFee};
|
|
XRPL_ASSERT_PARTS(
|
|
periodic.trackedPrincipalDelta >= 0,
|
|
"ripple::loanMakePayment",
|
|
"regular payment valid principal");
|
|
|
|
// -------------------------------------------------------------
|
|
// late payment handling
|
|
if (paymentType == LoanPaymentType::late)
|
|
{
|
|
TenthBips32 const lateInterestRate{loan->at(sfLateInterestRate)};
|
|
Number const latePaymentFee = loan->at(sfLatePaymentFee);
|
|
|
|
if (auto const latePaymentComponents = detail::computeLatePayment(
|
|
asset,
|
|
view,
|
|
principalOutstandingProxy,
|
|
nextDueDateProxy,
|
|
periodic,
|
|
lateInterestRate,
|
|
loanScale,
|
|
latePaymentFee,
|
|
amount,
|
|
managementFeeRate,
|
|
j))
|
|
{
|
|
return doPayment(
|
|
*latePaymentComponents,
|
|
totalValueOutstandingProxy,
|
|
principalOutstandingProxy,
|
|
managementFeeOutstandingProxy,
|
|
paymentRemainingProxy,
|
|
prevPaymentDateProxy,
|
|
nextDueDateProxy,
|
|
paymentInterval);
|
|
}
|
|
else if (latePaymentComponents.error())
|
|
{
|
|
// error() will be the TER returned if a payment is not made. It
|
|
// will only evaluate to true if it's unsuccessful.
|
|
return Unexpected(latePaymentComponents.error());
|
|
}
|
|
|
|
// LCOV_EXCL_START
|
|
UNREACHABLE("ripple::loanMakePayment : invalid late payment result");
|
|
JLOG(j.error()) << "Late payment computation failed unexpectedly.";
|
|
return Unexpected(tecINTERNAL);
|
|
// LCOV_EXCL_STOP
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// regular periodic payment handling
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
paymentType == LoanPaymentType::regular ||
|
|
paymentType == LoanPaymentType::overpayment,
|
|
"ripple::loanMakePayment",
|
|
"regular payment type");
|
|
|
|
// Keep a running total of the actual parts paid
|
|
LoanPaymentParts totalParts;
|
|
Number totalPaid;
|
|
std::size_t numPayments = 0;
|
|
|
|
while ((amount >= (totalPaid + periodic.totalDue)) &&
|
|
paymentRemainingProxy > 0 &&
|
|
numPayments < loanMaximumPaymentsPerTransaction)
|
|
{
|
|
// Try to make more payments
|
|
XRPL_ASSERT_PARTS(
|
|
periodic.trackedPrincipalDelta >= 0,
|
|
"ripple::loanMakePayment",
|
|
"payment pays non-negative principal");
|
|
|
|
totalPaid += periodic.totalDue;
|
|
totalParts += detail::doPayment(
|
|
periodic,
|
|
totalValueOutstandingProxy,
|
|
principalOutstandingProxy,
|
|
managementFeeOutstandingProxy,
|
|
paymentRemainingProxy,
|
|
prevPaymentDateProxy,
|
|
nextDueDateProxy,
|
|
paymentInterval);
|
|
++numPayments;
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
(periodic.specialCase == detail::PaymentSpecialCase::final) ==
|
|
(paymentRemainingProxy == 0),
|
|
"ripple::loanMakePayment",
|
|
"final payment is the final payment");
|
|
|
|
// Don't compute the next payment if this was the last payment
|
|
if (periodic.specialCase == detail::PaymentSpecialCase::final)
|
|
break;
|
|
|
|
periodic = detail::ExtendedPaymentComponents{
|
|
detail::computePaymentComponents(
|
|
asset,
|
|
loanScale,
|
|
totalValueOutstandingProxy,
|
|
principalOutstandingProxy,
|
|
managementFeeOutstandingProxy,
|
|
periodicPayment,
|
|
periodicRate,
|
|
paymentRemainingProxy,
|
|
managementFeeRate),
|
|
serviceFee};
|
|
}
|
|
|
|
if (numPayments == 0)
|
|
{
|
|
JLOG(j.warn()) << "Regular loan payment amount is insufficient. Due: "
|
|
<< periodic.totalDue << ", paid: " << amount;
|
|
return Unexpected(tecINSUFFICIENT_PAYMENT);
|
|
}
|
|
|
|
XRPL_ASSERT_PARTS(
|
|
totalParts.principalPaid + totalParts.interestPaid +
|
|
totalParts.feePaid ==
|
|
totalPaid,
|
|
"ripple::loanMakePayment",
|
|
"payment parts add up");
|
|
XRPL_ASSERT_PARTS(
|
|
totalParts.valueChange == 0,
|
|
"ripple::loanMakePayment",
|
|
"no value change");
|
|
|
|
// -------------------------------------------------------------
|
|
// overpayment handling
|
|
if (paymentType == LoanPaymentType::overpayment &&
|
|
loan->isFlag(lsfLoanOverpayment) && paymentRemainingProxy > 0 &&
|
|
totalPaid < amount && numPayments < loanMaximumPaymentsPerTransaction)
|
|
{
|
|
TenthBips32 const overpaymentInterestRate{
|
|
loan->at(sfOverpaymentInterestRate)};
|
|
TenthBips32 const overpaymentFeeRate{loan->at(sfOverpaymentFee)};
|
|
|
|
// It shouldn't be possible for the overpayment to be greater than
|
|
// totalValueOutstanding, because that would have been processed as
|
|
// another normal payment. But cap it just in case.
|
|
Number const overpayment =
|
|
std::min(amount - totalPaid, *totalValueOutstandingProxy);
|
|
|
|
detail::ExtendedPaymentComponents const overpaymentComponents =
|
|
detail::computeOverpaymentComponents(
|
|
asset,
|
|
loanScale,
|
|
overpayment,
|
|
overpaymentInterestRate,
|
|
overpaymentFeeRate,
|
|
managementFeeRate);
|
|
|
|
// Don't process an overpayment if the whole amount (or more!)
|
|
// gets eaten by fees and interest.
|
|
if (overpaymentComponents.trackedPrincipalDelta > 0)
|
|
{
|
|
XRPL_ASSERT_PARTS(
|
|
overpaymentComponents.untrackedInterest >= beast::zero,
|
|
"ripple::loanMakePayment",
|
|
"overpayment penalty did not reduce value of loan");
|
|
// Can't just use `periodicPayment` here, because it might
|
|
// change
|
|
auto periodicPaymentProxy = loan->at(sfPeriodicPayment);
|
|
if (auto const overResult = detail::doOverpayment(
|
|
asset,
|
|
loanScale,
|
|
overpaymentComponents,
|
|
totalValueOutstandingProxy,
|
|
principalOutstandingProxy,
|
|
managementFeeOutstandingProxy,
|
|
periodicPaymentProxy,
|
|
interestRate,
|
|
paymentInterval,
|
|
periodicRate,
|
|
paymentRemainingProxy,
|
|
prevPaymentDateProxy,
|
|
nextDueDateProxy,
|
|
managementFeeRate,
|
|
j))
|
|
totalParts += *overResult;
|
|
else if (overResult.error())
|
|
// error() will be the TER returned if a payment is not
|
|
// made. It will only evaluate to true if it's unsuccessful.
|
|
// Otherwise, tesSUCCESS means nothing was done, so
|
|
// continue.
|
|
return Unexpected(overResult.error());
|
|
}
|
|
}
|
|
|
|
// Check the final results are rounded, to double-check that the
|
|
// intermediate steps were rounded.
|
|
XRPL_ASSERT(
|
|
isRounded(asset, totalParts.principalPaid, loanScale) &&
|
|
totalParts.principalPaid >= beast::zero,
|
|
"ripple::loanMakePayment : total principal paid is valid");
|
|
XRPL_ASSERT(
|
|
isRounded(asset, totalParts.interestPaid, loanScale) &&
|
|
totalParts.interestPaid >= beast::zero,
|
|
"ripple::loanMakePayment : total interest paid is valid");
|
|
XRPL_ASSERT(
|
|
isRounded(asset, totalParts.valueChange, loanScale),
|
|
"ripple::loanMakePayment : loan value change is valid");
|
|
XRPL_ASSERT(
|
|
isRounded(asset, totalParts.feePaid, loanScale) &&
|
|
totalParts.feePaid >= beast::zero,
|
|
"ripple::loanMakePayment : fee paid is valid");
|
|
return totalParts;
|
|
}
|
|
} // namespace ripple
|