Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ Update this file after every completed contract change, fix, or architectural de
- New event: `INSTPAID` via `emit_installment_paid`
- All 93 existing tests updated and passing; 0 failing

### repay_installment Unit Tests
- Added `setup_loan_with_schedule` helper that creates a loan with N equal installments
- `test_repay_installment_happy_path`: pays installment 0, verifies `paid`/`paid_at`, balance decremented, second installment untouched
- `test_repay_installment_double_pay_rejected`: asserts `InstallmentAlreadyPaid` (#24) on second payment of same slot
- `test_repay_installment_out_of_bounds`: asserts `InvalidInstallmentIndex` (#23) for index >= schedule length
- `test_repay_installment_non_borrower_rejected`: asserts `UnauthorizedRepayer` (#14) when caller is not the borrower
- `test_repay_installment_zero_amount_rejected`: asserts `InvalidRepaymentAmount` (#13) for zero payment
- Total tests: 98 (93 existing + 5 new) — all passing

---

## In Progress
Expand All @@ -74,7 +83,6 @@ Update this file after every completed contract change, fix, or architectural de
- Should the vouching contract be a standalone crate or logic added to `creditline-contract`? (Leaning toward standalone for modularity)
- What is the correct `grace_period_seconds` for learner installment loans? (Longer than standard BNPL — possibly 7-14 days per installment)
- Should sponsor pool deposits go through `liquidity-pool-contract` or a new `sponsor-pool-contract`?
- `repay_installment()` needs dedicated tests: happy path, double-pay rejection, out-of-bounds index, non-borrower rejection.

---

Expand Down
115 changes: 115 additions & 0 deletions contracts/creditline-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2820,3 +2820,118 @@ fn test_approve_loan_not_admin() {
let result = t.client.try_approve_loan(&loan_id);
assert!(result.is_err(), "expected auth error when caller is not admin");
}

// ─── repay_installment tests ──────────────────────────────────────────────────

/// Helper: creates a loan with `n_installments` equal-valued installments
/// using DEFAULT_PRINCIPAL and a generated vendor. Returns (loan_id, vendor).
fn setup_loan_with_schedule(
t: &TestCtx,
borrower: &Address,
n_installments: u32,
) -> (u64, Address) {
let vendor = Address::generate(&t.env);
t.register_vendor(&vendor, "Test Vendor");
t.mint(borrower, DEFAULT_GUARANTEE);

let due_date = t.env.ledger().timestamp() + 10_000;
let installment_amount = DEFAULT_TOTAL_DUE / n_installments as i128;
let mut schedule = soroban_sdk::Vec::new(&t.env);

for i in 0..n_installments {
schedule.push_back(RepaymentInstallment {
amount: installment_amount,
due_date: due_date + (i as u64 * 10_000),
paid: false,
paid_at: 0,
});
}

let loan_id = t.client.create_loan(
borrower,
&vendor,
&DEFAULT_PRINCIPAL,
&DEFAULT_GUARANTEE,
&schedule,
&LoanType::Standard,
);
(loan_id, vendor)
}

#[test]
fn test_repay_installment_happy_path() {
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);

t.env.ledger().set_timestamp(5000);
let remaining = t.client.repay_installment(&user, &loan_id, &0, &payment);

let loan = t.client.get_loan(&loan_id);
let installment = loan.repayment_schedule.get(0).unwrap();
assert!(installment.paid);
assert_eq!(installment.paid_at, 5000);
assert_eq!(loan.remaining_balance, DEFAULT_TOTAL_DUE - payment);
assert_eq!(remaining, DEFAULT_TOTAL_DUE - payment);
assert_eq!(loan.status, LoanStatus::Active);
// Second installment remains unpaid
let inst2 = loan.repayment_schedule.get(1).unwrap();
assert!(!inst2.paid);
assert_eq!(inst2.paid_at, 0);
}

#[test]
#[should_panic(expected = "Error(Contract, #24)")]
fn test_repay_installment_double_pay_rejected() {
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 * 2);

t.client.repay_installment(&user, &loan_id, &0, &payment);
t.client.repay_installment(&user, &loan_id, &0, &payment);
}

#[test]
#[should_panic(expected = "Error(Contract, #23)")]
fn test_repay_installment_out_of_bounds() {
let t = TestCtx::setup();
let user = Address::generate(&t.env);

let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2);

// Index 2 is out of bounds for a 2-installment schedule (valid: 0, 1)
t.client.repay_installment(&user, &loan_id, &2, &500);
}

#[test]
#[should_panic(expected = "Error(Contract, #14)")]
fn test_repay_installment_non_borrower_rejected() {
let t = TestCtx::setup();
let user = Address::generate(&t.env);
let intruder = Address::generate(&t.env);

let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2);

// A different address cannot repay the borrower's installment
t.client.repay_installment(&intruder, &loan_id, &0, &500);
}

#[test]
#[should_panic(expected = "Error(Contract, #13)")]
fn test_repay_installment_zero_amount_rejected() {
let t = TestCtx::setup();
let user = Address::generate(&t.env);

let (loan_id, _vendor) = setup_loan_with_schedule(&t, &user, 2);

// Zero-amount payment must be rejected
t.client.repay_installment(&user, &loan_id, &0, &0);
}
Loading