From 5dbf2b8a3744deb4a4dc7280aa58fc5031c7d2b8 Mon Sep 17 00:00:00 2001 From: divicbold47 Date: Fri, 19 Jun 2026 12:24:59 +0000 Subject: [PATCH] feat(creditline): add vendor payment on loan approval --- contracts/creditline-contract/src/errors.rs | 3 +- contracts/creditline-contract/src/events.rs | 8 +++ contracts/creditline-contract/src/lib.rs | 59 ++++++++++++++-- contracts/creditline-contract/src/tests.rs | 75 ++++++++++++++++++++- contracts/creditline-contract/src/types.rs | 1 + 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/contracts/creditline-contract/src/errors.rs b/contracts/creditline-contract/src/errors.rs index a639875..ef5274f 100644 --- a/contracts/creditline-contract/src/errors.rs +++ b/contracts/creditline-contract/src/errors.rs @@ -30,5 +30,6 @@ pub enum CreditLineError { InvalidInstallmentIndex = 23, InstallmentAlreadyPaid = 24, InvalidLoanStatus = 25, - NotInitialized = 26, + VendorAlreadyPaid = 26, + NotInitialized = 27, } diff --git a/contracts/creditline-contract/src/events.rs b/contracts/creditline-contract/src/events.rs index dd2f17a..b63b18d 100644 --- a/contracts/creditline-contract/src/events.rs +++ b/contracts/creditline-contract/src/events.rs @@ -11,6 +11,7 @@ const LOAN_CANCELLED: Symbol = symbol_short!("LOANCNCL"); const LOAN_LATE_FEE: Symbol = symbol_short!("LOANLTFE"); const LOAN_GRACE_PERIOD: Symbol = symbol_short!("LOANGRC"); const INSTALLMENT_PAID: Symbol = symbol_short!("INSTPAID"); +const VENDOR_PAID: Symbol = symbol_short!("VENDPAID"); pub const LOANAPPROVED: &str = "LOANAPPROVED"; @@ -137,6 +138,13 @@ pub fn emit_installment_paid( ); } +pub fn emit_vendor_paid(env: &Env, loan_id: u64, vendor: &Address, amount: i128) { + env.events().publish( + (VENDOR_PAID, loan_id), + (vendor, amount, env.ledger().timestamp()), + ); +} + /// Emitted when a loan is past its due date but still within the grace period. /// Signals that the borrower can still repay before a hard default is triggered. pub fn emit_loan_in_grace_period( diff --git a/contracts/creditline-contract/src/lib.rs b/contracts/creditline-contract/src/lib.rs index 436bee9..4eee5b0 100644 --- a/contracts/creditline-contract/src/lib.rs +++ b/contracts/creditline-contract/src/lib.rs @@ -88,6 +88,7 @@ impl CreditLineContract { repayment_schedule.clone(), score, LoanStatus::Active, + true, loan_type, )?; loan.funded_at = env.ledger().timestamp(); @@ -136,6 +137,7 @@ impl CreditLineContract { repayment_schedule.clone(), score, LoanStatus::Pending, + false, loan_type, )?; @@ -344,6 +346,7 @@ impl CreditLineContract { score: u32, status: LoanStatus, loan_type: LoanType, + vendor_paid: bool, ) -> Result { Self::validate_guarantee(env, total_amount, guarantee_amount)?; Self::validate_vendor(env, &vendor)?; @@ -383,6 +386,7 @@ impl CreditLineContract { remaining_balance, repayment_schedule, status, + vendor_paid, loan_type, created_at: env.ledger().timestamp(), funded_at: 0, @@ -564,11 +568,14 @@ impl CreditLineContract { panic_with_error!(&env, CreditLineError::InvalidLoanStatus); } - // 4. Transition to Active - loan.status = LoanStatus::Active; - - // 5. Write back with TTL extension + Self::enter_non_reentrant(&env); + Self::pay_vendor_internal(&env, &mut loan) + .unwrap_or_else(|err| { + Self::exit_non_reentrant(&env); + panic_with_error!(&env, err) + }); storage::write_loan(&env, &loan); + Self::exit_non_reentrant(&env); // 6. Emit event events::emit_loan_approved(&env, loan_id); @@ -576,6 +583,50 @@ impl CreditLineContract { loan } + pub fn pay_vendor(env: Env, loan_id: u64) -> Result<(), CreditLineError> { + let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); + admin.require_auth(); + + let mut loan = storage::read_loan(&env, loan_id)?; + if loan.status != LoanStatus::Pending { + return Err(CreditLineError::InvalidLoanStatus); + } + + Self::enter_non_reentrant(&env); + let result = Self::pay_vendor_internal(&env, &mut loan); + if result.is_ok() { + storage::write_loan(&env, &loan); + } + Self::exit_non_reentrant(&env); + result + } + + fn pay_vendor_internal(env: &Env, loan: &mut Loan) -> Result<(), CreditLineError> { + if loan.vendor_paid { + return Err(CreditLineError::VendorAlreadyPaid); + } + + let liquidity_pool = storage::get_liquidity_pool(env)? + .ok_or(CreditLineError::InsufficientLiquidity)?; + let pool_contribution = safe_math::sub_i128(loan.total_amount, loan.guarantee_amount)?; + + if pool_contribution > 0 { + let lp_client = LiquidityPoolContractClient::new(env, &liquidity_pool); + lp_client + .fund_loan(&env.current_contract_address(), &loan.vendor, &pool_contribution) + .unwrap_or_else(|err| panic_with_error!(env, err)); + } + + loan.vendor_paid = true; + loan.status = LoanStatus::Active; + loan.funded_at = env.ledger().timestamp(); + + storage::increase_user_active_debt(env, &loan.borrower, loan.remaining_balance)?; + events::emit_vendor_paid(&env, loan.loan_id, &loan.vendor, pool_contribution); + + Ok(()) + } + pub fn cancel_loan(env: Env, caller: Address, loan_id: u64) { caller.require_auth(); diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index f7d8327..6daaf82 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -12,7 +12,7 @@ use soroban_sdk::{ contract, contractimpl, testutils::{Address as _, Events, Ledger}, token::Client as TokenClient, - Address, Env, String as SorobanString, + Address, Env, String as SorobanString, Symbol, }; use vendor_registry_contract::VendorRegistryContract; @@ -43,8 +43,21 @@ impl MockReputation { #[contract] pub struct MockLiquidityPool; +const MOCK_LP_TOKEN_KEY: Symbol = Symbol::short("LP_TOKEN"); + #[contractimpl] impl MockLiquidityPool { + pub fn set_token(env: Env, token: Address) { + env.storage().instance().set(&MOCK_LP_TOKEN_KEY, &token); + } + + fn get_token(env: &Env) -> Address { + env.storage() + .instance() + .get(&MOCK_LP_TOKEN_KEY) + .unwrap_or_else(|| panic!("Liquidity pool token not configured")) + } + pub fn get_pool_stats(_env: Env) -> PoolStats { PoolStats { total_liquidity: 1_000_000, @@ -55,7 +68,16 @@ impl MockLiquidityPool { } } - pub fn fund_loan(_env: Env, _creditline: Address, _vendor: Address, _amount: i128) {} + pub fn set_token_address(env: Env, token: Address) { + env.storage().instance().set(&MOCK_LP_TOKEN_KEY, &token); + } + + pub fn fund_loan(env: Env, creditline: Address, vendor: Address, amount: i128) { + creditline.require_auth(); + let token_address = Self::get_token(&env); + let token_client = TokenClient::new(&env, &token_address); + token_client.transfer(&env.current_contract_address(), &vendor, &amount); + } pub fn receive_repayment(_env: Env, _from: Address, _amount: i128, _fee: i128) {} @@ -124,6 +146,11 @@ impl TestCtx { let token_id = env .register_stellar_asset_contract_v2(token_admin.clone()) .address(); + + // Seed the liquidity pool so it can fund approved loans in tests. + let asset_client = StellarAssetClient::new(&env, &token_id); + asset_client.mint(&lp_id, &1_000_000); + client.initialize(&admin, &rep_id, &vendor_registry_id, &lp_id, &token_id); TestCtx { @@ -2888,12 +2915,56 @@ fn test_approve_loan_success() { let loan_id = t.create_default_request(&user, &vendor); let pending = t.client.get_loan(&loan_id); assert_eq!(pending.status, LoanStatus::Pending); + assert!(!pending.vendor_paid); let approved = t.client.approve_loan(&loan_id); assert_eq!(approved.status, LoanStatus::Active); + assert!(approved.vendor_paid); let stored = t.client.get_loan(&loan_id); assert_eq!(stored.status, LoanStatus::Active); + assert!(stored.vendor_paid); + + let balance = t.balance(&vendor); + let expected_payment = pending.total_amount - pending.guarantee_amount; + assert_eq!(balance, expected_payment); +} + +#[test] +fn test_pay_vendor_direct_call_marks_vendor_paid() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + let vendor = Address::generate(&t.env); + + let loan_id = t.create_default_request(&user, &vendor); + let pending = t.client.get_loan(&loan_id); + assert_eq!(pending.status, LoanStatus::Pending); + assert!(!pending.vendor_paid); + + let result = t.client.pay_vendor(&loan_id); + assert!(result.is_ok()); + + let stored = t.client.get_loan(&loan_id); + assert_eq!(stored.status, LoanStatus::Active); + assert!(stored.vendor_paid); + + let balance = t.balance(&vendor); + let expected_payment = pending.total_amount - pending.guarantee_amount; + assert_eq!(balance, expected_payment); +} + +#[test] +fn test_pay_vendor_prevents_double_payment() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + let vendor = Address::generate(&t.env); + + let loan_id = t.create_default_request(&user, &vendor); + assert!(t.client.pay_vendor(&loan_id).is_ok()); + + let second = t.client.pay_vendor(&loan_id); + assert!(second.is_err()); + assert_eq!(second.unwrap_err(), CreditLineError::VendorAlreadyPaid); } #[test] diff --git a/contracts/creditline-contract/src/types.rs b/contracts/creditline-contract/src/types.rs index a6830d7..bf24855 100644 --- a/contracts/creditline-contract/src/types.rs +++ b/contracts/creditline-contract/src/types.rs @@ -60,6 +60,7 @@ pub struct Loan { pub remaining_balance: i128, pub repayment_schedule: soroban_sdk::Vec, pub status: LoanStatus, + pub vendor_paid: bool, pub loan_type: LoanType, pub created_at: u64, // Unix timestamp pub funded_at: u64, // 0 means not funded yet