diff --git a/contracts/PoolSafe.sol b/contracts/PoolSafe.sol index b2904d99..7be9d79f 100644 --- a/contracts/PoolSafe.sol +++ b/contracts/PoolSafe.sol @@ -42,7 +42,7 @@ contract PoolSafe is PoolConfigCache, IPoolSafe { /// @inheritdoc IPoolSafe function deposit(address from, uint256 amount) external virtual { - _onlyCustodian(msg.sender); + _onlySystemMoneyMover(msg.sender); underlyingToken.safeTransferFrom(from, address(this), amount); } @@ -50,7 +50,7 @@ contract PoolSafe is PoolConfigCache, IPoolSafe { /// @inheritdoc IPoolSafe function withdraw(address to, uint256 amount) external virtual { if (to == address(0)) revert Errors.zeroAddressProvided(); - _onlyCustodian(msg.sender); + _onlySystemMoneyMover(msg.sender); underlyingToken.safeTransfer(to, amount); } @@ -100,7 +100,7 @@ contract PoolSafe is PoolConfigCache, IPoolSafe { availableBalance = balance > reserved ? balance - reserved : 0; } - function _onlyCustodian(address account) internal view { + function _onlySystemMoneyMover(address account) internal view { if ( account != poolConfig.seniorTranche() && account != poolConfig.juniorTranche() && diff --git a/contracts/credit/CreditLine.sol b/contracts/credit/CreditLine.sol index ab3b5ad9..64276c73 100644 --- a/contracts/credit/CreditLine.sol +++ b/contracts/credit/CreditLine.sol @@ -32,7 +32,6 @@ contract CreditLine is Credit, ICreditLine { function drawdown(address borrower, uint256 borrowAmount) external virtual override { poolConfig.onlyProtocolAndPoolOn(); if (borrower != msg.sender) revert Errors.notBorrower(); - if (borrowAmount == 0) revert Errors.zeroAmountProvided(); bytes32 creditHash = getCreditHash(borrower); creditManager.onlyCreditBorrower(creditHash, borrower); diff --git a/contracts/credit/Receivable.sol b/contracts/credit/Receivable.sol index 1c2199e7..1cd4f697 100644 --- a/contracts/credit/Receivable.sol +++ b/contracts/credit/Receivable.sol @@ -6,7 +6,6 @@ import {ERC721EnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/t import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; import {ERC721BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.sol"; import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {CountersUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/CountersUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Errors} from "../Errors.sol"; @@ -21,7 +20,6 @@ import {ReceivableInfo, ReceivableState} from "./CreditStructs.sol"; contract Receivable is IReceivable, ReceivableStorage, - Initializable, ERC721Upgradeable, ERC721EnumerableUpgradeable, ERC721URIStorageUpgradeable, @@ -61,6 +59,20 @@ contract Receivable is uint16 currencyCode ); + /** + * @dev Emitted when a receivable metadata URI is updated + * @param owner The address of the owner of the receivable + * @param tokenId The ID of the newly created receivable update token + * @param oldTokenURI The old metadata URI of the receivable + * @param newTokenURI The new metadata URI of the receivable + */ + event ReceivableMetadataUpdated( + address indexed owner, + uint256 indexed tokenId, + string oldTokenURI, + string newTokenURI + ); + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { // _disableInitializers(); @@ -90,8 +102,7 @@ contract Receivable is uint64 maturityDate, string memory uri ) public onlyRole(MINTER_ROLE) returns (uint256 tokenId) { - tokenId = _tokenIdCounter.current(); - _tokenIdCounter.increment(); + tokenId = _getNewTokenId(); _safeMint(msg.sender, tokenId); receivableInfoMap[tokenId] = ReceivableInfo( @@ -128,6 +139,22 @@ contract Receivable is emit PaymentDeclared(msg.sender, tokenId, receivableInfo.currencyCode, paymentAmount); } + /** + * @notice Updates the metadata URI of a receivable + * @custom:access Only the owner or the original creator of the token can update the metadata URI + * @param tokenId The ID of the receivable token + * @param uri The new metadata URI of the receivable + */ + function updateReceivableMetadata(uint256 tokenId, string memory uri) external { + if (msg.sender != ownerOf(tokenId) && msg.sender != creators[tokenId]) + revert Errors.notReceivableOwnerOrCreator(); + + string memory oldTokenURI = tokenURI(tokenId); + _setTokenURI(tokenId, uri); + + emit ReceivableMetadataUpdated(msg.sender, tokenId, oldTokenURI, uri); + } + /// @inheritdoc IReceivable function getReceivable( uint256 tokenId @@ -161,6 +188,13 @@ contract Receivable is super._burn(tokenId); } + function _getNewTokenId() internal returns (uint256) { + // Increment the counter first before assigning a new ID so that the ID starts at 1 + // instead of 0. + _tokenIdCounter.increment(); + return _tokenIdCounter.current(); + } + function tokenURI( uint256 tokenId ) diff --git a/contracts/credit/ReceivableBackedCreditLine.sol b/contracts/credit/ReceivableBackedCreditLine.sol index a295a618..d7ec8ecb 100644 --- a/contracts/credit/ReceivableBackedCreditLine.sol +++ b/contracts/credit/ReceivableBackedCreditLine.sol @@ -137,11 +137,18 @@ contract ReceivableBackedCreditLine is Credit, IERC721Receiver { bytes32 creditHash = getCreditHash(borrower); creditManager.onlyCreditBorrower(creditHash, borrower); - if (getCreditRecord(creditHash).state != CreditState.GoodStanding) + CreditRecord memory cr = getCreditRecord(creditHash); + if (cr.state != CreditState.GoodStanding) revert Errors.creditLineNotInStateForMakingPrincipalPayment(); if (drawdownAmount == 0 || paymentAmount == 0) revert Errors.zeroAmountProvided(); + uint256 principalOutstanding = cr.unbilledPrincipal + cr.nextDue - cr.yieldDue; + if (principalOutstanding == 0) { + // No principal payment is needed when there is no principal outstanding. + return (0, false); + } + IERC721 receivableAsset = IERC721(poolConfig.receivableAsset()); _prepareForPayment(borrower, receivableAsset, paymentReceivableId); _prepareForDrawdown( @@ -152,7 +159,6 @@ contract ReceivableBackedCreditLine is Credit, IERC721Receiver { drawdownAmount ); - // TODO(jiatu): What if there is no principal in the first place? if (paymentAmount == drawdownAmount) { poolSafe.deposit(msg.sender, paymentAmount); poolSafe.withdraw(borrower, paymentAmount); @@ -216,9 +222,10 @@ contract ReceivableBackedCreditLine is Credit, IERC721Receiver { ReceivableInput memory receivableInput, uint256 amount ) internal { - // TODO: Check amount < receivable amount? if (receivableInput.receivableAmount == 0) revert Errors.zeroAmountProvided(); if (receivableInput.receivableId == 0) revert Errors.zeroReceivableIdProvided(); + if (amount > receivableInput.receivableAmount) + revert Errors.insufficientReceivableAmount(); if (receivableAsset.ownerOf(receivableInput.receivableId) != borrower) revert Errors.notReceivableOwner(); diff --git a/contracts/credit/ReceivableFactoringCredit.sol b/contracts/credit/ReceivableFactoringCredit.sol index c8f4053f..40e6e4a1 100644 --- a/contracts/credit/ReceivableFactoringCredit.sol +++ b/contracts/credit/ReceivableFactoringCredit.sol @@ -22,14 +22,14 @@ contract ReceivableFactoringCredit is event ExtraFundsDispersed(address indexed receiver, uint256 amount); - event DrawdownWithReceivableMade( + event DrawdownMadeWithReceivable( address indexed borrower, uint256 indexed receivableId, uint256 amount, address by ); - event PaymentWithReceivableMade( + event PaymentMadeWithReceivable( address indexed borrower, uint256 indexed receivableId, uint256 amount, @@ -73,7 +73,7 @@ contract ReceivableFactoringCredit is _drawdown(borrower, creditHash, amount); - emit DrawdownWithReceivableMade(borrower, receivableId, amount, msg.sender); + emit DrawdownMadeWithReceivable(borrower, receivableId, amount, msg.sender); } /// @inheritdoc IReceivableFactoringCredit @@ -94,7 +94,7 @@ contract ReceivableFactoringCredit is revert Errors.notReceivableOwner(); (amountPaid, paidoff) = _makePaymentWithReceivable(borrower, creditHash, amount); - emit PaymentWithReceivableMade(borrower, receivableId, amount, msg.sender); + emit PaymentMadeWithReceivable(borrower, receivableId, amount, msg.sender); } /// TODO(jiatu): rename this? @@ -118,7 +118,7 @@ contract ReceivableFactoringCredit is ); (amountPaid, paidoff) = _makePaymentWithReceivable(borrower, creditHash, amount); - emit PaymentWithReceivableMade(borrower, receivableId, amount, msg.sender); + emit PaymentMadeWithReceivable(borrower, receivableId, amount, msg.sender); } function _makePaymentWithReceivable( diff --git a/contracts/credit/utils/CreditDueManager.sol b/contracts/credit/utils/CreditDueManager.sol index e788cbc4..4f82fdf4 100644 --- a/contracts/credit/utils/CreditDueManager.sol +++ b/contracts/credit/utils/CreditDueManager.sol @@ -16,7 +16,7 @@ contract CreditDueManager is PoolConfigCache, ICreditDueManager { function _updatePoolConfigData(PoolConfig _poolConfig) internal virtual override { address addr = _poolConfig.calendar(); - if (addr == address(0)) revert Errors.zeroAddressProvided(); + assert(addr != address(0)); calendar = ICalendar(addr); } @@ -261,9 +261,6 @@ contract CreditDueManager is PoolConfigCache, ICreditDueManager { uint256 principal, uint256 daysPassed ) internal pure returns (uint96 accrued, uint96 committed) { - if (daysPassed == 0) { - return (0, 0); - } accrued = computeYieldDue(principal, cc.yieldInBps, daysPassed); committed = computeYieldDue(cc.committedAmount, cc.yieldInBps, daysPassed); return (accrued, committed); diff --git a/test/credit/CreditLineTest.ts b/test/credit/CreditLineTest.ts index 017a3451..a5b74955 100644 --- a/test/credit/CreditLineTest.ts +++ b/test/credit/CreditLineTest.ts @@ -7609,7 +7609,7 @@ describe("CreditLine Test", function () { }); }); - it("Should not allow payment when the protocol is paused or pool is not on", async function () { + 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.makePayment(borrower.getAddress(), toToken(1)), @@ -8951,18 +8951,21 @@ describe("CreditLine Test", function () { }); it("Should not allow the borrower to close a credit that has outstanding unbilled principal", async function () { + // Close the approved credit then open a new one with a different committed amount. + await creditManagerContract.connect(borrower).closeCredit(borrower.getAddress()); const amount = toToken(1_000); + await approveCredit(3, toToken(100_000)); await creditContract.connect(borrower).drawdown(borrower.getAddress(), amount); // Only pay back the yield next due and have principal due outstanding. const oldCR = await creditContract.getCreditRecord(creditHash); await creditContract .connect(borrower) - .makePayment(borrower.getAddress(), oldCR.yieldDue); + .makePayment(borrower.getAddress(), oldCR.nextDue); const newCR = await creditContract.getCreditRecord(creditHash); - expect(newCR.nextDue.sub(oldCR.yieldDue)).to.be.gt(0); + expect(newCR.nextDue).to.equal(0); expect(newCR.totalPastDue).to.equal(0); - expect(newCR.unbilledPrincipal).to.equal(0); + expect(newCR.unbilledPrincipal).to.be.gt(0); await testCloseCreditReversion(borrower, "creditLineHasOutstandingBalance"); }); @@ -9186,9 +9189,12 @@ describe("CreditLine Test", function () { it("Should not allow extension on a credit line that becomes delayed after refresh", async function () { await creditContract.connect(borrower).drawdown(borrower.address, toToken(5_000)); const oldCR = await creditContract.getCreditRecord(creditHash); + // All principal and yield is due in the first period since there is only 1 period, + // so pay slightly less than the amount next due so that the bill can become past due + // when refreshed. await creditContract .connect(borrower) - .makePayment(borrower.getAddress(), oldCR.nextDue); + .makePayment(borrower.getAddress(), oldCR.nextDue.sub(toToken(1))); const extensionDate = oldCR.nextDueDate.toNumber() + diff --git a/test/credit/ReceivableBackedCreditLineTest.ts b/test/credit/ReceivableBackedCreditLineTest.ts index b5b32cce..a5a461ed 100644 --- a/test/credit/ReceivableBackedCreditLineTest.ts +++ b/test/credit/ReceivableBackedCreditLineTest.ts @@ -220,8 +220,6 @@ describe("ReceivableBackedCreditLine Tests", function () { const currentTS = (await getLatestBlock()).timestamp; const maturityDate = currentTS + CONSTANTS.SECONDS_IN_A_DAY * CONSTANTS.DAYS_IN_A_MONTH; - // Throw-away receivable to get around the tokenId != 0 check. - await receivableContract.connect(poolOwner).createReceivable(1, 0, 0, ""); await receivableContract .connect(borrower) .createReceivable(1, borrowAmount, maturityDate, ""); @@ -330,8 +328,6 @@ describe("ReceivableBackedCreditLine Tests", function () { const currentTS = (await getLatestBlock()).timestamp; maturityDate = currentTS + CONSTANTS.SECONDS_IN_A_DAY * CONSTANTS.DAYS_IN_A_MONTH; - // Throw-away receivable to get around the tokenId != 0 check. - await receivableContract.connect(poolOwner).createReceivable(1, 0, 0, ""); await receivableContract .connect(borrower) .createReceivable(1, borrowAmount, maturityDate, ""); @@ -514,6 +510,19 @@ describe("ReceivableBackedCreditLine Tests", function () { ).to.be.revertedWithCustomError(creditContract, "zeroReceivableIdProvided"); }); + it("Should not allow drawdown if the amount exceeds the receivable amount", async function () { + await expect( + creditContract.connect(borrower).drawdownWithReceivable( + borrower.getAddress(), + { + receivableAmount: borrowAmount.sub(toToken(1)), + receivableId: tokenId, + }, + borrowAmount, + ), + ).to.be.revertedWithCustomError(creditContract, "insufficientReceivableAmount"); + }); + it("Should not allow drawdown with 0 borrow amount", async function () { await expect( creditContract.connect(borrower).drawdownWithReceivable( @@ -610,8 +619,6 @@ describe("ReceivableBackedCreditLine Tests", function () { const currentTS = (await getLatestBlock()).timestamp; maturityDate = currentTS + CONSTANTS.SECONDS_IN_A_DAY * CONSTANTS.DAYS_IN_A_MONTH; - // Throw-away receivable to get around the tokenId != 0 check. - await receivableContract.connect(poolOwner).createReceivable(1, 0, 0, ""); await receivableContract .connect(borrower) .createReceivable(1, borrowAmount, maturityDate, ""); @@ -711,7 +718,7 @@ describe("ReceivableBackedCreditLine Tests", function () { checkDueDetailsMatch(actualDD, expectedDD); }); - it("Should not allow payment when the protocol is paused", async function () { + 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 @@ -719,6 +726,14 @@ describe("ReceivableBackedCreditLine Tests", function () { .makePaymentWithReceivable(borrower.getAddress(), tokenId, borrowAmount), ).to.be.revertedWithCustomError(poolConfigContract, "protocolIsPaused"); await humaConfigContract.connect(protocolOwner).unpause(); + + await poolContract.connect(poolOwner).disablePool(); + await expect( + creditContract + .connect(borrower) + .makePaymentWithReceivable(borrower.getAddress(), tokenId, borrowAmount), + ).to.be.revertedWithCustomError(poolConfigContract, "poolIsNotOn"); + await poolContract.connect(poolOwner).enablePool(); }); it("Should not allow payment by non-borrower or non-PDS account", async function () { @@ -804,8 +819,6 @@ describe("ReceivableBackedCreditLine Tests", function () { const currentTS = (await getLatestBlock()).timestamp; maturityDate = currentTS + CONSTANTS.SECONDS_IN_A_DAY * CONSTANTS.DAYS_IN_A_MONTH; - // Throw-away receivable to get around the tokenId != 0 check. - await receivableContract.connect(poolOwner).createReceivable(1, 0, 0, ""); await receivableContract .connect(borrower) .createReceivable(1, borrowAmount, maturityDate, ""); @@ -999,7 +1012,9 @@ describe("ReceivableBackedCreditLine Tests", function () { lateFeeBps = 2400; const numOfPeriods = 3, latePaymentGracePeriodInDays = 5; - let paymentReceivableMaturityDate: number, drawdownReceivableMaturityDate: number; + let paymentReceivableMaturityDate: number, + drawdownReceivableMaturityDate: number, + designatedStartDate: number; let paymentAmount: BN, drawdownAmount: BN, paymentTokenId: BN, drawdownTokenId: BN; let creditHash: string; @@ -1028,8 +1043,6 @@ describe("ReceivableBackedCreditLine Tests", function () { drawdownReceivableMaturityDate = paymentReceivableMaturityDate + CONSTANTS.SECONDS_IN_A_DAY * CONSTANTS.DAYS_IN_A_MONTH; - // Throw-away receivable to get around the tokenId != 0 check. - await receivableContract.connect(poolOwner).createReceivable(1, 0, 0, ""); // Create two receivables, one ce payment and another one for drawdown. await receivableContract .connect(borrower) @@ -1059,6 +1072,7 @@ describe("ReceivableBackedCreditLine Tests", function () { } async function approveBorrower() { + designatedStartDate = await getFutureBlockTime(2); await creditManagerContract .connect(eaServiceAccount) .approveBorrower( @@ -1066,8 +1080,8 @@ describe("ReceivableBackedCreditLine Tests", function () { toToken(100_000), numOfPeriods, yieldInBps, - 0, - 0, + toToken(5_000), + designatedStartDate, true, ); } @@ -1148,6 +1162,57 @@ describe("ReceivableBackedCreditLine Tests", function () { }); }); + describe("With credit approval but no initial drawdown", function () { + beforeEach(async function () { + await loadFixture(approveBorrower); + }); + + it("Should allow a no-op payment", async function () { + // Start committed credit so that there is commitment outstanding, but no principal outstanding. + await creditManagerContract + .connect(poolOwner) + .startCommittedCredit(borrower.getAddress()); + + const oldCR = await creditContract.getCreditRecord(creditHash); + const oldDD = await creditContract.getDueDetail(creditHash); + drawdownAmount = paymentAmount; + + const borrowerBalanceBefore = await mockTokenContract.balanceOf( + borrower.getAddress(), + ); + const poolSafeBalanceBefore = await mockTokenContract.balanceOf( + poolSafeContract.address, + ); + await expect( + creditContract + .connect(borrower) + .makePrincipalPaymentAndDrawdownWithReceivable( + borrower.getAddress(), + paymentTokenId, + paymentAmount, + { receivableAmount: paymentAmount, receivableId: drawdownTokenId }, + paymentAmount, + ), + ) + .not.to.emit(creditContract, "PrincipalPaymentMadeWithReceivable") + .not.to.emit(creditContract, "DrawdownMadeWithReceivable"); + const borrowerBalanceAfter = await mockTokenContract.balanceOf( + borrower.getAddress(), + ); + expect(borrowerBalanceBefore.sub(borrowerBalanceAfter)).to.equal(0); + const poolSafeBalanceAfter = await mockTokenContract.balanceOf( + poolSafeContract.address, + ); + expect(poolSafeBalanceAfter.sub(poolSafeBalanceBefore)).to.equal(0); + + const actualCR = await creditContract.getCreditRecord(creditHash); + checkCreditRecordsMatch(actualCR, oldCR); + + const actualDD = await creditContract.getDueDetail(creditHash); + checkDueDetailsMatch(actualDD, oldDD); + }); + }); + describe("With credit approval and initial drawdown", function () { beforeEach(async function () { await loadFixture(approveBorrower); diff --git a/test/credit/ReceivableFactoringCreditTest.ts b/test/credit/ReceivableFactoringCreditTest.ts index 5a9a7f85..39ebda2e 100644 --- a/test/credit/ReceivableFactoringCreditTest.ts +++ b/test/credit/ReceivableFactoringCreditTest.ts @@ -353,7 +353,7 @@ describe("ReceivableFactoringCredit Tests", function () { ) .to.emit(creditContract, "DrawdownMade") .withArgs(await borrower.getAddress(), borrowAmount, netBorrowAmount) - .to.emit(creditContract, "DrawdownWithReceivableMade") + .to.emit(creditContract, "DrawdownMadeWithReceivable") .withArgs( await borrower.getAddress(), tokenId, @@ -552,7 +552,7 @@ describe("ReceivableFactoringCredit Tests", function () { 0, await borrower.getAddress(), ) - .to.emit(creditContract, "PaymentWithReceivableMade") + .to.emit(creditContract, "PaymentMadeWithReceivable") .withArgs( await borrower.getAddress(), tokenId, @@ -580,7 +580,7 @@ describe("ReceivableFactoringCredit Tests", function () { checkDueDetailsMatch(actualDD, expectedDD); }); - it("Should not allow payment when the protocol is paused", async function () { + 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 @@ -588,6 +588,14 @@ describe("ReceivableFactoringCredit Tests", function () { .makePaymentWithReceivable(borrower.getAddress(), tokenId, borrowAmount), ).to.be.revertedWithCustomError(poolConfigContract, "protocolIsPaused"); await humaConfigContract.connect(protocolOwner).unpause(); + + await poolContract.connect(poolOwner).disablePool(); + await expect( + creditContract + .connect(borrower) + .makePaymentWithReceivable(borrower.getAddress(), tokenId, borrowAmount), + ).to.be.revertedWithCustomError(poolConfigContract, "poolIsNotOn"); + await poolContract.connect(poolOwner).enablePool(); }); it("Should not allow payment by non-borrower", async function () { @@ -715,7 +723,7 @@ describe("ReceivableFactoringCredit Tests", function () { 0, await payer.getAddress(), ) - .to.emit(creditContract, "PaymentWithReceivableMade") + .to.emit(creditContract, "PaymentMadeWithReceivable") .withArgs( await borrower.getAddress(), tokenId, @@ -743,7 +751,7 @@ describe("ReceivableFactoringCredit Tests", function () { checkDueDetailsMatch(actualDD, expectedDD); }); - it("Should not allow payment when the protocol is paused", async function () { + 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 @@ -751,6 +759,14 @@ describe("ReceivableFactoringCredit Tests", function () { .makePaymentWithReceivableForContract(tokenId, borrowAmount), ).to.be.revertedWithCustomError(poolConfigContract, "protocolIsPaused"); await humaConfigContract.connect(protocolOwner).unpause(); + + await poolContract.connect(poolOwner).disablePool(); + await expect( + creditContract + .connect(payer) + .makePaymentWithReceivableForContract(tokenId, borrowAmount), + ).to.be.revertedWithCustomError(poolConfigContract, "poolIsNotOn"); + await poolContract.connect(poolOwner).enablePool(); }); it("Should not allow payment by non-payer", async function () { diff --git a/test/credit/ReceivableTest.ts b/test/credit/ReceivableTest.ts index e9b60f8e..aa53c38c 100644 --- a/test/credit/ReceivableTest.ts +++ b/test/credit/ReceivableTest.ts @@ -248,6 +248,45 @@ describe("Receivable Test", function () { }); }); + describe("updateReceivableMetadata", function () { + it("Should emit a ReceivableMetadataUpdated event when creating a receivable update", async function () { + const tokenId = await receivableContract.tokenOfOwnerByIndex(borrower.address, 0); + await expect( + receivableContract.connect(borrower).updateReceivableMetadata(tokenId, "uri2"), + ).to.emit(receivableContract, "ReceivableMetadataUpdated"); + }); + + it("Should not allow for updates to be created for non-existant receivable", async function () { + await expect( + receivableContract.connect(borrower).updateReceivableMetadata(123, "uri2"), + ).to.be.revertedWith("ERC721: invalid token ID"); + }); + + it("Should allow the creator to create a receivable update even after the original receivable has been transferred", async function () { + const tokenId = await receivableContract.tokenOfOwnerByIndex(borrower.address, 0); + await receivableContract + .connect(borrower) + ["safeTransferFrom(address,address,uint256)"]( + borrower.address, + lender.address, + tokenId, + ); + + await receivableContract.connect(borrower).updateReceivableMetadata(tokenId, "uri2"); + + const tokenURI = await receivableContract.tokenURI(tokenId); + expect(tokenURI).to.equal("uri2"); + }); + + it("Should not allow a non-owner and non-creator to create a receivable update", async function () { + const tokenId = await receivableContract.tokenOfOwnerByIndex(borrower.address, 0); + + await expect( + receivableContract.connect(poolOwner).updateReceivableMetadata(tokenId, "uri2"), + ).to.be.revertedWithCustomError(receivableContract, "notReceivableOwnerOrCreator"); + }); + }); + describe("getStatus", function () { it("Should return the correct status if a receivable is unpaid", async function () { const tokenId = await receivableContract.tokenOfOwnerByIndex(borrower.address, 0); diff --git a/test/integration/credit/ReceivableBackedCreditLineIntegrationTest.ts b/test/integration/credit/ReceivableBackedCreditLineIntegrationTest.ts index 4ab470dd..00541762 100644 --- a/test/integration/credit/ReceivableBackedCreditLineIntegrationTest.ts +++ b/test/integration/credit/ReceivableBackedCreditLineIntegrationTest.ts @@ -267,7 +267,6 @@ describe("ReceivableBackedCreditLine Integration Test", function () { }); it("Month1 - Day1 ~ Day5: drawdown in the first week", async function () { - await receivableContract.connect(poolOwner).createReceivable(1, 0, 0, ""); let block = await getLatestBlock(); nextTime = ( diff --git a/test/integration/credit/ReceivableFactoringCreditIntegrationTest.ts b/test/integration/credit/ReceivableFactoringCreditIntegrationTest.ts index 614faa90..73827181 100644 --- a/test/integration/credit/ReceivableFactoringCreditIntegrationTest.ts +++ b/test/integration/credit/ReceivableFactoringCreditIntegrationTest.ts @@ -22,10 +22,14 @@ import { } from "../../../typechain-types"; import { CONSTANTS, + CreditState, PayPeriodDuration, + calcYield, + checkCreditRecordsMatch, + checkDueDetailsMatch, deployAndSetupPoolContracts, deployProtocolContracts, - printCreditRecord, + genDueDetail, } from "../../BaseTest"; import { evmRevert, @@ -163,17 +167,16 @@ describe("ReceivableFactoringCredit Integration Tests", function () { describe("Bulla case tests", function () { let creditHash: string; - let borrowAmount: BN, paymentAmount: BN; - let creditLimit: BN; + let borrowAmount: BN, creditLimit: BN; const yieldInBps = 1200; const lateFeeBps = 2400; const principalRate = 0; - const lateGracePeriodInDays = 5; + const latePaymentGracePeriodInDays = 5; let tokenId: BN; + let nextTimestamp: number; async function prepareForBullaTests() { borrowAmount = toToken(1_000_000); - paymentAmount = borrowAmount; creditLimit = borrowAmount .mul(5) .mul(CONSTANTS.BP_FACTOR.add(500)) @@ -184,7 +187,7 @@ describe("ReceivableFactoringCredit Integration Tests", function () { ...settings, ...{ payPeriodDuration: PayPeriodDuration.Monthly, - latePaymentGracePeriodInDays: lateGracePeriodInDays, + latePaymentGracePeriodInDays, }, }); @@ -214,8 +217,7 @@ describe("ReceivableFactoringCredit Integration Tests", function () { } }); - let nextTime: number; - it("approve borrower credit", async function () { + it("Approves borrower credit", async function () { await creditManagerContract .connect(eaServiceAccount) .approveReceivable( @@ -227,7 +229,7 @@ describe("ReceivableFactoringCredit Integration Tests", function () { ); }); - it("payee draws down with receivable", async function () { + it("Payee draws down with receivable", async function () { await nftContract.connect(borrower).approve(creditContract.address, tokenId); await creditContract @@ -235,33 +237,90 @@ describe("ReceivableFactoringCredit Integration Tests", function () { .drawdownWithReceivable(borrower.address, tokenId, borrowAmount); }); - it("payee pays for half of the amount due", async function () { - let cr = await creditContract["getCreditRecord(bytes32)"](creditHash); - printCreditRecord("cr", cr); - + it("Payee pays for the yield due due", async function () { + const oldCR = await creditContract["getCreditRecord(bytes32)"](creditHash); await creditContract .connect(borrower) - .makePaymentWithReceivable(borrower.address, tokenId, cr.nextDue.div(2)); + .makePaymentWithReceivable(borrower.address, tokenId, oldCR.yieldDue); + + const actualCR = await creditContract["getCreditRecord(bytes32)"](creditHash); + const expectedCR = { + unbilledPrincipal: 0, + nextDueDate: oldCR.nextDueDate, + nextDue: borrowAmount, + yieldDue: 0, + totalPastDue: 0, + missedPeriods: 0, + remainingPeriods: 0, + state: CreditState.GoodStanding, + }; + checkCreditRecordsMatch(actualCR, expectedCR); }); - it("refresh credit after late payment grace period", async function () { - let cr = await creditContract["getCreditRecord(bytes32)"](creditHash); - nextTime = - cr.nextDueDate.toNumber() + - CONSTANTS.SECONDS_IN_A_DAY * lateGracePeriodInDays + + it("Refreshes credit after late payment grace period", async function () { + const oldCR = await creditContract["getCreditRecord(bytes32)"](creditHash); + nextTimestamp = + oldCR.nextDueDate.toNumber() + + CONSTANTS.SECONDS_IN_A_DAY * latePaymentGracePeriodInDays + 100; - await setNextBlockTimestamp(nextTime); + await setNextBlockTimestamp(nextTimestamp); + + await creditManagerContract.refreshCredit(tokenId); - await creditManagerContract.refreshCredit(borrower.address); - cr = await creditContract["getCreditRecord(bytes32)"](creditHash); - printCreditRecord("cr", cr); + const cc = await creditManagerContract.getCreditConfig(creditHash); + const nextDueDate = await calendarContract.getStartDateOfNextPeriod( + cc.periodDuration, + nextTimestamp, + ); + const lateFeeUpdatedDate = await calendarContract.getStartOfNextDay(nextTimestamp); + const daysPassed = await calendarContract.getDaysDiff( + oldCR.nextDueDate, + lateFeeUpdatedDate, + ); + const lateFee = calcYield(borrowAmount, lateFeeBps, daysPassed.toNumber()); + const actualCR = await creditContract["getCreditRecord(bytes32)"](creditHash); + const expectedCR = { + unbilledPrincipal: 0, + nextDueDate, + nextDue: 0, + yieldDue: 0, + totalPastDue: borrowAmount.add(lateFee), + missedPeriods: 1, + remainingPeriods: 0, + state: CreditState.Delayed, + }; + checkCreditRecordsMatch(actualCR, expectedCR); + + const actualDD = await creditContract.getDueDetail(creditHash); + const expectedDD = genDueDetail({ + lateFeeUpdatedDate, + lateFee, + principalPastDue: borrowAmount, + }); + checkDueDetailsMatch(actualDD, expectedDD); }); - it("payer pays the receivable", async function () { + it("Payer pays for the receivable in full", async function () { + const oldCR = await creditContract["getCreditRecord(bytes32)"](creditHash); + await nftContract.connect(payer).payOwner(tokenId, creditLimit); - let cr = await creditContract["getCreditRecord(bytes32)"](creditHash); - printCreditRecord("cr", cr); + const actualCR = await creditContract["getCreditRecord(bytes32)"](creditHash); + const expectedCR = { + unbilledPrincipal: 0, + nextDueDate: oldCR.nextDueDate, + nextDue: 0, + yieldDue: 0, + totalPastDue: 0, + missedPeriods: 0, + remainingPeriods: 0, + state: CreditState.Deleted, + }; + checkCreditRecordsMatch(actualCR, expectedCR); + + const actualDD = await creditContract.getDueDetail(creditHash); + const expectedDD = genDueDetail({}); + checkDueDetailsMatch(actualDD, expectedDD); }); }); });