From 69fbacbf4faf50039e617266ca904304d76f09b0 Mon Sep 17 00:00:00 2001 From: Stephan-Thomas Date: Wed, 26 Aug 2026 23:02:38 +0100 Subject: [PATCH] feat(invoice-escrow): implement configurable grace period window for overdue invoice settlement --- contracts/invoice-escrow/src/errors.rs | 4 ++ contracts/invoice-escrow/src/events.rs | 6 +++ contracts/invoice-escrow/src/lib.rs | 25 ++++++++- contracts/invoice-escrow/src/test.rs | 71 ++++++++++++++++++++++++++ contracts/invoice-escrow/src/types.rs | 2 + 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index 8bea222..2c9fb23 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -90,4 +90,8 @@ pub enum Error { InvalidLimit = 37, /// Pagination limit exceeds maximum allowed page size. LimitExceeded = 38, + /// Escrow payment attempted after grace period has expired. + EscrowOverdue = 39, + /// Escrow refund attempted before grace period has expired. + EscrowNotOverdue = 40, } diff --git a/contracts/invoice-escrow/src/events.rs b/contracts/invoice-escrow/src/events.rs index 4bd1ac8..efcf8ea 100644 --- a/contracts/invoice-escrow/src/events.rs +++ b/contracts/invoice-escrow/src/events.rs @@ -195,3 +195,9 @@ pub fn funding_finalised(env: &Env, invoice_id: BytesN<32>, total_raised: i128, (total_raised, seller.clone()), ); } + +/// Publish grace period expired event. +pub fn grace_period_expired(env: &Env, inv_id: Symbol) { + env.events() + .publish((Symbol::new(env, "grace_period_expired"),), inv_id); +} diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index 8929b30..d4b2349 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -75,11 +75,24 @@ impl InvoiceEscrow { paused: false, whitelist_enabled: false, min_investment: 0, + grace_period_seconds: 0, }; storage::set_config(&env, &config); Ok(()) } + /// Admin-only: set the grace period window for overdue invoice settlement. + pub fn set_grace_period(env: Env, admin: Address, grace_period_seconds: u64) -> Result<(), Error> { + admin.require_auth(); + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + config.grace_period_seconds = grace_period_seconds; + storage::set_config(&env, &config); + Ok(()) + } + /// Admin-only: set the minimum investment amount for `fund_escrow`. /// Pass `0` to disable the floor (deposits must still be strictly positive). pub fn set_min_investment(env: Env, admin: Address, min_investment: i128) -> Result<(), Error> { @@ -512,6 +525,12 @@ impl InvoiceEscrow { return Err(Error::AlreadySettled); } + let ledger_ts = env.ledger().timestamp(); + let final_deadline = data.due_dt.checked_add(config.grace_period_seconds).unwrap_or(u64::MAX); + if ledger_ts > final_deadline { + return Err(Error::EscrowOverdue); + } + // Remaining balance toward face_value let remaining = data .face_value @@ -639,8 +658,9 @@ impl InvoiceEscrow { return Err(Error::RefundNotAllowed); } let ledger_ts = env.ledger().timestamp(); - if ledger_ts < data.due_dt { - return Err(Error::RefundNotAllowed); + let final_deadline = data.due_dt.checked_add(config.grace_period_seconds).unwrap_or(u64::MAX); + if ledger_ts <= final_deadline { + return Err(Error::EscrowNotOverdue); } // Refund the remaining collateral (purchase_price minus already released partial payments) @@ -710,6 +730,7 @@ impl InvoiceEscrow { soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], ); + events::grace_period_expired(&env, invoice_id.clone()); events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); events::escrow_status_changed( &env, diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index 62e9fc1..96d53aa 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -8617,3 +8617,74 @@ fn test_fund_escrow_signed_future_timestamp_succeeds() { let result = c.fund_escrow_signed(&Symbol::new(&env, "inv1"), &buyer, &500, &1, &(now + 3600)); assert!(result.is_ok()); } +#[test] +fn test_grace_period_settlement_succeeds() { + let env = Env::default(); + let test_env = MockTokenEnvironment::new(&env, 300, 1000, 1000); + test_env.escrow_client.set_grace_period(&test_env.admin, &86400); // 24 hours + test_env.fund(1000); + + // Get the due date + let data = test_env.escrow_client.get_escrow(&test_env.invoice_id); + + // Set timestamp to exactly grace period end (within grace period) + env.ledger().with_mut(|li| li.timestamp = data.due_dt + 86400); + + test_env.record_payment(1000); + assert_eq!( + test_env.escrow_client.get_escrow_status(&test_env.invoice_id), + EscrowStatus::Settled + ); +} + +#[test] +fn test_grace_period_settlement_rejected() { + let env = Env::default(); + let test_env = MockTokenEnvironment::new(&env, 300, 1000, 1000); + test_env.escrow_client.set_grace_period(&test_env.admin, &86400); // 24 hours + test_env.fund(1000); + + let data = test_env.escrow_client.get_escrow(&test_env.invoice_id); + + // Set timestamp to after grace period + env.ledger().with_mut(|li| li.timestamp = data.due_dt + 86401); + + let res = test_env.escrow_client.try_record_payment(&test_env.invoice_id, &test_env.payer, &1000); + assert_eq!(res.unwrap_err().unwrap(), Error::EscrowOverdue); +} + +#[test] +fn test_grace_period_refund_rejected_before_expiration() { + let env = Env::default(); + let test_env = MockTokenEnvironment::new(&env, 300, 1000, 1000); + test_env.escrow_client.set_grace_period(&test_env.admin, &86400); // 24 hours + test_env.fund(1000); + + let data = test_env.escrow_client.get_escrow(&test_env.invoice_id); + + // Set timestamp to exactly grace period end (not yet expired) + env.ledger().with_mut(|li| li.timestamp = data.due_dt + 86400); + + let res = test_env.escrow_client.try_refund(&test_env.invoice_id); + assert_eq!(res.unwrap_err().unwrap(), Error::EscrowNotOverdue); +} + +#[test] +fn test_grace_period_refund_succeeds_after_expiration() { + let env = Env::default(); + let test_env = MockTokenEnvironment::new(&env, 300, 1000, 1000); + test_env.escrow_client.set_grace_period(&test_env.admin, &86400); // 24 hours + test_env.fund(1000); + + let data = test_env.escrow_client.get_escrow(&test_env.invoice_id); + + // Set timestamp to after grace period + env.ledger().with_mut(|li| li.timestamp = data.due_dt + 86401); + + test_env.escrow_client.refund(&test_env.invoice_id); + assert_eq!( + test_env.escrow_client.get_escrow_status(&test_env.invoice_id), + EscrowStatus::Refunded + ); +} + diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index c7145d4..d79ada9 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -51,6 +51,8 @@ pub struct Config { /// `0` disables the floor (only `amount > 0` is required). Completing the /// remaining capacity below this floor is always allowed. pub min_investment: i128, + /// Grace period in seconds for overdue invoice settlement. + pub grace_period_seconds: u64, } /// Lifecycle status of an escrow.