diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index 7a84e30..8203ae8 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -37,7 +37,9 @@ pub enum Error { /// Contract is paused and the requested operation is temporarily disabled. Paused = 15, /// Payer is not the authorized debtor for this invoice. - InvalidPayer = 15, + InvalidPayer = 16, /// Due date is invalid (e.g., in the past or zero). - InvalidDueDate = 16, + InvalidDueDate = 17, + /// Payment was attempted after the overdue grace period closed. + PaymentWindowExpired = 18, } diff --git a/contracts/invoice-escrow/src/events.rs b/contracts/invoice-escrow/src/events.rs index c480bcd..2125e82 100644 --- a/contracts/invoice-escrow/src/events.rs +++ b/contracts/invoice-escrow/src/events.rs @@ -60,6 +60,14 @@ pub fn payment_settled( ); } +/// Publish grace-period settlement event. +pub fn payment_grace_used(env: &Env, inv_id: Symbol, paid_at: u64, grace_end: u64) { + env.events().publish( + (Symbol::new(env, "grace_pay"),), + (inv_id, paid_at, grace_end), + ); +} + /// Publish refund event. pub fn escrow_refunded(env: &Env, inv_id: Symbol, amount: i128) { env.events() diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index cddf3b7..406bd06 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -21,6 +21,8 @@ use errors::Error; const MAX_BPS: u32 = 10_000; const DISTRIBUTE_PAYMENT_FN: &str = "distribute_payment"; const DISTRIBUTE_REFUND_FN: &str = "distribute_refund"; +/// Overdue invoices may still be settled for seven days after the due date. +const PAYMENT_GRACE_PERIOD_SECONDS: u64 = 7 * 24 * 60 * 60; #[contract] pub struct InvoiceEscrow; @@ -92,6 +94,9 @@ impl InvoiceEscrow { funded_amt: 0, funder: None, due_dt: due_date, + grace_end: due_date + .checked_add(PAYMENT_GRACE_PERIOD_SECONDS) + .ok_or(Error::Overflow)?, token: payment_token.clone(), inv_token: invoice_token.clone(), paid_amt: 0, @@ -241,6 +246,11 @@ impl InvoiceEscrow { return Err(Error::AlreadySettled); } + let ledger_ts = env.ledger().timestamp(); + if ledger_ts > data.grace_end { + return Err(Error::PaymentWindowExpired); + } + // Remaining balance toward face_value let remaining = data .face_value @@ -337,6 +347,9 @@ impl InvoiceEscrow { ); } + if ledger_ts > data.due_dt { + events::payment_grace_used(&env, invoice_id.clone(), ledger_ts, data.grace_end); + } events::payment_settled(&env, invoice_id, amount, platform_fee, investor_amount); Ok(()) } @@ -352,7 +365,7 @@ impl InvoiceEscrow { return Err(Error::RefundNotAllowed); } let ledger_ts = env.ledger().timestamp(); - if ledger_ts < data.due_dt { + if ledger_ts <= data.grace_end { return Err(Error::RefundNotAllowed); } diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index 944b2d0..20803fc 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -374,7 +374,7 @@ fn test_escrow_refunded_event() { escrow_client.fund_escrow(&invoice_id, &buyer, &amount); // Set ledger timestamp past due date to allow refund - env.ledger().with_mut(|li| li.timestamp = due_date + 1); + env.ledger().with_mut(|li| li.timestamp = due_date + 604800 + 1); escrow_client.refund(&invoice_id); @@ -949,6 +949,95 @@ fn test_refund_not_funded() { assert_eq!(result, Err(Ok(Error::RefundNotAllowed))); } + +#[test] +fn test_record_payment_within_overdue_grace_period_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id); + + let admin = Address::generate(&env); + let payment_token_admin = Address::generate(&env); + let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone()); + let payment_token_asset = AssetClient::new(&env, &payment_token_id.address()); + let inv_token_id = env.register(MockInvoiceToken, ()); + + escrow_client.initialize(&admin, &300); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let payer = Address::generate(&env); + let invoice_id = Symbol::new(&env, "GRACE1"); + let due_date = 10000u64; + + payment_token_asset.mint(&buyer, &1000); + payment_token_asset.mint(&payer, &1000); + + escrow_client.create_escrow( + &invoice_id, + &seller, + &payer, + &1000, + &1000, + &due_date, + &payment_token_id.address(), + &inv_token_id, + &test_commitment(&env, "grace_payment_test"), + ); + escrow_client.fund_escrow(&invoice_id, &buyer, &1000); + + env.ledger().with_mut(|li| li.timestamp = due_date + 604800); + + escrow_client.record_payment(&invoice_id, &payer, &1000); + assert_eq!(escrow_client.get_escrow_status(&invoice_id), EscrowStatus::Settled); +} + +#[test] +fn test_record_payment_after_grace_period_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id); + + let admin = Address::generate(&env); + let payment_token_admin = Address::generate(&env); + let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone()); + let payment_token_asset = AssetClient::new(&env, &payment_token_id.address()); + let inv_token_id = env.register(MockInvoiceToken, ()); + + escrow_client.initialize(&admin, &300); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let payer = Address::generate(&env); + let invoice_id = Symbol::new(&env, "GRACE2"); + let due_date = 10000u64; + + payment_token_asset.mint(&buyer, &1000); + payment_token_asset.mint(&payer, &1000); + + escrow_client.create_escrow( + &invoice_id, + &seller, + &payer, + &1000, + &1000, + &due_date, + &payment_token_id.address(), + &inv_token_id, + &test_commitment(&env, "expired_payment_test"), + ); + escrow_client.fund_escrow(&invoice_id, &buyer, &1000); + + env.ledger().with_mut(|li| li.timestamp = due_date + 604800 + 1); + + let result = escrow_client.try_record_payment(&invoice_id, &payer, &1000); + assert_eq!(result, Err(Ok(Error::PaymentWindowExpired))); +} + // ========== Refund Timing Tests ========== #[test] @@ -997,7 +1086,7 @@ fn test_refund_before_due_date() { } #[test] -fn test_refund_at_due_date() { +fn test_refund_at_due_date_blocked_by_grace_period() { let env = Env::default(); env.mock_all_auths(); @@ -1037,14 +1126,15 @@ fn test_refund_at_due_date() { // Set time exactly at due date env.ledger().with_mut(|li| li.timestamp = due_date); - // Refund should succeed - escrow_client.refund(&invoice_id); + // Refund should fail during the grace period + let result = escrow_client.try_refund(&invoice_id); + assert_eq!(result, Err(Ok(Error::RefundNotAllowed))); - // Verify buyer got refund - assert_eq!(payment_token.balance(&buyer), 1000); + // Verify escrow remains funded while the grace period is open + assert_eq!(payment_token.balance(&buyer), 0); assert_eq!( escrow_client.get_escrow_status(&invoice_id), - EscrowStatus::Refunded + EscrowStatus::Funded ); } @@ -1086,8 +1176,8 @@ fn test_refund_after_due_date() { escrow_client.fund_escrow(&invoice_id, &buyer, &1000); - // Set time after due date - env.ledger().with_mut(|li| li.timestamp = due_date + 5000); + // Set time after the seven-day grace period + env.ledger().with_mut(|li| li.timestamp = due_date + 604800 + 1); // Refund should succeed escrow_client.refund(&invoice_id); @@ -1533,8 +1623,8 @@ fn test_refund_after_partial_payment() { // Balances now: Contract 700, Seller 300, Buyer 291, Admin 9. assert_eq!(payment_token.balance(&escrow_id), 700); - // Advance time - env.ledger().with_mut(|li| li.timestamp = due_date + 1); + // Advance time beyond the overdue grace period + env.ledger().with_mut(|li| li.timestamp = due_date + 604800 + 1); // Refund escrow_client.refund(&invoice_id); diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index 7c62d73..33f456b 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -66,6 +66,8 @@ pub struct EscrowData { pub funder: Option, /// Due date (ledger timestamp). pub due_dt: u64, + /// Last ledger timestamp at which overdue payment is still accepted. + pub grace_end: u64, /// Payment token contract address. pub token: soroban_sdk::Address, /// Invoice token contract address (ownership/claim).