diff --git a/audit/spearbit-incremental-Nov-2024.pdf b/audit/spearbit-incremental-Nov-2024.pdf new file mode 100644 index 00000000..a3591f39 Binary files /dev/null and b/audit/spearbit-incremental-Nov-2024.pdf differ diff --git a/contracts/common/Errors.sol b/contracts/common/Errors.sol index 9c95ef29..e5f2e295 100644 --- a/contracts/common/Errors.sol +++ b/contracts/common/Errors.sol @@ -16,6 +16,7 @@ contract Errors { error PoolOwnerOrHumaOwnerRequired(); // 0x3e984120 error PoolOperatorRequired(); // 0xae7fe070 error PoolOwnerRequired(); // 0x8b506451 + error PoolOwnerTreasuryRequired(); // 0xf527eb38 error HumaTreasuryRequired(); // 0x6e0a9ac9 error PoolOwnerOrEARequired(); // 0xe54466f3 error PauserRequired(); // 0xd4a99e4e diff --git a/contracts/credit/Credit.sol b/contracts/credit/Credit.sol index bc8b439b..5c0a4601 100644 --- a/contracts/credit/Credit.sol +++ b/contracts/credit/Credit.sol @@ -632,6 +632,10 @@ abstract contract Credit is PoolConfigCache, CreditStorage, ICredit { revert Errors.SentinelServiceAccountRequired(); } + function _onlyPoolOwnerTreasury(address account) internal view { + if (account != poolConfig.poolOwnerTreasury()) revert Errors.PoolOwnerTreasuryRequired(); + } + /** * @notice Returns from whose account the funds for payment should be extracted. * @notice This function exists because of Auto-pay: diff --git a/contracts/credit/CreditLine.sol b/contracts/credit/CreditLine.sol index 4628d684..b1c05901 100644 --- a/contracts/credit/CreditLine.sol +++ b/contracts/credit/CreditLine.sol @@ -36,6 +36,20 @@ contract CreditLine is Credit, ICreditLine { return _makePayment(borrower, creditHash, amount); } + /// @inheritdoc ICreditLine + function makePaymentOnBehalfOf( + address borrower, + uint256 amount + ) external virtual override returns (uint256 amountPaid, bool paidoff) { + poolConfig.onlyProtocolAndPoolOn(); + _onlyPoolOwnerTreasury(msg.sender); + + bytes32 creditHash = getCreditHash(borrower); + creditManager.onlyCreditBorrower(creditHash, borrower); + + return _makePayment(borrower, creditHash, amount); + } + /// @inheritdoc ICreditLine function makePrincipalPayment( uint256 amount diff --git a/contracts/credit/ReceivableBackedCreditLine.sol b/contracts/credit/ReceivableBackedCreditLine.sol index efa38d18..cc6a94d3 100644 --- a/contracts/credit/ReceivableBackedCreditLine.sol +++ b/contracts/credit/ReceivableBackedCreditLine.sol @@ -37,6 +37,20 @@ contract ReceivableBackedCreditLine is Credit, IERC721Receiver { address by ); + /** + * @notice A payment has been made against the credit line by someone on behalf of the borrower. + * @param borrower The address of the borrower. + * @param receivableId The ID of the receivable. + * @param amount The payback amount. + * @param by The address that initiated the payment. + */ + event PaymentMadeOnBehalfOfWithReceivable( + address indexed borrower, + uint256 indexed receivableId, + uint256 amount, + address by + ); + /** * @notice A borrowing event has happened to the credit line. * @param borrower The address of the borrower. @@ -118,6 +132,27 @@ contract ReceivableBackedCreditLine is Credit, IERC721Receiver { emit PaymentMadeWithReceivable(borrower, receivableId, amount, msg.sender); } + /** + * @notice Allows the Pool Owner Treasury to pay back on behalf of the borrower with a receivable + */ + function makePaymentOnBehalfOfWithReceivable( + address borrower, + uint256 receivableId, + uint256 amount + ) public virtual returns (uint256 amountPaid, bool paidoff) { + poolConfig.onlyProtocolAndPoolOn(); + _onlyPoolOwnerTreasury(msg.sender); + + bytes32 creditHash = getCreditHash(borrower); + creditManager.onlyCreditBorrower(creditHash, borrower); + + _prepareForPayment(borrower, poolConfig.receivableAsset(), receivableId); + + (amountPaid, paidoff) = _makePayment(borrower, creditHash, amount); + + emit PaymentMadeOnBehalfOfWithReceivable(borrower, receivableId, amount, msg.sender); + } + /** * @notice Allows the borrower to payback the principal and label it with a receivable */ diff --git a/contracts/credit/interfaces/ICreditLine.sol b/contracts/credit/interfaces/ICreditLine.sol index 784eda8f..b0fdc995 100644 --- a/contracts/credit/interfaces/ICreditLine.sol +++ b/contracts/credit/interfaces/ICreditLine.sol @@ -30,6 +30,23 @@ interface ICreditLine { uint256 amount ) external returns (uint256 amountPaid, bool paidoff); + /** + * @notice Makes one payment for the credit line by the pool owner treasury on behalf of the borrower. + * If this is the final payment, it automatically triggers the payoff process. + * @notice Warning: payments should be made by calling this function. No token should be transferred directly + * to the contract. + * @param borrower The address of the borrower. + * @param amount The payment amount. + * @return amountPaid The actual amount paid to the contract. When the tendered + * amount is larger than the payoff amount, the contract only accepts the payoff amount. + * @return paidoff A flag indicating whether the account has been paid off. + * @custom:access Only the Pool Owner Treasury can call this function. + */ + function makePaymentOnBehalfOf( + address borrower, + uint256 amount + ) external returns (uint256 amountPaid, bool paidoff); + /** * @notice Makes a payment towards the principal for the credit line. Even if there is additional amount remaining * after the principal is paid off, this function will only accept the amount up to the total principal due. diff --git a/package.json b/package.json index 8b95d13f..174d1803 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "huma-contracts-v2", - "version": "2.1.0", + "version": "2.2.0", "description": "", "keywords": [], "license": "AGPL-3.0-or-later", diff --git a/scripts/error-functions.json b/scripts/error-functions.json index 2818f29a..a78f9ad5 100644 --- a/scripts/error-functions.json +++ b/scripts/error-functions.json @@ -10,6 +10,7 @@ "PoolOwnerOrHumaOwnerRequired()": "0x3e984120", "PoolOperatorRequired()": "0xae7fe070", "PoolOwnerRequired()": "0x8b506451", + "PoolOwnerTreasuryRequired()": "0xf527eb38", "HumaTreasuryRequired()": "0x6e0a9ac9", "PoolOwnerOrEARequired()": "0xe54466f3", "PauserRequired()": "0xd4a99e4e", diff --git a/test/unit/credit/CreditLineTest.ts b/test/unit/credit/CreditLineTest.ts index 0ff73bfa..9d76b05b 100644 --- a/test/unit/credit/CreditLineTest.ts +++ b/test/unit/credit/CreditLineTest.ts @@ -7422,6 +7422,496 @@ describe("CreditLine Test", function () { }); }); + describe("makePaymentOnBehalfOf", function () { + const yieldInBps = 1217, + lateFeeBps = 300, + latePaymentGracePeriodInDays = 5, + remainingPeriods = 6; + let principalRateInBps: number; + let borrowAmount: BN, creditHash: string; + let drawdownDate: moment.Moment, makePaymentDate: moment.Moment; + + async function approveCredit() { + const settings = await poolConfigContract.getPoolSettings(); + await poolConfigContract + .connect(poolOwner) + .setPoolSettings({ ...settings, ...{ latePaymentGracePeriodInDays: 5 } }); + + await creditManagerContract + .connect(evaluationAgent) + .approveBorrower( + borrower.getAddress(), + toToken(100_000), + remainingPeriods, + yieldInBps, + toToken(100_000), + 0, + true, + ); + } + + async function drawdown() { + const currentTS = (await getLatestBlock()).timestamp; + drawdownDate = moment.utc( + ( + await calendarContract.getStartDateOfNextPeriod( + PayPeriodDuration.Monthly, + currentTS, + ) + ).toNumber() * 1000, + ); + drawdownDate = drawdownDate + .add(11, "days") + .add(13, "hours") + .add(47, "minutes") + .add(8, "seconds"); + await setNextBlockTimestamp(drawdownDate.unix()); + + borrowAmount = toToken(50_000); + await creditContract.connect(borrower).drawdown(borrowAmount); + } + + async function testMakePaymentOnBehalfOf( + paymentAmount: BN, + paymentDate: moment.Moment = makePaymentDate, + paymentInitiator: SignerWithAddress = borrower, + ) { + const cc = await creditManagerContract.getCreditConfig(creditHash); + const cr = await creditContract.getCreditRecord(creditHash); + const dd = await creditContract.getDueDetail(creditHash); + const maturityDate = moment.utc( + getMaturityDate(cc.periodDuration, cc.numOfPeriods - 1, drawdownDate.unix()) * + 1000, + ); + + // Calculate the dues, fees and dates right before the payment is made. + let [ + remainingUnbilledPrincipal, + remainingPrincipalPastDue, + remainingPrincipalNextDue, + ] = await calcPrincipalDueNew( + calendarContract, + cc, + cr, + dd, + paymentDate, + maturityDate, + latePaymentGracePeriodInDays, + principalRateInBps, + ); + let [ + remainingYieldPastDue, + remainingYieldNextDue, + [accruedYieldNextDue, committedYieldNextDue], + ] = await calcYieldDueNew( + calendarContract, + cc, + cr, + dd, + paymentDate, + latePaymentGracePeriodInDays, + ); + let [lateFeeUpdatedDate, remainingLateFee] = await calcLateFeeNew( + poolConfigContract, + calendarContract, + cc, + cr, + dd, + paymentDate, + latePaymentGracePeriodInDays, + ); + let nextDueBefore = remainingPrincipalNextDue.add(remainingYieldNextDue); + + let principalDuePaid = BN.from(0), + yieldDuePaid = BN.from(0), + unbilledPrincipalPaid = BN.from(0), + principalPastDuePaid = BN.from(0), + yieldPastDuePaid = BN.from(0), + lateFeePaid = BN.from(0), + remainingPaymentAmount = paymentAmount; + // If there is past due, attempt to pay past due first. + let remainingPastDue = remainingPrincipalPastDue + .add(remainingYieldPastDue) + .add(remainingLateFee); + if (remainingPastDue.gt(0)) { + if (paymentAmount.gte(remainingPastDue)) { + yieldPastDuePaid = remainingYieldPastDue; + remainingYieldPastDue = BN.from(0); + principalPastDuePaid = remainingPrincipalPastDue; + remainingPrincipalPastDue = BN.from(0); + lateFeePaid = remainingLateFee; + remainingLateFee = BN.from(0); + remainingPaymentAmount = paymentAmount.sub(remainingPastDue); + } else if (paymentAmount.gte(remainingYieldPastDue.add(remainingLateFee))) { + principalPastDuePaid = paymentAmount + .sub(remainingYieldPastDue) + .sub(remainingLateFee); + remainingPrincipalPastDue = + remainingPrincipalPastDue.sub(principalPastDuePaid); + yieldPastDuePaid = remainingYieldPastDue; + remainingYieldPastDue = BN.from(0); + lateFeePaid = remainingLateFee; + remainingLateFee = BN.from(0); + remainingPaymentAmount = BN.from(0); + } else if (paymentAmount.gte(remainingYieldPastDue)) { + lateFeePaid = paymentAmount.sub(remainingYieldPastDue); + remainingLateFee = remainingLateFee.sub(lateFeePaid); + yieldPastDuePaid = remainingYieldPastDue; + remainingYieldPastDue = BN.from(0); + remainingPaymentAmount = BN.from(0); + } else { + yieldPastDuePaid = paymentAmount; + remainingYieldPastDue = remainingYieldPastDue.sub(paymentAmount); + remainingPaymentAmount = BN.from(0); + } + remainingPastDue = remainingPrincipalPastDue + .add(remainingYieldPastDue) + .add(remainingLateFee); + } + // Then pay next due. + let nextDueAfter = nextDueBefore; + if (remainingPaymentAmount.gt(0)) { + if (remainingPaymentAmount.gte(nextDueBefore)) { + yieldDuePaid = remainingYieldNextDue; + principalDuePaid = remainingPrincipalNextDue; + remainingPaymentAmount = remainingPaymentAmount.sub(nextDueBefore); + remainingYieldNextDue = BN.from(0); + remainingPrincipalNextDue = BN.from(0); + unbilledPrincipalPaid = minBigNumber( + remainingUnbilledPrincipal, + remainingPaymentAmount, + ); + remainingUnbilledPrincipal = + remainingUnbilledPrincipal.sub(unbilledPrincipalPaid); + remainingPaymentAmount = remainingPaymentAmount.sub(unbilledPrincipalPaid); + } else if (remainingPaymentAmount.gte(remainingYieldNextDue)) { + yieldDuePaid = remainingYieldNextDue; + principalDuePaid = remainingPaymentAmount.sub(remainingYieldNextDue); + remainingPrincipalNextDue = remainingPrincipalNextDue.sub( + remainingPaymentAmount.sub(remainingYieldNextDue), + ); + remainingYieldNextDue = BN.from(0); + remainingPaymentAmount = BN.from(0); + } else { + yieldDuePaid = remainingPaymentAmount; + remainingYieldNextDue = remainingYieldNextDue.sub(remainingPaymentAmount); + remainingPaymentAmount = BN.from(0); + } + nextDueAfter = remainingYieldNextDue.add(remainingPrincipalNextDue); + } + + // Clear late fee updated date if the bill is paid off + if (remainingPastDue.isZero()) { + lateFeeUpdatedDate = BN.from(0); + } + + let newDueDate; + if ( + paymentDate.isSameOrBefore( + getNextBillRefreshDate(cr, paymentDate, latePaymentGracePeriodInDays), + ) + ) { + newDueDate = cr.nextDueDate; + } else { + newDueDate = await calendarContract.getStartDateOfNextPeriod( + cc.periodDuration, + paymentDate.unix(), + ); + } + const paymentAmountUsed = paymentAmount.sub(remainingPaymentAmount); + + const oldPoolOwnerTreasuryBalance = await mockTokenContract.balanceOf( + poolOwnerTreasury.getAddress(), + ); + const oldBorrowerBalance = await mockTokenContract.balanceOf(borrower.getAddress()); + + if (paymentAmountUsed.gt(ethers.constants.Zero)) { + let poolDistributionEventName = ""; + if (cr.state === CreditState.Defaulted) { + poolDistributionEventName = "LossRecoveryDistributed"; + } else if (yieldPastDuePaid.add(yieldDuePaid).add(lateFeePaid).gt(0)) { + poolDistributionEventName = "ProfitDistributed"; + } + + if (poolDistributionEventName !== "") { + await expect( + creditContract + .connect(paymentInitiator) + .makePaymentOnBehalfOf(borrower.getAddress(), paymentAmount), + ) + .to.emit(creditContract, "PaymentMade") + .withArgs( + await borrower.getAddress(), + await poolOwnerTreasury.getAddress(), + paymentAmountUsed, + yieldDuePaid, + principalDuePaid, + unbilledPrincipalPaid, + yieldPastDuePaid, + lateFeePaid, + principalPastDuePaid, + await paymentInitiator.getAddress(), + ) + .to.emit(poolContract, poolDistributionEventName); + } else { + await expect( + creditContract + .connect(paymentInitiator) + .makePaymentOnBehalfOf(borrower.getAddress(), paymentAmount), + ) + .to.emit(creditContract, "PaymentMade") + .withArgs( + await borrower.getAddress(), + await poolOwnerTreasury.getAddress(), + paymentAmountUsed, + yieldDuePaid, + principalDuePaid, + unbilledPrincipalPaid, + yieldPastDuePaid, + lateFeePaid, + principalPastDuePaid, + await paymentInitiator.getAddress(), + ); + } + } else { + await expect( + creditContract + .connect(paymentInitiator) + .makePaymentOnBehalfOf(borrower.getAddress(), paymentAmount), + ).not.to.emit(creditContract, "PaymentMade"); + } + + const newPoolOwnerTreasuryBalance = await mockTokenContract.balanceOf( + poolOwnerTreasury.getAddress(), + ); + expect(oldPoolOwnerTreasuryBalance.sub(newPoolOwnerTreasuryBalance)).to.equal( + paymentAmountUsed, + ); + const newBorrowerBalance = await mockTokenContract.balanceOf(borrower.getAddress()); + expect(oldBorrowerBalance).to.equal(newBorrowerBalance); + + const newCR = await creditContract.getCreditRecord(creditHash); + let periodsPassed = 0; + if (cr.state === CreditState.Approved) { + periodsPassed = 1; + } else if (cr.state === CreditState.GoodStanding) { + if ( + paymentDate.isAfter( + getLatePaymentGracePeriodDeadline(cr, latePaymentGracePeriodInDays), + ) + ) { + periodsPassed = + ( + await calendarContract.getNumPeriodsPassed( + cc.periodDuration, + cr.nextDueDate, + paymentDate.unix(), + ) + ).toNumber() + 1; + } + } else if (paymentDate.isAfter(moment.utc(cr.nextDueDate.toNumber() * 1000))) { + periodsPassed = + ( + await calendarContract.getNumPeriodsPassed( + cc.periodDuration, + cr.nextDueDate, + paymentDate.unix(), + ) + ).toNumber() + 1; + } + const remainingPeriods = Math.max(cr.remainingPeriods - periodsPassed, 0); + // Whether the bill is late up until payment is made. + const isLate = + cr.missedPeriods > 0 || + (cr.nextDue.gt(0) && + paymentDate.isAfter( + getLatePaymentGracePeriodDeadline(cr, latePaymentGracePeriodInDays), + )); + const missedPeriods = + !isLate || remainingPastDue.isZero() ? 0 : cr.missedPeriods + periodsPassed; + let creditState; + if (remainingPastDue.isZero()) { + if (nextDueAfter.isZero() && remainingUnbilledPrincipal.isZero()) { + if (remainingPeriods === 0) { + creditState = CreditState.Deleted; + } else { + creditState = CreditState.GoodStanding; + } + } else if (cr.state === CreditState.Delayed) { + creditState = CreditState.GoodStanding; + } else { + creditState = cr.state; + } + } else if (missedPeriods != 0) { + if (cr.state === CreditState.GoodStanding) { + creditState = CreditState.Delayed; + } else { + creditState = cr.state; + } + } else { + creditState = cr.state; + } + let expectedNewCR, expectedNewDD; + if ( + nextDueAfter.isZero() && + !remainingUnbilledPrincipal.isZero() && + newDueDate.lt(paymentDate.unix()) + ) { + // We expect the bill to be refreshed if all next due is paid off and the bill is in the + // new billing cycle. + const [accrued, committed] = calcYieldDue( + cc, + remainingUnbilledPrincipal, + CONSTANTS.DAYS_IN_A_MONTH, + ); + const yieldDue = maxBigNumber(accrued, committed); + let principalDue; + newDueDate = await calendarContract.getStartDateOfNextPeriod( + cc.periodDuration, + newDueDate, + ); + if (newDueDate.eq(maturityDate.unix())) { + principalDue = remainingUnbilledPrincipal; + } else { + principalDue = calcPrincipalDueForFullPeriods( + remainingUnbilledPrincipal, + principalRateInBps, + 1, + ); + } + expectedNewCR = { + unbilledPrincipal: remainingUnbilledPrincipal.sub(principalDue), + nextDueDate: newDueDate, + nextDue: yieldDue.add(principalDue), + yieldDue: yieldDue, + totalPastDue: BN.from(0), + missedPeriods: 0, + remainingPeriods: remainingPeriods - 1, + state: CreditState.GoodStanding, + }; + expectedNewDD = { + lateFeeUpdatedDate: 0, + lateFee: 0, + principalPastDue: 0, + yieldPastDue: 0, + accrued: accrued, + committed: committed, + paid: 0, + }; + } else { + expectedNewCR = { + unbilledPrincipal: remainingUnbilledPrincipal, + nextDueDate: newDueDate, + nextDue: nextDueAfter, + yieldDue: remainingYieldNextDue, + totalPastDue: remainingPastDue, + missedPeriods, + remainingPeriods, + state: creditState, + }; + const yieldPaidInCurrentCycle = + newDueDate === cr.nextDueDate ? dd.paid.add(yieldDuePaid) : yieldDuePaid; + expectedNewDD = { + lateFeeUpdatedDate, + lateFee: remainingLateFee, + principalPastDue: remainingPrincipalPastDue, + yieldPastDue: remainingYieldPastDue, + accrued: accruedYieldNextDue, + committed: committedYieldNextDue, + paid: yieldPaidInCurrentCycle, + }; + } + await checkCreditRecordsMatch(newCR, expectedNewCR); + + const newDD = await creditContract.getDueDetail(creditHash); + await checkDueDetailsMatch(newDD, expectedNewDD); + } + + async function prepare() { + creditHash = await borrowerLevelCreditHash(creditContract, borrower); + + principalRateInBps = 0; + await poolConfigContract.connect(poolOwner).setFeeStructure({ + yieldInBps, + minPrincipalRateInBps: principalRateInBps, + lateFeeBps, + }); + await approveCredit(); + await drawdown(); + + makePaymentDate = drawdownDate + .clone() + .add(16, "days") + .add(2, "hours") + .add(31, "seconds"); + await setNextBlockTimestamp(makePaymentDate.unix()); + } + + beforeEach(async function () { + await loadFixture(prepare); + }); + + it("Should allow the borrower to make multiple payments", async function () { + const cc = await creditManagerContract.getCreditConfig(creditHash); + const cr = await creditContract.getCreditRecord(creditHash); + const dd = await creditContract.getDueDetail(creditHash); + + const [, yieldNextDue] = await calcYieldDueNew( + calendarContract, + cc, + cr, + dd, + makePaymentDate, + latePaymentGracePeriodInDays, + ); + + // Make a series of payment gradually and eventually pay off the bill. + await testMakePaymentOnBehalfOf(yieldNextDue, makePaymentDate, poolOwnerTreasury); + + const secondPaymentDate = makePaymentDate.clone().add(1, "day").add(4, "hours"); + setNextBlockTimestamp(secondPaymentDate.unix()); + await testMakePaymentOnBehalfOf(borrowAmount, secondPaymentDate, poolOwnerTreasury); + + const thirdPaymentDate = secondPaymentDate.clone().add("3", "hours"); + setNextBlockTimestamp(thirdPaymentDate.unix()); + await testMakePaymentOnBehalfOf(toToken(1), thirdPaymentDate, poolOwnerTreasury); + }); + + it("Should not allow payment when the protocol is paused or the pool is not on", async function () { + await humaConfigContract.connect(protocolOwner).pause(); + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOf(borrower.getAddress(), toToken(1)), + ).to.be.revertedWithCustomError(poolConfigContract, "ProtocolIsPaused"); + await humaConfigContract.connect(protocolOwner).unpause(); + + await poolContract.connect(poolOwner).disablePool(); + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOf(borrower.getAddress(), toToken(1)), + ).to.be.revertedWithCustomError(poolConfigContract, "PoolIsNotOn"); + await poolContract.connect(poolOwner).enablePool(); + }); + + it("Should not allow non-Pool-Owner-Treasury to make payment on behalf of the borrower", async function () { + await expect( + creditContract + .connect(borrower) + .makePaymentOnBehalfOf(borrower.getAddress(), toToken(1)), + ).to.be.revertedWithCustomError(creditContract, "PoolOwnerTreasuryRequired"); + }); + + it("Should not allow payment with 0 amount", async function () { + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOf(borrower.getAddress(), 0), + ).to.be.revertedWithCustomError(creditContract, "ZeroAmountProvided"); + }); + }); + describe("makePrincipalPayment", function () { const yieldInBps = 1217, lateFeeBps = 300, diff --git a/test/unit/credit/ReceivableBackedCreditLineTest.ts b/test/unit/credit/ReceivableBackedCreditLineTest.ts index 3223303e..f1be1eb8 100644 --- a/test/unit/credit/ReceivableBackedCreditLineTest.ts +++ b/test/unit/credit/ReceivableBackedCreditLineTest.ts @@ -929,6 +929,347 @@ describe("ReceivableBackedCreditLine Tests", function () { }); }); + describe("makePaymentOnBehalfOfWithReceivable", function () { + const yieldInBps = 1217, + principalRate = 100, + lateFeeBps = 2400; + const numOfPeriods = 3, + latePaymentGracePeriodInDays = 5; + let maturityDate: number; + let committedAmount: BN, borrowAmount: BN, receivableId: BN; + let creditHash: string; + + async function prepareForMakePayment() { + const settings = await poolConfigContract.getPoolSettings(); + await poolConfigContract.connect(poolOwner).setPoolSettings({ + ...settings, + ...{ + latePaymentGracePeriodInDays: latePaymentGracePeriodInDays, + advanceRateInBps: CONSTANTS.BP_FACTOR, + receivableAutoApproval: true, + }, + }); + await poolConfigContract.connect(poolOwner).setFeeStructure({ + yieldInBps, + minPrincipalRateInBps: principalRate, + lateFeeBps, + }); + + committedAmount = toToken(10_000); + borrowAmount = toToken(15_000); + creditHash = await borrowerLevelCreditHash(creditContract, borrower); + + const currentTS = (await getLatestBlock()).timestamp; + maturityDate = ( + await calendarContract.getStartDateOfNextPeriod( + PayPeriodDuration.Monthly, + currentTS, + ) + ).toNumber(); + await receivableContract + .connect(borrower) + .createReceivable(1, borrowAmount, maturityDate, "", ""); + receivableId = await receivableContract.tokenOfOwnerByIndex(borrower.getAddress(), 0); + await receivableContract + .connect(borrower) + .approve(creditContract.address, receivableId); + } + + async function approveAndDrawdown() { + await creditManagerContract + .connect(evaluationAgent) + .approveBorrower( + borrower.getAddress(), + toToken(100_000), + numOfPeriods, + yieldInBps, + committedAmount, + 0, + true, + ); + await creditContract + .connect(borrower) + .drawdownWithReceivable(receivableId, borrowAmount); + } + + beforeEach(async function () { + await loadFixture(prepareForMakePayment); + }); + + describe("Without credit approval", function () { + it("Should not allow payment on a non-existent credit", async function () { + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOfWithReceivable( + borrower.getAddress(), + receivableId, + borrowAmount, + ), + ).to.be.revertedWithCustomError(creditManagerContract, "BorrowerRequired"); + }); + }); + + describe("With credit approval", function () { + beforeEach(async function () { + await loadFixture(approveAndDrawdown); + }); + + it("Should allow the Pool Owner Treasury to make payment on behalf of the borrower", async function () { + const oldCR = await creditContract.getCreditRecord(creditHash); + const oldDD = await creditContract.getDueDetail(creditHash); + const paymentAmount = oldCR.yieldDue; + + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOfWithReceivable( + borrower.getAddress(), + receivableId, + paymentAmount, + ), + ) + .to.emit(creditContract, "PaymentMade") + .withArgs( + await borrower.getAddress(), + await poolOwnerTreasury.getAddress(), + paymentAmount, + oldCR.yieldDue, + 0, + 0, + 0, + 0, + 0, + await poolOwnerTreasury.getAddress(), + ) + .to.emit(creditContract, "PaymentMadeOnBehalfOfWithReceivable") + .withArgs( + await borrower.getAddress(), + receivableId, + paymentAmount, + await poolOwnerTreasury.getAddress(), + ); + + const actualCR = await creditContract.getCreditRecord(creditHash); + const expectedCR = { + ...oldCR, + ...{ + nextDue: oldCR.nextDue.sub(oldCR.yieldDue), + yieldDue: 0, + }, + }; + checkCreditRecordsMatch(actualCR, expectedCR); + + const actualDD = await creditContract.getDueDetail(creditHash); + const expectedDD = { + ...oldDD, + ...{ + paid: paymentAmount, + }, + }; + checkDueDetailsMatch(actualDD, expectedDD); + }); + + it("Should allow the Pool Owner Treasury to make payment even if the receivable has matured", async function () { + const oldCR = await creditContract.getCreditRecord(creditHash); + const oldDD = await creditContract.getDueDetail(creditHash); + const paymentAmount = oldCR.yieldDue; + + // Advance the block timestamp to be after the maturity date and make sure the payment can + // still go through. + await setNextBlockTimestamp(maturityDate + 1); + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOfWithReceivable( + borrower.getAddress(), + receivableId, + paymentAmount, + ), + ) + .to.emit(creditContract, "PaymentMade") + .withArgs( + await borrower.getAddress(), + await poolOwnerTreasury.getAddress(), + paymentAmount, + oldCR.yieldDue, + 0, + 0, + 0, + 0, + 0, + await poolOwnerTreasury.getAddress(), + ) + .to.emit(creditContract, "PaymentMadeOnBehalfOfWithReceivable") + .withArgs( + await borrower.getAddress(), + receivableId, + paymentAmount, + await poolOwnerTreasury.getAddress(), + ); + + const actualCR = await creditContract.getCreditRecord(creditHash); + const expectedCR = { + ...oldCR, + ...{ + nextDue: oldCR.nextDue.sub(oldCR.yieldDue), + yieldDue: 0, + }, + }; + checkCreditRecordsMatch(actualCR, expectedCR); + + const actualDD = await creditContract.getDueDetail(creditHash); + const expectedDD = { + ...oldDD, + ...{ + paid: paymentAmount, + }, + }; + checkDueDetailsMatch(actualDD, expectedDD); + }); + + it("Should allow the Pool Owner Treasury to make payment even if the receivable is not just minted", async function () { + const oldCR = await creditContract.getCreditRecord(creditHash); + const oldDD = await creditContract.getDueDetail(creditHash); + const paymentAmount = oldCR.yieldDue; + // Declare payment on the receivable so it's partially paid. + await receivableContract + .connect(borrower) + .declarePayment(receivableId, toToken(1)); + + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOfWithReceivable( + borrower.getAddress(), + receivableId, + paymentAmount, + ), + ) + .to.emit(creditContract, "PaymentMade") + .withArgs( + await borrower.getAddress(), + await poolOwnerTreasury.getAddress(), + paymentAmount, + oldCR.yieldDue, + 0, + 0, + 0, + 0, + 0, + await poolOwnerTreasury.getAddress(), + ) + .to.emit(creditContract, "PaymentMadeOnBehalfOfWithReceivable") + .withArgs( + await borrower.getAddress(), + receivableId, + paymentAmount, + await poolOwnerTreasury.getAddress(), + ); + + const actualCR = await creditContract.getCreditRecord(creditHash); + const expectedCR = { + ...oldCR, + ...{ + nextDue: oldCR.nextDue.sub(oldCR.yieldDue), + yieldDue: 0, + }, + }; + checkCreditRecordsMatch(actualCR, expectedCR); + + const actualDD = await creditContract.getDueDetail(creditHash); + const expectedDD = { + ...oldDD, + ...{ + paid: paymentAmount, + }, + }; + checkDueDetailsMatch(actualDD, expectedDD); + }); + + it("Should not allow payment when the protocol is paused or the pool is not on", async function () { + await humaConfigContract.connect(protocolOwner).pause(); + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOfWithReceivable( + borrower.getAddress(), + receivableId, + borrowAmount, + ), + ).to.be.revertedWithCustomError(poolConfigContract, "ProtocolIsPaused"); + await humaConfigContract.connect(protocolOwner).unpause(); + + await poolContract.connect(poolOwner).disablePool(); + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOfWithReceivable( + borrower.getAddress(), + receivableId, + borrowAmount, + ), + ).to.be.revertedWithCustomError(poolConfigContract, "PoolIsNotOn"); + await poolContract.connect(poolOwner).enablePool(); + }); + + it("Should not allow payment by non-Pool Owner Treasury", async function () { + await expect( + creditContract + .connect(borrower) + .makePaymentOnBehalfOfWithReceivable( + borrower.getAddress(), + receivableId, + borrowAmount, + ), + ).to.be.revertedWithCustomError(creditContract, "PoolOwnerTreasuryRequired"); + }); + + it("Should not allow payment with 0 receivable ID", async function () { + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOfWithReceivable( + borrower.getAddress(), + 0, + borrowAmount, + ), + ).to.be.revertedWithCustomError(creditContract, "ZeroReceivableIdProvided"); + }); + + it("Should not allow payment if the receivable wasn't transferred to the contract", async function () { + // Create another receivable that wasn't used for drawdown, hence not transferred to the contract. + await receivableContract + .connect(borrower) + .createReceivable(2, borrowAmount, maturityDate, "", ""); + const balance = await receivableContract.balanceOf(borrower.getAddress()); + expect(balance).to.equal(1); + const receivableId2 = await receivableContract.tokenOfOwnerByIndex( + borrower.getAddress(), + 0, + ); + await receivableContract + .connect(borrower) + .approve(creditContract.address, receivableId2); + await creditManagerContract + .connect(evaluationAgent) + .approveReceivable(borrower.getAddress(), receivableId2); + + await expect( + creditContract + .connect(poolOwnerTreasury) + .makePaymentOnBehalfOfWithReceivable( + borrower.getAddress(), + receivableId2, + borrowAmount, + ), + ).to.be.revertedWithCustomError(creditContract, "ReceivableOwnerRequired"); + + await receivableContract.connect(borrower).burn(receivableId2); + }); + }); + }); + describe("makePrincipalPaymentWithReceivable", function () { const yieldInBps = 1217, principalRate = 100,