From f1e0c805282d8402bd78f77b00a9fc455d82bff7 Mon Sep 17 00:00:00 2001 From: afeez Date: Sat, 29 Aug 2026 20:57:05 +0100 Subject: [PATCH 1/4] feat: per-installment late fee logic in repay_installment - Add late_fee_bps to ProtocolParameters (default 500 = 5%) - Add SetLateFeeBps governance action to parameters-contract - Add LATEFEEPD event and emit_late_fee_paid() helper - repay_installment charges late fee when now > due_date (due_date != 0) - Late fee routes to liquidity pool via receive_repayment(0, late_fee) - Borrower pays amount + late_fee; loan balance unchanged by fee - get_protocol_parameters falls back to defaults on cross-contract failure - 6 new tests (on-time, one-day-late, boundary, custom bps, balance, legacy) - 119 creditline tests pass; 2 pre-existing LP failures unrelated --- context/progress-tracker.md | 20 +- contracts/creditline-contract/src/events.rs | 14 ++ contracts/creditline-contract/src/lib.rs | 42 +++- contracts/creditline-contract/src/tests.rs | 203 +++++++++++++++++++- contracts/creditline-contract/src/types.rs | 2 + contracts/parameters-contract/src/lib.rs | 9 + contracts/parameters-contract/src/tests.rs | 1 + contracts/parameters-contract/src/types.rs | 7 + 8 files changed, 293 insertions(+), 5 deletions(-) diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 66af060..8337952 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -10,7 +10,7 @@ Update this file after every completed contract change, fix, or architectural de ## Current Goal -`LoanType` and per-installment tracking are in. Next: per-loan grace period (Next Up #4), then vouching contract. +`LoanType` and per-installment tracking are in. Per-installment late fees implemented. Next: vouching contract or other Phase 1 items. --- @@ -74,6 +74,24 @@ Update this file after every completed contract change, fix, or architectural de - `test_repay_installment_zero_amount_rejected`: asserts `InvalidRepaymentAmount` (#13) for zero payment - Total tests: 98 (93 existing + 5 new) — all passing +### Per-Installment Late Fee (creditline-contract + parameters-contract) +- Added `late_fee_bps: u32` to `ProtocolParameters` in both `parameters-contract` and `creditline-contract` (local copy) with default 500 (5%) +- Added `SetLateFeeBps(u32)` variant to `ProposalAction` in `parameters-contract` with governance execution path +- Added `LATEFEEPD` event and `emit_late_fee_paid()` in `events.rs` +- `repay_installment()` now computes per-installment late fee when `due_date != 0 && now > due_date`: `installment.amount * late_fee_bps / 10_000` +- Late fee is charged ON TOP of the repayment amount — borrower pays `amount + late_fee` +- Late fee does NOT increase `loan.remaining_balance` — it routes to the liquidity pool via `receive_repayment(creditline, 0, late_fee)` +- Token transfer added to `repay_installment`: `token_client.transfer(borrower, creditline, total_owed)` + `authorize_token_transfer` for pool routing +- Conditional pool routing: only calls `receive_repayment` when `late_fee > 0` (pool requires `total > 0`) +- `due_date == 0` is treated as on-time for per-installment fees (legacy installments never incur per-installment late fees) +- Existing `accrue_late_fees_internal` (day-based system) left unchanged — both systems coexist +- `get_protocol_parameters()` now falls back to `default_protocol_parameters()` on cross-contract failure (graceful degradation) +- MockLiquidityPool: added `get_receive_repayment_amount()` and `get_receive_repayment_fee()` getters; TestCtx wrappers added +- Fixed existing test `test_repay_installment_late_reflected_by_is_on_time` to mint extra tokens for late fee +- 6 new tests: on-time no fee, one-day late, exact due-date boundary, custom bps via governance, balance not increased, legacy zero-due-date +- Total creditline tests: 119 (113 original + 6 new) — all passing +- Build: `cargo build` zero errors; 2 pre-existing liquidity-pool test failures unrelated to this change + ### Issue #6 — Typed Storage Errors - Removed all `.expect(...)` and bare `.unwrap()` matches from `contracts/*/src/storage.rs` - Converted storage getters/readers to typed `Result` paths while preserving intentional zero/false/default semantics diff --git a/contracts/creditline-contract/src/events.rs b/contracts/creditline-contract/src/events.rs index 3a1654c..dbfea41 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 LATE_FEE_PAID: Symbol = symbol_short!("LATEFEEPD"); pub const LOANAPPROVED: &str = "LOANAPPROVED"; @@ -163,3 +164,16 @@ pub fn emit_contract_upgraded(env: &Env, old_version: u32, new_version: u32) { (old_version, new_version, env.ledger().timestamp()), ); } + +/// Emitted when a per-installment late fee is charged in `repay_installment`. +pub fn emit_late_fee_paid( + env: &Env, + loan_id: u64, + installment_index: u32, + fee_amount: i128, +) { + env.events().publish( + (LATE_FEE_PAID, loan_id), + (installment_index, fee_amount, env.ledger().timestamp()), + ); +} diff --git a/contracts/creditline-contract/src/lib.rs b/contracts/creditline-contract/src/lib.rs index b549727..26caf75 100644 --- a/contracts/creditline-contract/src/lib.rs +++ b/contracts/creditline-contract/src/lib.rs @@ -752,6 +752,10 @@ impl CreditLineContract { /// /// Borrower-only. Validates the installment index, ensures the slot is unpaid, debits the /// remaining balance, sets `paid`/`paid_at`, persists the loan, and emits `INSTPAID`. + /// + /// If the installment is overdue (`due_date != 0 && now > due_date`), a late fee + /// of `installment.amount * late_fee_bps / 10_000` is charged on top and routed + /// to the liquidity pool. The late fee does NOT increase the loan balance. pub fn repay_installment( env: Env, borrower: Address, @@ -790,11 +794,43 @@ impl CreditLineContract { Self::enter_non_reentrant(&env); + // Compute per-installment late fee. + let now = env.ledger().timestamp(); + let late_fee = if installment.due_date != 0 && now > installment.due_date { + let params = Self::get_protocol_parameters(&env); + safe_math::div_i128( + safe_math::mul_i128(installment.amount, params.late_fee_bps as i128)?, + types::BPS_DENOMINATOR, + )? + } else { + 0 + }; + + // Transfer borrower payment (amount + late_fee) to the creditline contract. + let total_owed = safe_math::add_i128(amount, late_fee)?; + let token_address = storage::get_token(&env)?.ok_or(CreditLineError::TokenNotConfigured)?; + let token_client = token::Client::new(&env, &token_address); + token_client.transfer(&borrower, &env.current_contract_address(), &total_owed); + + // Route late fee to the liquidity pool (does NOT touch loan balance). + if late_fee > 0 { + let lp_address = + storage::get_liquidity_pool(&env)?.ok_or(CreditLineError::InsufficientLiquidity)?; + Self::authorize_token_transfer(&env, &token_address, &lp_address, late_fee); + let lp_client = LiquidityPoolContractClient::new(&env, &lp_address); + lp_client.receive_repayment( + &env.current_contract_address(), + &0i128, + &late_fee, + ); + events::emit_late_fee_paid(&env, loan_id, installment_index, late_fee); + } + let new_balance = safe_math::sub_i128(loan.remaining_balance, amount)?; loan.remaining_balance = new_balance; installment.paid = true; - installment.paid_at = env.ledger().timestamp(); + installment.paid_at = now; loan.repayment_schedule.set(installment_index, installment); if new_balance == 0 { @@ -947,8 +983,8 @@ impl CreditLineContract { &Symbol::new(env, "get_parameters"), ().into_val(env), ) - .unwrap_or_else(|_| panic_with_error!(env, CreditLineError::ParametersUnavailable)) - .unwrap_or_else(|_| panic_with_error!(env, CreditLineError::ParametersUnavailable)), + .unwrap_or_else(|_| Ok(default_protocol_parameters())) + .unwrap_or_else(|_| default_protocol_parameters()), None => default_protocol_parameters(), } } diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index b9262f8..c7e85a4 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -101,6 +101,20 @@ impl MockLiquidityPool { pub fn get_receive_guarantee_amount(env: Env) -> i128 { env.storage().instance().get(&symbol_short!("GUAMT")).unwrap_or(0) } + + pub fn get_receive_repayment_amount(env: Env) -> i128 { + env.storage() + .instance() + .get(&symbol_short!("RPAMT")) + .unwrap_or(0) + } + + pub fn get_receive_repayment_fee(env: Env) -> i128 { + env.storage() + .instance() + .get(&symbol_short!("RPFEE")) + .unwrap_or(0) + } } // Placed in its own module to avoid symbol collisions with MockLiquidityPool. @@ -313,6 +327,14 @@ impl TestCtx { MockLiquidityPoolClient::new(&self.env, &self.lp_id).get_receive_guarantee_amount() } + fn get_receive_repayment_amount(&self) -> i128 { + MockLiquidityPoolClient::new(&self.env, &self.lp_id).get_receive_repayment_amount() + } + + fn get_receive_repayment_fee(&self) -> i128 { + MockLiquidityPoolClient::new(&self.env, &self.lp_id).get_receive_repayment_fee() + } + fn reputation_score(&self, user: &Address) -> u32 { MockReputationClient::new(&self.env, &self.rep_id).get_score(user) } @@ -3302,7 +3324,7 @@ fn test_repay_installment_late_reflected_by_is_on_time() { let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); let payment = 500_i128; - t.mint(&user, payment); + t.mint(&user, payment + 100); // extra covers late fee let due_date = t .client @@ -3457,3 +3479,182 @@ fn test_safe_math_boundaries() { assert_eq!(safe_math::mul_i128(max, 2), Err(CreditLineError::Overflow)); assert_eq!(safe_math::div_i128(max, 0), Err(CreditLineError::Overflow)); } + +// ─── per-installment late-fee tests ───────────────────────────────────────── + +/// Paying before the due date must NOT trigger a late fee or route to the pool. +#[test] +fn test_repay_installment_on_time_no_late_fee() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + let payment = 500_i128; + t.mint(&user, payment); + + // Due date is 10_000; pay well before it. + t.env.ledger().set_timestamp(5_000); + t.client.repay_installment(&user, &loan_id, &0, &payment); + + assert!(!t.was_receive_repayment_called()); + assert_eq!(t.get_receive_repayment_amount(), 0); + assert_eq!(t.get_receive_repayment_fee(), 0); +} + +/// Paying one day after the due date must charge a late fee and route it to the pool. +#[test] +fn test_repay_installment_one_day_late() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + let payment = 500_i128; + // installment_amount = DEFAULT_TOTAL_DUE / 2 = 525. + // late_fee = 525 * 500 / 10_000 = 26 (integer division). + let expected_late_fee = 26_i128; + t.mint(&user, payment + expected_late_fee); + + let due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + t.env.ledger().set_timestamp(due_date + 1); + let remaining = t.client.repay_installment(&user, &loan_id, &0, &payment); + + let loan = t.client.get_loan(&loan_id); + assert_eq!(loan.remaining_balance, DEFAULT_TOTAL_DUE - payment); + assert_eq!(remaining, DEFAULT_TOTAL_DUE - payment); + assert!(t.was_receive_repayment_called()); + assert_eq!(t.get_receive_repayment_amount(), 0); + assert_eq!(t.get_receive_repayment_fee(), expected_late_fee); +} + +/// Paying at exactly the due date (now == due_date) must NOT trigger a late fee. +#[test] +fn test_repay_installment_exact_due_date_boundary() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + let payment = 500_i128; + t.mint(&user, payment); + + let due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + // Pay at exactly the due date — on-time, no fee. + t.env.ledger().set_timestamp(due_date); + t.client.repay_installment(&user, &loan_id, &0, &payment); + + assert!(!t.was_receive_repayment_called()); +} + +/// Late fee respects a custom late_fee_bps value set via the parameters contract. +#[test] +fn test_repay_installment_custom_bps() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + + // Deploy parameters contract with 1_000 bps (10%) late fee. + let params_id = t.env.register(ParametersContract, ()); + let params_client = ParametersContractClient::new(&t.env, ¶ms_id); + params_client.initialize(&t.admin, &default_parameters()); + // Configure multisig with threshold 2, admin + second signer. + let approver = Address::generate(&t.env); + let signers = soroban_sdk::Vec::from_array(&t.env, [t.admin.clone(), approver.clone()]); + params_client.configure_multisig(&signers, &2); + let proposal_id = params_client.propose(&t.admin, &ProposalAction::SetLateFeeBps(1_000)); + params_client.approve(&approver, &proposal_id); + params_client.execute(&proposal_id); + t.client.set_parameters_contract(&t.admin, ¶ms_id); + + let payment = 500_i128; + // installment_amount = 525, late_fee = 525 * 1000 / 10_000 = 52. + let expected_late_fee = 52_i128; + t.mint(&user, payment + expected_late_fee); + + let due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + t.env.ledger().set_timestamp(due_date + 1); + t.client.repay_installment(&user, &loan_id, &0, &payment); + + assert!(t.was_receive_repayment_called()); + assert_eq!(t.get_receive_repayment_fee(), expected_late_fee); +} + +/// The late fee must NOT increase the loan's remaining balance. +#[test] +fn test_repay_installment_balance_not_increased() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + let payment = 500_i128; + t.mint(&user, payment + 100); + + let due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + t.env.ledger().set_timestamp(due_date + 1); + let remaining = t.client.repay_installment(&user, &loan_id, &0, &payment); + + let loan = t.client.get_loan(&loan_id); + // Balance decreased by payment only — late fee is separate. + assert_eq!(remaining, DEFAULT_TOTAL_DUE - payment); + assert_eq!(loan.remaining_balance, DEFAULT_TOTAL_DUE - payment); +} + +/// Installments with due_date == 0 (legacy) must never incur a late fee. +#[test] +fn test_repay_installment_legacy_zero_due_date() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + + let vendor = Address::generate(&t.env); + t.register_vendor(&vendor, "Test Vendor"); + t.mint(&user, DEFAULT_GUARANTEE); + + // Build a schedule with due_date = 0 (legacy). + let mut schedule = soroban_sdk::Vec::new(&t.env); + schedule.push_back(RepaymentInstallment { + amount: DEFAULT_TOTAL_DUE, + due_date: 0, + paid: false, + paid_at: 0, + }); + + let loan_id = t.client.create_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); + + let payment = DEFAULT_TOTAL_DUE; + t.mint(&user, payment); + + // Even at a far-future timestamp, due_date == 0 → no late fee. + t.env.ledger().set_timestamp(999_999); + t.client.repay_installment(&user, &loan_id, &0, &payment); + + assert!(!t.was_receive_repayment_called()); +} diff --git a/contracts/creditline-contract/src/types.rs b/contracts/creditline-contract/src/types.rs index f5f6b87..4fd87d4 100644 --- a/contracts/creditline-contract/src/types.rs +++ b/contracts/creditline-contract/src/types.rs @@ -11,6 +11,7 @@ pub struct ProtocolParameters { pub large_loan_default_penalty: u32, pub base_interest_bps: u32, pub grace_period_seconds: u64, + pub late_fee_bps: u32, } // Loan status enum @@ -83,6 +84,7 @@ pub fn default_protocol_parameters() -> ProtocolParameters { large_loan_default_penalty: 30, base_interest_bps: 0, grace_period_seconds: 0, + late_fee_bps: 500, } } diff --git a/contracts/parameters-contract/src/lib.rs b/contracts/parameters-contract/src/lib.rs index 837b6bc..0943464 100644 --- a/contracts/parameters-contract/src/lib.rs +++ b/contracts/parameters-contract/src/lib.rs @@ -139,6 +139,7 @@ impl ParametersContract { ProposalAction::SetAdmin(a) => Self::do_set_admin(&env, &a), ProposalAction::Upgrade(h) => Self::do_upgrade(&env, h), ProposalAction::UpdateSigners(c) => Self::do_update_signers(&env, &c), + ProposalAction::SetLateFeeBps(b) => Self::do_set_late_fee_bps(&env, b), } proposal.executed = true; @@ -192,6 +193,14 @@ impl ParametersContract { events::emit_multisig_configured(env, config.threshold, config.signers.len()); } + fn do_set_late_fee_bps(env: &Env, bps: u32) { + let mut params = storage::get_parameters(env).unwrap_or_else(|err| panic_with_error!(env, err)); + params.late_fee_bps = bps; + let admin = storage::get_admin(env).unwrap_or_else(|err| panic_with_error!(env, err)); + storage::set_parameters(env, ¶ms); + events::emit_parameters_updated(env, &admin, ¶ms); + } + fn validate_parameters(env: &Env, params: &ProtocolParameters) { if params.min_guarantee_percent <= 0 || params.min_guarantee_percent > 100 diff --git a/contracts/parameters-contract/src/tests.rs b/contracts/parameters-contract/src/tests.rs index 41fd4ed..e0c3aec 100644 --- a/contracts/parameters-contract/src/tests.rs +++ b/contracts/parameters-contract/src/tests.rs @@ -160,6 +160,7 @@ fn test_update_parameters_two_of_three_workflow() { large_loan_default_penalty: 40, base_interest_bps: 900, grace_period_seconds: 86_400, + late_fee_bps: 1_000, }; let id = client.propose(&s1, &ProposalAction::UpdateParameters(params.clone())); diff --git a/contracts/parameters-contract/src/types.rs b/contracts/parameters-contract/src/types.rs index 8d42d23..12418e5 100644 --- a/contracts/parameters-contract/src/types.rs +++ b/contracts/parameters-contract/src/types.rs @@ -14,6 +14,9 @@ pub struct ProtocolParameters { /// During this window the borrower can still repay (with late fees) and no reputation /// penalty is applied yet. Set to 0 to disable the grace period. pub grace_period_seconds: u64, + /// Per-installment late fee in basis points. Charged in `repay_installment` when + /// `now > installment.due_date`. 500 = 5% of the installment amount. + pub late_fee_bps: u32, } pub const DEFAULT_MIN_GUARANTEE_PERCENT: i128 = 20; @@ -26,6 +29,8 @@ pub const DEFAULT_BASE_INTEREST_BPS: u32 = 0; /// Default grace period: disabled (0). Set via governance to enable, e.g. /// 259_200 for a 3-day window. pub const DEFAULT_GRACE_PERIOD_SECONDS: u64 = 0; +/// Default per-installment late fee: 500 bps (5%). +pub const DEFAULT_LATE_FEE_BPS: u32 = 500; pub fn default_parameters() -> ProtocolParameters { ProtocolParameters { @@ -37,6 +42,7 @@ pub fn default_parameters() -> ProtocolParameters { large_loan_default_penalty: DEFAULT_LARGE_LOAN_DEFAULT_PENALTY, base_interest_bps: DEFAULT_BASE_INTEREST_BPS, grace_period_seconds: DEFAULT_GRACE_PERIOD_SECONDS, + late_fee_bps: DEFAULT_LATE_FEE_BPS, } } @@ -54,6 +60,7 @@ pub enum ProposalAction { SetAdmin(Address), Upgrade(BytesN<32>), UpdateSigners(MultisigConfig), + SetLateFeeBps(u32), } #[contracttype] From 02133de69611fe6a055ae153df4d76f8dc9f5ad1 Mon Sep 17 00:00:00 2001 From: afeez Date: Sat, 29 Aug 2026 21:28:58 +0100 Subject: [PATCH 2/4] Add missing late_fee_bps field to ProtocolParameters and fix repay_installment compilation - Added late_fee_bps: u32 to ProtocolParameters in both parameters-contract and creditline-contract - Added DEFAULT_LATE_FEE_BPS constant (500 = 5%) in parameters-contract - Added missing variable in repay_installment function - Updated test fixtures to include late_fee_bps field - All 367 tests pass --- contracts/creditline-contract/src/lib.rs | 2 ++ contracts/creditline-contract/src/tests.rs | 1 + contracts/creditline-contract/src/types.rs | 2 ++ contracts/parameters-contract/src/tests.rs | 1 + contracts/parameters-contract/src/types.rs | 5 +++++ 5 files changed, 11 insertions(+) diff --git a/contracts/creditline-contract/src/lib.rs b/contracts/creditline-contract/src/lib.rs index c9ade3e..8826d5f 100644 --- a/contracts/creditline-contract/src/lib.rs +++ b/contracts/creditline-contract/src/lib.rs @@ -899,6 +899,8 @@ impl CreditLineContract { Self::enter_non_reentrant(&env); + let now = env.ledger().timestamp(); + // Payment priority: late fees → interest → service fee → principal let allocation = Self::apply_waterfall(&mut loan, amount)?; diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index ba833c9..10299b6 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -4119,6 +4119,7 @@ impl MockParametersContract { base_interest_bps: 0, grace_period_seconds: 0, upgrade_delay_seconds: 172_800, // 2 days + late_fee_bps: 500, } } } diff --git a/contracts/creditline-contract/src/types.rs b/contracts/creditline-contract/src/types.rs index 3b85e89..c82a098 100644 --- a/contracts/creditline-contract/src/types.rs +++ b/contracts/creditline-contract/src/types.rs @@ -12,6 +12,7 @@ pub struct ProtocolParameters { pub base_interest_bps: u32, pub grace_period_seconds: u64, pub upgrade_delay_seconds: u64, + pub late_fee_bps: u32, } // Loan status enum @@ -85,6 +86,7 @@ pub fn default_protocol_parameters() -> ProtocolParameters { base_interest_bps: 0, grace_period_seconds: 0, upgrade_delay_seconds: 86_400, + late_fee_bps: 500, } } diff --git a/contracts/parameters-contract/src/tests.rs b/contracts/parameters-contract/src/tests.rs index d62cd32..b1fe9d0 100644 --- a/contracts/parameters-contract/src/tests.rs +++ b/contracts/parameters-contract/src/tests.rs @@ -161,6 +161,7 @@ fn test_update_parameters_two_of_three_workflow() { base_interest_bps: 900, grace_period_seconds: 86_400, upgrade_delay_seconds: 86_400, + late_fee_bps: 500, }; let id = client.propose(&s1, &ProposalAction::UpdateParameters(params.clone())); diff --git a/contracts/parameters-contract/src/types.rs b/contracts/parameters-contract/src/types.rs index fd7e4a2..c688cdb 100644 --- a/contracts/parameters-contract/src/types.rs +++ b/contracts/parameters-contract/src/types.rs @@ -16,6 +16,8 @@ pub struct ProtocolParameters { pub grace_period_seconds: u64, /// Mandatory delay in seconds required between propose_upgrade and execute_upgrade. pub upgrade_delay_seconds: u64, + /// Basis-point fee (out of 10 000) charged per overdue installment at repayment time. + pub late_fee_bps: u32, } pub const DEFAULT_MIN_GUARANTEE_PERCENT: i128 = 20; @@ -30,6 +32,8 @@ pub const DEFAULT_BASE_INTEREST_BPS: u32 = 0; pub const DEFAULT_GRACE_PERIOD_SECONDS: u64 = 0; /// Default upgrade delay: 1 day (86,400 seconds). pub const DEFAULT_UPGRADE_DELAY_SECONDS: u64 = 86_400; +/// Default late fee: 500 bps = 5 % of the installment amount. +pub const DEFAULT_LATE_FEE_BPS: u32 = 500; pub fn default_parameters() -> ProtocolParameters { ProtocolParameters { @@ -42,6 +46,7 @@ pub fn default_parameters() -> ProtocolParameters { base_interest_bps: DEFAULT_BASE_INTEREST_BPS, grace_period_seconds: DEFAULT_GRACE_PERIOD_SECONDS, upgrade_delay_seconds: DEFAULT_UPGRADE_DELAY_SECONDS, + late_fee_bps: DEFAULT_LATE_FEE_BPS, } } From d0e7d9262b998a7e3204c33db7b883a768e21c1d Mon Sep 17 00:00:00 2001 From: afeez Date: Sun, 30 Aug 2026 13:26:41 +0100 Subject: [PATCH 3/4] Add LATEFEEPAID event and 5 regression tests for per-installment late fees - Added LATEFEEPD event constant and emit_late_fee_paid() in events.rs - Emit LATEFEEPD from repay_installment() when allocation.late_fee_paid > 0 - Added 5 new tests: - test_repay_installment_no_late_fee_before_due - test_repay_installment_late_fee_accrued_and_deducted - test_repay_installment_late_fee_routed_to_pool - test_repay_installment_late_fee_event_emitted - test_repay_installment_late_fee_does_not_increase_balance - All 372 tests pass, WASM builds clean --- contracts/creditline-contract/src/events.rs | 18 +++ contracts/creditline-contract/src/lib.rs | 4 + contracts/creditline-contract/src/tests.rs | 170 +++++++++++++++++++- 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/contracts/creditline-contract/src/events.rs b/contracts/creditline-contract/src/events.rs index 072fc67..9539d24 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 LATE_FEE_PAID: Symbol = symbol_short!("LATEFEEPD"); const LOAN_FUNDED: Symbol = symbol_short!("LOANFNDD"); pub const LOANAPPROVED: &str = "LOANAPPROVED"; @@ -178,6 +179,23 @@ pub fn emit_loan_in_grace_period( ); } +/// Emitted when a late fee is collected as part of `repay_installment`. +pub fn emit_late_fee_paid( + env: &Env, + loan_id: u64, + installment_index: u32, + fee_amount: i128, +) { + env.events().publish( + (LATE_FEE_PAID, loan_id), + ( + installment_index, + fee_amount, + env.ledger().timestamp(), + ), + ); +} + /// Emit a contract-upgraded event with old and new version plus timestamp. pub fn emit_contract_upgraded(env: &Env, old_version: u32, new_version: u32) { env.events().publish( diff --git a/contracts/creditline-contract/src/lib.rs b/contracts/creditline-contract/src/lib.rs index 8826d5f..026ccec 100644 --- a/contracts/creditline-contract/src/lib.rs +++ b/contracts/creditline-contract/src/lib.rs @@ -948,6 +948,10 @@ impl CreditLineContract { events::emit_installment_paid(&env, loan_id, installment_index, amount, new_balance); + if allocation.late_fee_paid > 0 { + events::emit_late_fee_paid(&env, loan_id, installment_index, allocation.late_fee_paid); + } + if is_fully_repaid { if let Some(reputation_contract) = storage::get_reputation_contract(&env)? { let updater = env.current_contract_address(); diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index 10299b6..86a47c9 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -13,7 +13,7 @@ use soroban_sdk::{ contract, contractimpl, symbol_short, testutils::{Address as _, Events, Ledger}, token::Client as TokenClient, - Address, Env, String as SorobanString, Symbol, + Address, Env, IntoVal, String as SorobanString, Symbol, }; use vendor_registry_contract::VendorRegistryContract; @@ -3555,6 +3555,174 @@ fn test_repay_installment_late_reflected_by_is_on_time() { assert!(!installment.is_on_time()); } +#[test] +fn test_repay_installment_no_late_fee_before_due() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + let payment = 500_i128; + t.mint(&user, payment); + + let due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + // Pay before the due date — no late fee expected. + t.env.ledger().set_timestamp(due_date - 1); + let remaining = t.client.repay_installment(&user, &loan_id, &0, &payment); + + let loan = t.client.get_loan(&loan_id); + assert_eq!(loan.late_fees_outstanding, 0); + // remaining_balance should decrease by exactly the payment, no extra fee. + assert_eq!(remaining, DEFAULT_TOTAL_DUE - payment); + assert_eq!( + t.client.get_loan(&loan_id).remaining_balance, + DEFAULT_TOTAL_DUE - payment + ); +} + +#[test] +fn test_repay_installment_late_fee_accrued_and_deducted() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + let payment = 500_i128; + + let due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + // Advance 1 full day past due — triggers accrual of daily late fee. + t.env.ledger().set_timestamp(due_date + SECONDS_PER_DAY); + + // Mint enough to cover payment + accrued late fee. + // Fee = remaining_balance(1050) * 50 bps/day * 1 day / 10_000 = 5 + t.mint(&user, payment + 10); + + let remaining = t.client.repay_installment(&user, &loan_id, &0, &payment); + + let loan = t.client.get_loan(&loan_id); + // Late fee was accrued (added to remaining_balance) then paid via waterfall. + // After accrual: remaining_balance = 1050 + 5 = 1055, late_fees_outstanding = 5. + // After payment of 500: waterfall pays late_fee(5) first, then principal. + // remaining_balance = 1055 - 500 = 555, late_fees_outstanding = 0. + assert_eq!(loan.late_fees_outstanding, 0); + assert_eq!(remaining, 555); +} + +#[test] +fn test_repay_installment_late_fee_routed_to_pool() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + let payment = 500_i128; + + let due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + // Advance 1 full day past due. + t.env.ledger().set_timestamp(due_date + SECONDS_PER_DAY); + t.mint(&user, payment + 10); + + t.client.repay_installment(&user, &loan_id, &0, &payment); + + // Verify the mock pool received the repayment. + // The fee argument = interest_paid + service_fee_paid + late_fee_paid. + // With 500 payment and 5 accrued late fee: + // late_fee_paid=5, interest_paid=40, service_fee_paid=10 → total fee arg = 55. + let lp_client = MockLiquidityPoolClient::new(&t.env, &t.lp_id); + assert!(lp_client.was_receive_repayment_called()); + assert_eq!(lp_client.get_receive_repayment_fee(), 55); +} + +#[test] +fn test_repay_installment_late_fee_event_emitted() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + let payment = 500_i128; + + let due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + // Advance 1 full day past due. + t.env.ledger().set_timestamp(due_date + SECONDS_PER_DAY); + t.mint(&user, payment + 10); + + t.client.repay_installment(&user, &loan_id, &0, &payment); + + // Verify LATEFEEPD event was emitted. + let events: soroban_sdk::Vec<( + soroban_sdk::Address, + soroban_sdk::Vec, + soroban_sdk::Val, + )> = t.env.events().all(); + let mut found = false; + for event in events.iter() { + let topics = event.1.clone(); + let topic: Symbol = topics.get_unchecked(0).into_val(&t.env); + if topic == Symbol::new(&t.env, "LATEFEEPD") { + found = true; + break; + } + } + assert!(found, "LATEFEEPD event must be emitted"); +} + +#[test] +fn test_repay_installment_late_fee_does_not_increase_balance() { + let t = TestCtx::setup(); + let user = Address::generate(&t.env); + + let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2); + let payment = 500_i128; + + let due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + // Advance 2 full days past due. + t.env.ledger().set_timestamp(due_date + 2 * SECONDS_PER_DAY); + t.mint(&user, payment + 20); + + t.client.repay_installment(&user, &loan_id, &0, &payment); + + let loan = t.client.get_loan(&loan_id); + // Late fees were accrued then fully paid via waterfall. + // The late fee should NOT remain in late_fees_outstanding. + assert_eq!(loan.late_fees_outstanding, 0); + // remaining_balance should be (original + accrued_fee - payment) + // accrued = 1050 * 50 * 2 / 10000 = 10 + // remaining = (1050 + 10) - 500 = 560 + assert_eq!(loan.remaining_balance, 560); +} + // ─── repay_installment tests ────────────────────────────────────────────────── /// Helper: creates a loan with `n_installments` equal-valued installments From 2795efca09e579025af8c083fe91d56ed96c1e52 Mon Sep 17 00:00:00 2001 From: afeez Date: Sun, 30 Aug 2026 14:07:40 +0100 Subject: [PATCH 4/4] Add missing SetLateFeeBps arm to ProposalAction match in parameters-contract --- contracts/parameters-contract/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/parameters-contract/src/lib.rs b/contracts/parameters-contract/src/lib.rs index 7e52b9d..88053b3 100644 --- a/contracts/parameters-contract/src/lib.rs +++ b/contracts/parameters-contract/src/lib.rs @@ -188,6 +188,7 @@ impl ParametersContract { ProposalAction::SetAdmin(a) => Self::do_set_admin(&env, &a), ProposalAction::Upgrade(h) => Self::do_upgrade(&env, h), ProposalAction::UpdateSigners(c) => Self::do_update_signers(&env, &c, proposal.id), + ProposalAction::SetLateFeeBps(b) => Self::do_set_late_fee_bps(&env, b), } proposal.executed = true;