documents paymentCOmponents

This commit is contained in:
Vito
2025-11-15 16:12:43 +01:00
parent dd1ed63b90
commit e76ce57f04

View File

@@ -895,11 +895,22 @@ PaymentComponents::trackedInterestPart() const
(trackedPrincipalDelta + trackedManagementFeeDelta);
}
PaymentComponents
computePaymentComponents(
Asset const& asset,
std::int32_t scale,
Number const& totalValueOutstanding,
/* 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,
@@ -912,10 +923,6 @@ computePaymentComponents(
std::uint32_t paymentRemaining,
TenthBips16 managementFeeRate)
{
/*
* This function is derived from the XLS-66 spec, section 3.2.4.1.1 (Regular
* Payment)
*/
XRPL_ASSERT_PARTS(
isRounded(asset, totalValueOutstanding, scale) &&
isRounded(asset, principalOutstanding, scale) &&
@@ -926,11 +933,17 @@ computePaymentComponents(
paymentRemaining > 0,
"ripple::detail::computePaymentComponents",
"some payments remaining");
auto const roundedPeriodicPayment =
roundPeriodicPayment(asset, periodicPayment, scale);
// 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 = calculateRawLoanState(
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),
@@ -939,18 +952,25 @@ computePaymentComponents(
.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 = constructRoundedLoanState(
totalValueOutstanding, principalOutstanding, managementFeeOutstanding);
// The difference between current and target states gives us the payment
// components. Any discrepancies from accumulated rounding are captured
// here.
LoanDeltas deltas = currentLedgerState - roundedTarget;
// Rounding can occasionally produce negative deltas. Zero them out.
deltas.nonNegative();
// Adjust the deltas if necessary for data integrity
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);
@@ -959,6 +979,8 @@ computePaymentComponents(
"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),
@@ -969,17 +991,18 @@ computePaymentComponents(
"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});
// 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.
XRPL_ASSERT_PARTS(
deltas.total() <= totalValueOutstanding,
"ripple::detail::computePaymentComponents",
@@ -993,7 +1016,6 @@ computePaymentComponents(
"ripple::detail::computePaymentComponents",
"last payment management fee agrees");
// Pay everything off
return PaymentComponents{
.trackedValueDelta = totalValueOutstanding,
.trackedPrincipalDelta = principalOutstanding,
@@ -1001,34 +1023,34 @@ computePaymentComponents(
.specialCase = PaymentSpecialCase::final};
}
// 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) {
// Helper to reduce a component by taking from excess
auto const takeFrom = [](Number& component, Number& excess) {
if (excess > beast::zero)
{
// Take as much of the excess as we can out of the provided part and
// the total
auto part = std::min(component, excess);
component -= part;
excess -= part;
}
// If the excess goes negative, we took too much, which should be
// impossible
XRPL_ASSERT_PARTS(
excess >= beast::zero,
"ripple::detail::computePaymentComponents",
"excess non-negative");
};
auto addressExcess = [&takeFrom](LoanDeltas& deltas, Number& excess) {
// This order is based on where errors are the least problematic
// 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 const addressExcess = [&takeFrom](LoanDeltas& deltas, Number& excess) {
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
@@ -1039,7 +1061,7 @@ computePaymentComponents(
// LCOV_EXCL_STOP
}
// Make sure the parts don't add up to too much
// Check if deltas exceed the periodic payment amount. Reduce if needed.
Number shortage = roundedPeriodicPayment - deltas.total();
XRPL_ASSERT_PARTS(
@@ -1049,21 +1071,21 @@ computePaymentComponents(
if (shortage < beast::zero)
{
// Deltas exceed payment amount - reduce them proportionally
Number excess = -shortage;
addressExcess(deltas, excess);
shortage = -excess;
}
// The shortage should never be negative, which indicates that the
// parts are trying to take more than the whole payment. The
// shortage may be positive, which indicates that we're not going to
// take the whole payment amount.
// 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,
@@ -1091,9 +1113,8 @@ computePaymentComponents(
"ripple::detail::computePaymentComponents",
"payment parts add to payment");
// Final safety clamp to ensure no value exceeds its outstanding balance
return PaymentComponents{
// As a final safety check, ensure the value is non-negative, and won't
// make the corresponding item negative
.trackedValueDelta = std::clamp(
deltas.total(), numZero, currentLedgerState.valueOutstanding),
.trackedPrincipalDelta = std::clamp(