Skip to content
Open
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
6 changes: 4 additions & 2 deletions contracts/invoice-escrow/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
8 changes: 8 additions & 0 deletions contracts/invoice-escrow/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 14 additions & 1 deletion contracts/invoice-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(())
}
Expand All @@ -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);
}

Expand Down
112 changes: 101 additions & 11 deletions contracts/invoice-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions contracts/invoice-escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ pub struct EscrowData {
pub funder: Option<soroban_sdk::Address>,
/// 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).
Expand Down