From ba19d7f32fc791b3525ff93a093a4a4ed8199135 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Mon, 10 Nov 2025 23:17:42 -0500 Subject: [PATCH] Add tfLoanLatePayment flag; full payment is no longer a special case - A regular payment that is late, or a tfLoanLatePayment that is not late will fail. - Flags are mutually exclusive. - Add a few interest computation shortcuts and overflow prevention checks that return 0 if there's no time to compute for. --- include/xrpl/protocol/TxFlags.h | 3 +- src/test/app/Loan_test.cpp | 34 +- src/xrpld/app/misc/LendingHelpers.h | 27 +- src/xrpld/app/misc/detail/LendingHelpers.cpp | 386 +++++++++---------- src/xrpld/app/tx/detail/LoanPay.cpp | 54 +-- 5 files changed, 260 insertions(+), 244 deletions(-) diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index d3a2fecf45..0f3ea94b21 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -274,10 +274,11 @@ constexpr std::uint32_t const tfLoanOverpayment = 0x00010000; // LoanPay exclusive flags: // tfLoanFullPayment: True, indicates that the payment is constexpr std::uint32_t const tfLoanFullPayment = 0x00020000; +constexpr std::uint32_t const tfLoanLatePayment = 0x00040000; constexpr std::uint32_t const tfLoanSetMask = ~(tfUniversal | tfLoanOverpayment); constexpr std::uint32_t const tfLoanPayMask = ~(tfUniversal | - tfLoanOverpayment | tfLoanFullPayment); + tfLoanOverpayment | tfLoanFullPayment | tfLoanLatePayment); // LoanManage flags: constexpr std::uint32_t const tfLoanDefault = 0x00010000; diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index f425778a7e..536ac31eda 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -1059,8 +1059,7 @@ protected: // Make the payment env(pay(borrower, loanKeylet.key, transactionAmount)); - env.close( - d{(state.previousPaymentDate + state.nextPaymentDate) / 2}); + env.close(d{state.paymentInterval / 2}); // Need to account for fees if the loan is in XRP PrettyAmount adjustment = broker.asset(0); @@ -2227,14 +2226,29 @@ protected: (Number{15, -1} / loanPaymentsPerFeeIncrement + 1)}), ter(temINVALID_FLAG)); } - // Try to send a payment marked as both full payment and - // overpayment. Do not include `txFlags`, so we don't duplicate the - // prior test transaction. + // Try to send a payment marked as multiple mutually exclusive + // payment types. Do not include `txFlags`, so we don't duplicate + // the prior test transaction. + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment), + ter(temINVALID_FLAG)); env(pay(borrower, loanKeylet.key, broker.asset(state.periodicPayment * 2), tfLoanOverpayment | tfLoanFullPayment), ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); { auto const otherAsset = broker.asset.raw() == assets[0].raw() @@ -4562,7 +4576,11 @@ protected: issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); env.close(); - env(pay(borrower, loanKeylet.key, debtMaximumRequest)); + // The payment is late by this point + env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecEXPIRED)); + env.close(); + env(pay( + borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); env.close(); // preclaim: tecKILLED @@ -6777,11 +6795,10 @@ public: testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic(); testLoanCoverMinimumRoundingExploit(); #endif - testDustManipulation(); - testIssuerLoan(); testDisabled(); testSelfLoan(); + testIssuerLoan(); testLoanSet(); testLifecycle(); testServiceFeeOnBrokerDeepFreeze(); @@ -6806,6 +6823,7 @@ public: testLoanNextPaymentDueDateOverflow(); testRequireAuth(); + testDustManipulation(); testRIPD3831(); testRIPD3459(); diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index d9e27414fa..c32b578d0b 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -30,21 +30,24 @@ roundPeriodicPayment( struct LoanPaymentParts { /// principal_paid is the amount of principal that the payment covered. - Number principalPaid; + Number principalPaid = numZero; /// interest_paid is the amount of interest that the payment covered. - Number interestPaid; + Number interestPaid = numZero; /** * value_change is the amount by which the total value of the Loan changed. * If value_change < 0, Loan value decreased. * If value_change > 0, Loan value increased. * This is 0 for regular payments. */ - Number valueChange; + Number valueChange = numZero; /// feePaid is amount of fee that is paid to the broker - Number feePaid; + Number feePaid = numZero; LoanPaymentParts& operator+=(LoanPaymentParts const& other); + + bool + operator==(LoanPaymentParts const& other) const; }; /** This structure describes the initial "computed" properties of a loan. @@ -240,15 +243,11 @@ computeLoanProperties( bool isRounded(Asset const& asset, Number const& value, std::int32_t scale); -Expected -loanMakeFullPayment( - Asset const& asset, - ApplyView& view, - SLE::ref loan, - SLE::const_ref brokerSle, - STAmount const& amount, - bool const overpaymentAllowed, - beast::Journal j); +// Indicates what type of payment is being made. +// regular, late, and full are mutually exclusive. +// overpayment is an "add on" to a regular payment, and follows that path with +// potential extra work at the end. +enum class LoanPaymentType { regular = 0, late, full, overpayment }; Expected loanMakePayment( @@ -257,7 +256,7 @@ loanMakePayment( SLE::ref loan, SLE::const_ref brokerSle, STAmount const& amount, - bool const overpaymentAllowed, + LoanPaymentType const paymentType, beast::Journal j); } // namespace ripple diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp index 8c349aafa2..b980e73353 100644 --- a/src/xrpld/app/misc/detail/LendingHelpers.cpp +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -35,6 +35,14 @@ LoanPaymentParts::operator+=(LoanPaymentParts const& other) return *this; } +bool +LoanPaymentParts::operator==(LoanPaymentParts const& other) const +{ + return principalPaid == other.principalPaid && + interestPaid == other.interestPaid && + valueChange == other.valueChange && feePaid == other.feePaid; +} + Number loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) { @@ -170,6 +178,12 @@ loanLatePaymentInterest( * * The spec is to be updated to base the duration on the next due date */ + + // If the payment is not late by any amount of time, then there's no late + // interest + if (parentCloseTime.time_since_epoch().count() <= nextPaymentDueDate) + return 0; + auto const secondsOverdue = parentCloseTime.time_since_epoch().count() - nextPaymentDueDate; @@ -193,6 +207,11 @@ loanAccruedInterest( */ auto const lastPaymentDate = std::max(prevPaymentDate, startDate); + // If the loan has been paid ahead, then "lastPaymentDate" is in the future, + // and no interest has accrued. + if (parentCloseTime.time_since_epoch().count() <= lastPaymentDate) + return 0; + auto const secondsSinceLastPayment = parentCloseTime.time_since_epoch().count() - lastPaymentDate; @@ -818,7 +837,7 @@ computeLatePayment( beast::Journal j) { if (!hasExpired(view, nextDueDate)) - return Unexpected(tesSUCCESS); + return Unexpected(tecTOO_SOON); // the payment is late // Late payment interest is only the part of the interest that comes @@ -972,9 +991,19 @@ computeFullPayment( "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, but that's not an error. + // sufficient to be a full payment. return Unexpected(tecINSUFFICIENT_PAYMENT); return full; @@ -1639,111 +1668,6 @@ computeLoanProperties( .firstPaymentPrincipal = firstPaymentPrincipal}; } -Expected -loanMakeFullPayment( - Asset const& asset, - ApplyView& view, - SLE::ref loan, - SLE::const_ref brokerSle, - STAmount const& amount, - bool const overpaymentAllowed, - beast::Journal j) -{ - auto principalOutstandingProxy = loan->at(sfPrincipalOutstanding); - auto paymentRemainingProxy = loan->at(sfPaymentRemaining); - - if (paymentRemainingProxy == 0 || principalOutstandingProxy == 0) - { - // Loan complete - JLOG(j.warn()) << "Loan is already paid off."; - return Unexpected(tecKILLED); - } - - 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) - { - 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)}; - TenthBips32 const closeInterestRate{loan->at(sfCloseInterestRate)}; - - Number const closePaymentFee = - roundToAsset(asset, loan->at(sfClosePaymentFee), loanScale); - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - - auto 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 normal periodic rate, payment, etc. - // We'll need it in the remaining calculations - Number const periodicRate = loanPeriodicRate(interestRate, paymentInterval); - XRPL_ASSERT( - interestRate == 0 || periodicRate > 0, - "ripple::loanMakeFullPayment : valid rate"); - - XRPL_ASSERT( - *totalValueOutstandingProxy > 0, - "ripple::loanMakeFullPayment : valid total value"); - - view.update(loan); - - // ------------------------------------------------------------- - // full payment handling - LoanState const roundedLoanState = calculateRoundedLoanState( - 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::loanMakeFullPayment : invalid result"); - return Unexpected(tecINTERNAL); - // LCOV_EXCL_STOP -} - Expected loanMakePayment( Asset const& asset, @@ -1751,7 +1675,7 @@ loanMakePayment( SLE::ref loan, SLE::const_ref brokerSle, STAmount const& amount, - bool const overpaymentAllowed, + LoanPaymentType const paymentType, beast::Journal j) { /* @@ -1785,16 +1709,14 @@ loanMakePayment( std::int32_t const loanScale = loan->at(sfLoanScale); TenthBips32 const interestRate{loan->at(sfInterestRate)}; - TenthBips32 const lateInterestRate{loan->at(sfLateInterestRate)}; - TenthBips32 const closeInterestRate{loan->at(sfCloseInterestRate)}; Number const serviceFee = loan->at(sfLoanServiceFee); - Number const latePaymentFee = loan->at(sfLatePaymentFee); 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 normal periodic rate, payment, etc. @@ -1810,7 +1732,7 @@ loanMakePayment( view.update(loan); - detail::PaymentComponentsPlus const periodic{ + detail::PaymentComponentsPlus periodic{ detail::computePaymentComponents( asset, loanScale, @@ -1829,21 +1751,141 @@ loanMakePayment( // ------------------------------------------------------------- // late payment handling - if (auto const latePaymentComponents = detail::computeLatePayment( - asset, - view, - principalOutstandingProxy, - *nextDueDateProxy, - periodic, - lateInterestRate, - loanScale, - latePaymentFee, - amount, - managementFeeRate, - j)) + if (paymentType == LoanPaymentType::late) { - return doPayment( - *latePaymentComponents, + 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"); + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + else if (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 = calculateRoundedLoanState( + 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"); + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + + // ------------------------------------------------------------- + // regular periodic payment handling + + XRPL_ASSERT_PARTS( + paymentType == LoanPaymentType::regular || + paymentType == LoanPaymentType::overpayment, + "ripple::loanMakePayment", + "regular payment type"); + + // This will keep a running total of what is actually paid, if the payment + // is sufficient for any payment + 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, @@ -1851,47 +1893,19 @@ loanMakePayment( 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. Otherwise, tesSUCCESS - // means nothing was done, so continue. - return Unexpected(latePaymentComponents.error()); + ++numPayments; - // ------------------------------------------------------------- - // regular periodic payment handling + XRPL_ASSERT_PARTS( + (periodic.specialCase == detail::PaymentSpecialCase::final) == + (paymentRemainingProxy == 0), + "ripple::loanMakePayment", + "final payment is the final payment"); - // if the payment is not late nor if it's a full payment, then it must - // be a periodic one, with possible overpayments + // Don't compute the next payment if this was the last payment + if (periodic.specialCase == detail::PaymentSpecialCase::final) + break; - // This will keep a running total of what is actually paid, if the payment - // is sufficient for a single payment - Number totalPaid = periodic.totalDue; - - if (amount < totalPaid) - { - JLOG(j.warn()) << "Periodic loan payment amount is insufficient. Due: " - << totalPaid << ", paid: " << amount; - return Unexpected(tecINSUFFICIENT_PAYMENT); - } - - LoanPaymentParts totalParts = detail::doPayment( - periodic, - totalValueOutstandingProxy, - principalOutstandingProxy, - managementFeeOutstandingProxy, - paymentRemainingProxy, - prevPaymentDateProxy, - nextDueDateProxy, - paymentInterval); - - std::size_t numPayments = 1; - - while (totalPaid < amount && paymentRemainingProxy > 0 && - numPayments < loanMaximumPaymentsPerTransaction) - { - // Try to make more payments - detail::PaymentComponentsPlus const nextPayment{ + periodic = detail::PaymentComponentsPlus{ detail::computePaymentComponents( asset, loanScale, @@ -1903,40 +1917,13 @@ loanMakePayment( paymentRemainingProxy, managementFeeRate), serviceFee}; - XRPL_ASSERT_PARTS( - nextPayment.trackedPrincipalDelta >= 0, - "ripple::loanMakePayment", - "additional payment pays non-negative principal"); -#if LOANCOMPLETE - XRPL_ASSERT( - nextPayment.rawInterest <= periodic.rawInterest, - "ripple::loanMakePayment : decreasing interest"); - XRPL_ASSERT( - nextPayment.rawPrinicpal >= periodic.rawPrincipal, - "ripple::loanMakePayment : increasing principal"); -#endif + } - if (amount < totalPaid + nextPayment.totalDue) - // We're done making payments. - break; - - totalPaid += nextPayment.totalDue; - totalParts += detail::doPayment( - nextPayment, - totalValueOutstandingProxy, - principalOutstandingProxy, - managementFeeOutstandingProxy, - paymentRemainingProxy, - prevPaymentDateProxy, - nextDueDateProxy, - paymentInterval); - ++numPayments; - - XRPL_ASSERT_PARTS( - (nextPayment.specialCase == detail::PaymentSpecialCase::final) == - (paymentRemainingProxy == 0), - "ripple::loanMakePayment", - "final payment is the final payment"); + if (numPayments == 0) + { + JLOG(j.warn()) << "Regular loan payment amount is insufficient. Due: " + << periodic.totalDue << ", paid: " << amount; + return Unexpected(tecINSUFFICIENT_PAYMENT); } XRPL_ASSERT_PARTS( @@ -1952,8 +1939,9 @@ loanMakePayment( // ------------------------------------------------------------- // overpayment handling - if (overpaymentAllowed && loan->isFlag(lsfLoanOverpayment) && - paymentRemainingProxy > 0 && nextDueDateProxy && totalPaid < amount && + if (paymentType == LoanPaymentType::overpayment && + loan->isFlag(lsfLoanOverpayment) && paymentRemainingProxy > 0 && + nextDueDateProxy && totalPaid < amount && numPayments < loanMaximumPaymentsPerTransaction) { TenthBips32 const overpaymentInterestRate{ diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp index fe19ed1c0d..64e18016e1 100644 --- a/src/xrpld/app/tx/detail/LoanPay.cpp +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -30,9 +30,21 @@ LoanPay::preflight(PreflightContext const& ctx) if (ctx.tx[sfAmount] <= beast::zero) return temBAD_AMOUNT; - // isFlag requires an exact match - all flags to be set - to return true. - if (ctx.tx.isFlag(tfLoanOverpayment | tfLoanFullPayment)) + // The loan payment flags are all mutually exclusive. If more than one is + // set, the tx is malformed. + int flagsSet = 0; + for (auto const flag : + {tfLoanLatePayment, tfLoanFullPayment, tfLoanOverpayment}) + { + if (ctx.tx.isFlag(flag)) + ++flagsSet; + } + if (flagsSet > 1) + { + JLOG(ctx.j.warn()) << "Only one LoanPay flag can be set per tx. " + << flagsSet << " is too many."; return temINVALID_FLAG; + } return tesSUCCESS; } @@ -42,8 +54,8 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) { auto const normalCost = Transactor::calculateBaseFee(view, tx); - if (tx.isFlag(tfLoanFullPayment)) - // The loan will be making one set of calculations for one (large) + if (tx.isFlag(tfLoanFullPayment) || tx.isFlag(tfLoanLatePayment)) + // The loan will be making one set of calculations for one full or late // payment return normalCost; @@ -65,7 +77,8 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) } if (hasExpired(view, loanSle->at(sfNextPaymentDueDate))) - // If the payment is late, it'll only make one payment + // If the payment is late, and the late payment flag is not set, it'll + // fail return normalCost; auto const brokerSle = @@ -97,6 +110,7 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) // Estimate how many payments will be made Number const numPaymentEstimate = static_cast(amount / regularPayment); + // Charge one base fee per paymentsPerFeeIncrement payments, rounding up. Number::setround(Number::upward); auto const feeIncrements = std::max( @@ -291,23 +305,19 @@ LoanPay::doApply() LoanManage::unimpairLoan(view, loanSle, vaultSle, j_); } - Expected const paymentParts = - tx.isFlag(tfLoanFullPayment) ? loanMakeFullPayment( - asset, - view, - loanSle, - brokerSle, - amount, - tx.isFlag(tfLoanOverpayment), - j_) - : loanMakePayment( - asset, - view, - loanSle, - brokerSle, - amount, - tx.isFlag(tfLoanOverpayment), - j_); + LoanPaymentType const paymentType = [&tx]() { + // preflight already checked that at most one flag is set. + if (tx.isFlag(tfLoanLatePayment)) + return LoanPaymentType::late; + if (tx.isFlag(tfLoanFullPayment)) + return LoanPaymentType::full; + if (tx.isFlag(tfLoanOverpayment)) + return LoanPaymentType::overpayment; + return LoanPaymentType::regular; + }(); + + Expected const paymentParts = loanMakePayment( + asset, view, loanSle, brokerSle, amount, paymentType, j_); if (!paymentParts) {