From da876840f82cdc521a5afb2149884dfa9570349a Mon Sep 17 00:00:00 2001 From: Oluwatos94 Date: Sun, 28 Jun 2026 19:54:50 +0100 Subject: [PATCH] loan installment due date tracking --- contracts/creditline-contract/src/errors.rs | 1 + contracts/creditline-contract/src/lib.rs | 10 ++ contracts/creditline-contract/src/tests.rs | 138 ++++++++++++++++++++ contracts/creditline-contract/src/types.rs | 6 + 4 files changed, 155 insertions(+) diff --git a/contracts/creditline-contract/src/errors.rs b/contracts/creditline-contract/src/errors.rs index a639875..6125165 100644 --- a/contracts/creditline-contract/src/errors.rs +++ b/contracts/creditline-contract/src/errors.rs @@ -31,4 +31,5 @@ pub enum CreditLineError { InstallmentAlreadyPaid = 24, InvalidLoanStatus = 25, NotInitialized = 26, + InvalidDueDate = 27, } diff --git a/contracts/creditline-contract/src/lib.rs b/contracts/creditline-contract/src/lib.rs index 229da67..52fa59e 100644 --- a/contracts/creditline-contract/src/lib.rs +++ b/contracts/creditline-contract/src/lib.rs @@ -575,6 +575,16 @@ impl CreditLineContract { panic_with_error!(&env, CreditLineError::InvalidLoanStatus); } + if loan.repayment_schedule.is_empty() { + panic_with_error!(&env, CreditLineError::InvalidDueDate); + } + let now = env.ledger().timestamp(); + for installment in loan.repayment_schedule.iter() { + if installment.due_date == 0 || installment.due_date <= now { + panic_with_error!(&env, CreditLineError::InvalidDueDate); + } + } + // 4. Transition to Active loan.status = LoanStatus::Active; diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index a1f1a2b..5f9490f 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -3188,6 +3188,144 @@ fn test_approve_loan_not_admin() { ); } +#[test] +#[should_panic(expected = "Error(Contract, #27)")] // InvalidDueDate +fn test_approve_loan_rejects_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); + + // Request a loan whose installment has no due date set (0). + let schedule = t.single_installment(DEFAULT_TOTAL_DUE, 0); + let loan_id = t.client.request_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); + + // Approval must reject the missing due date. + t.client.approve_loan(&loan_id); +} + +#[test] +#[should_panic(expected = "Error(Contract, #27)")] // InvalidDueDate +fn test_approve_loan_rejects_past_due_date() { + 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 due_date = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap() + .due_date; + + // Move the clock past the due date so it is no longer in the future at approval. + t.env.ledger().set_timestamp(due_date + 1); + t.client.approve_loan(&loan_id); +} + +// ─── is_on_time helper tests ────────────────────────────────────────────────── + +#[test] +fn test_installment_is_on_time() { + // Paid before the due date → on time. + let early = RepaymentInstallment { + amount: 100, + due_date: 1000, + paid: true, + paid_at: 900, + }; + assert!(early.is_on_time()); + + // Paid exactly at the due date → still on time. + let exact = RepaymentInstallment { + amount: 100, + due_date: 1000, + paid: true, + paid_at: 1000, + }; + assert!(exact.is_on_time()); + + // Paid one second after the due date → late. + let late = RepaymentInstallment { + amount: 100, + due_date: 1000, + paid: true, + paid_at: 1001, + }; + assert!(!late.is_on_time()); + + // Unpaid installment is never on time, regardless of timestamps. + let unpaid = RepaymentInstallment { + amount: 100, + due_date: 1000, + paid: false, + paid_at: 0, + }; + assert!(!unpaid.is_on_time()); +} + +#[test] +fn test_repay_installment_on_time_reflected_by_is_on_time() { + 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); + + // Pay well before the first installment's due date (10_000). + t.env.ledger().set_timestamp(5000); + t.client.repay_installment(&user, &loan_id, &0, &payment); + + let installment = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap(); + assert!(installment.is_on_time()); +} + +#[test] +fn test_repay_installment_late_reflected_by_is_on_time() { + 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 after the first installment's due date. + t.env.ledger().set_timestamp(due_date + 1); + t.client.repay_installment(&user, &loan_id, &0, &payment); + + let installment = t + .client + .get_loan(&loan_id) + .repayment_schedule + .get(0) + .unwrap(); + assert!(!installment.is_on_time()); +} + // ─── repay_installment tests ────────────────────────────────────────────────── /// Helper: creates a loan with `n_installments` equal-valued installments diff --git a/contracts/creditline-contract/src/types.rs b/contracts/creditline-contract/src/types.rs index a6830d7..f5f6b87 100644 --- a/contracts/creditline-contract/src/types.rs +++ b/contracts/creditline-contract/src/types.rs @@ -42,6 +42,12 @@ pub struct RepaymentInstallment { pub paid_at: u64, // Unix timestamp of payment (0 = unpaid) } +impl RepaymentInstallment { + pub fn is_on_time(&self) -> bool { + self.paid && self.paid_at <= self.due_date + } +} + // Loan data structure #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)]