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
12 changes: 7 additions & 5 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ mod test_admin;
mod test_admin_simple;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_admin_standalone;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_admin_two_step;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_audit;
Expand All @@ -86,7 +86,7 @@ mod test_backup;
mod test_backup_safety;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_backup_restore_reindex;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_escrow_event_completeness;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_bid_ttl;
Expand All @@ -96,6 +96,8 @@ mod test_cleanup_pagination;
mod test_currency;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_dispute;
#[cfg(test)]
mod test_dispute_refund_flow;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_dispute_timeline_props;
#[cfg(all(test, feature = "legacy-tests"))]
Expand Down Expand Up @@ -147,7 +149,7 @@ mod test_analytics_consistency;
mod test_bid_ranking;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_events;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_pause_reads_available;
#[cfg(all(test, feature = "fuzz-tests"))]
mod test_fuzz_invoice_metadata;
Expand All @@ -166,11 +168,11 @@ mod test_investment_transitions;
mod test_invoice_metadata;
#[cfg(test)]
mod test_invoice_search_ranking;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_rebuild_indexes;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_max_invoices_per_business;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_category_breakdown;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_diagnostics;
Expand Down
220 changes: 220 additions & 0 deletions quicklendx-contracts/src/test_dispute_refund_flow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//! End-to-end dispute-resolution-to-refund regression.
//!
//! The investor remedy is only final if the dispute, invoice, escrow, bid,
//! investment, status indexes, and token balances all agree after the refund.
//! This test drives the full funded invoice -> dispute -> review -> resolution
//! -> refund path and asserts no second refund or settlement can follow.

use super::*;
use crate::errors::QuickLendXError;
use crate::payments::EscrowStatus;
use soroban_sdk::{
testutils::{Address as _, Ledger},
token, Address, BytesN, Env, String, Vec,
};

struct FundedDisputeFixture {
env: Env,
client: QuickLendXContractClient<'static>,
admin: Address,
business: Address,
investor: Address,
currency: Address,
invoice_id: BytesN<32>,
bid_id: BytesN<32>,
bid_amount: i128,
}

fn setup_funded_invoice_for_dispute() -> FundedDisputeFixture {
let env = Env::default();
env.mock_all_auths();
env.ledger().with_mut(|ledger| ledger.timestamp = 1_000_000);

let contract_id = env.register(QuickLendXContract, ());
let client = QuickLendXContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let business = Address::generate(&env);
let investor = Address::generate(&env);

client.set_admin(&admin);
let _ = client.try_initialize_protocol_limits(&admin, &1i128, &365u64, &86_400u64);

let token_admin = Address::generate(&env);
let currency = env
.register_stellar_asset_contract_v2(token_admin)
.address();
let sac = token::StellarAssetClient::new(&env, &currency);
let tok = token::Client::new(&env, &currency);
let initial_balance = 10_000i128;

sac.mint(&business, &initial_balance);
sac.mint(&investor, &initial_balance);
sac.mint(&contract_id, &1i128);

let approval_expiration = env.ledger().sequence() + 10_000;
tok.approve(
&business,
&contract_id,
&initial_balance,
&approval_expiration,
);
tok.approve(
&investor,
&contract_id,
&initial_balance,
&approval_expiration,
);

client.add_currency(&admin, &currency);
client.submit_kyc_application(&business, &String::from_str(&env, "Business KYC"));
client.verify_business(&admin, &business);
client.submit_investor_kyc(&investor, &String::from_str(&env, "Investor KYC"));
client.verify_investor(&investor, &initial_balance);

let invoice_amount = 1_000i128;
let bid_amount = 900i128;
let due_date = env.ledger().timestamp() + 86_400;
let invoice_id = client.upload_invoice(
&business,
&invoice_amount,
&currency,
&due_date,
&String::from_str(&env, "Disputed goods delivery"),
&InvoiceCategory::Goods,
&Vec::new(&env),
);
client.verify_invoice(&invoice_id);

let bid_id = client.place_bid(&investor, &invoice_id, &bid_amount, &invoice_amount);
client.accept_bid_and_fund(&invoice_id, &bid_id);

FundedDisputeFixture {
env,
client,
admin,
business,
investor,
currency,
invoice_id,
bid_id,
bid_amount,
}
}

#[test]
fn dispute_resolved_against_business_refund_aligns_terminal_statuses() {
let fx = setup_funded_invoice_for_dispute();
let tok = token::Client::new(&fx.env, &fx.currency);
let investor_balance_before_refund = tok.balance(&fx.investor);
let business_balance_before_refund = tok.balance(&fx.business);

assert_eq!(
fx.client.get_invoice(&fx.invoice_id).status,
InvoiceStatus::Funded
);
assert_eq!(
fx.client.get_escrow_status(&fx.invoice_id),
EscrowStatus::Held
);
assert_eq!(
fx.client.get_bid(&fx.bid_id).unwrap().status,
BidStatus::Accepted
);
assert_eq!(
fx.client.get_invoice_investment(&fx.invoice_id).status,
InvestmentStatus::Active
);

fx.client.create_dispute(
&fx.invoice_id,
&fx.investor,
&String::from_str(&fx.env, "Delivered goods were rejected by buyer"),
&String::from_str(&fx.env, "Inspection record and delivery photos"),
);
fx.client
.put_dispute_under_review(&fx.invoice_id, &fx.admin);
fx.client.resolve_dispute(
&fx.invoice_id,
&fx.admin,
&String::from_str(&fx.env, "Resolved against business; refund investor escrow"),
);

assert_eq!(
fx.client.get_invoice(&fx.invoice_id).dispute_status,
DisputeStatus::Resolved
);
let dispute = fx
.client
.get_dispute_details(&fx.invoice_id)
.expect("resolved dispute should remain queryable");
assert_eq!(dispute.resolved_by, fx.admin);
assert!(dispute.resolved_at > 0);

fx.client.refund_escrow_funds(&fx.invoice_id, &fx.business);

assert_eq!(
fx.client.get_escrow_status(&fx.invoice_id),
EscrowStatus::Refunded
);

let invoice = fx.client.get_invoice(&fx.invoice_id);
assert_eq!(invoice.status, InvoiceStatus::Refunded);
assert_eq!(invoice.dispute_status, DisputeStatus::Resolved);
assert_eq!(invoice.investor, None);
assert_eq!(invoice.funded_amount, 0);
assert_eq!(invoice.funded_at, None);

assert_eq!(
fx.client.get_bid(&fx.bid_id).unwrap().status,
BidStatus::Cancelled
);
assert_eq!(
fx.client.get_invoice_investment(&fx.invoice_id).status,
InvestmentStatus::Refunded
);

let investor_balance_after_refund = tok.balance(&fx.investor);
let business_balance_after_refund = tok.balance(&fx.business);
assert_eq!(
investor_balance_after_refund - investor_balance_before_refund,
fx.bid_amount
);
assert_eq!(
business_balance_after_refund, business_balance_before_refund,
"refund must return escrow to the investor without crediting the business"
);
assert!(
!fx.client
.get_invoices_by_status(&InvoiceStatus::Funded)
.contains(&fx.invoice_id),
"refunded disputed invoice must leave the Funded index"
);
assert!(
fx.client
.get_invoices_by_status(&InvoiceStatus::Refunded)
.contains(&fx.invoice_id),
"refunded disputed invoice must enter the Refunded index"
);

let retry = fx
.client
.try_refund_escrow_funds(&fx.invoice_id, &fx.business);
assert!(matches!(retry, Err(Ok(QuickLendXError::InvalidStatus))));
assert_eq!(
tok.balance(&fx.investor),
investor_balance_after_refund,
"second refund attempt must not move funds"
);

let settle_after_refund = fx.client.try_settle_invoice(&fx.invoice_id, &fx.bid_amount);
assert!(matches!(
settle_after_refund,
Err(Ok(QuickLendXError::InvalidStatus))
));
assert_eq!(
tok.balance(&fx.investor),
investor_balance_after_refund,
"settlement attempt after refund must not move funds"
);
}
61 changes: 34 additions & 27 deletions quicklendx-contracts/src/test_invoice_search_ranking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
mod test_invoice_search_ranking {
use crate::invoice_search::InvoiceSearch;
use crate::storage::InvoiceStorage;
use crate::types::{Invoice, InvoiceCategory, InvoiceStatus, SearchRank, SearchResult, Dispute};
use crate::types::{
Dispute, Invoice, InvoiceCategory, InvoiceStatus, SearchRank, SearchResult,
};
use crate::QuickLendXContract;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{Address, BytesN, Env, String, Vec};

Expand All @@ -14,6 +17,11 @@ mod test_invoice_search_ranking {
env
}

fn with_contract<T>(env: &Env, f: impl FnOnce() -> T) -> T {
let contract_id = env.register(QuickLendXContract, ());
env.as_contract(&contract_id, f)
}

fn create_test_invoice(
env: &Env,
business: &Address,
Expand Down Expand Up @@ -93,6 +101,7 @@ mod test_invoice_search_ranking {
exact_id_bytes[1] = 0x62;
exact_id_bytes[2] = 0x63;
let exact_id = BytesN::from_array(&env, &exact_id_bytes);
let exact_id_query = "6162630000000000000000000000000000000000000000000000000000000000";

let dispute = Dispute {
created_by: business.clone(),
Expand Down Expand Up @@ -138,29 +147,24 @@ mod test_invoice_search_ranking {
let invoice_partial = create_test_invoice(
&env,
&business,
"this has abc in description",
"this has 6162630000000000000000000000000000000000000000000000000000000000 in description",
None,
None,
1000,
);

// Invoice 3: No match
let invoice_other = create_test_invoice(
&env,
&business,
"unrelated search target",
None,
None,
1000,
);

InvoiceStorage::store_invoice(&env, &invoice_exact);
InvoiceStorage::store_invoice(&env, &invoice_partial);
InvoiceStorage::store_invoice(&env, &invoice_other);
let invoice_other =
create_test_invoice(&env, &business, "unrelated search target", None, None, 1000);

// Query "abc" (corresponds to exact_id hex string)
let query = String::from_str(&env, "6162630000000000000000000000000000000000000000000000000000000000");
let results = InvoiceSearch::search_invoices(&env, query).unwrap();
let query = String::from_str(&env, exact_id_query);
let results = with_contract(&env, || {
InvoiceStorage::store_invoice(&env, &invoice_exact);
InvoiceStorage::store_invoice(&env, &invoice_partial);
InvoiceStorage::store_invoice(&env, &invoice_other);
InvoiceSearch::search_invoices(&env, query).unwrap()
});

// Should return 2 results: exact match first, then partial match. Other should be filtered out.
assert_eq!(results.len(), 2);
Expand All @@ -185,12 +189,13 @@ mod test_invoice_search_ranking {
let invoice_mid = create_test_invoice(&env, &business, "abc mid", None, None, 2000);
let invoice_new = create_test_invoice(&env, &business, "abc new", None, None, 3000);

InvoiceStorage::store_invoice(&env, &invoice_old);
InvoiceStorage::store_invoice(&env, &invoice_mid);
InvoiceStorage::store_invoice(&env, &invoice_new);

let query = String::from_str(&env, "abc");
let results = InvoiceSearch::search_invoices(&env, query).unwrap();
let results = with_contract(&env, || {
InvoiceStorage::store_invoice(&env, &invoice_old);
InvoiceStorage::store_invoice(&env, &invoice_mid);
InvoiceStorage::store_invoice(&env, &invoice_new);
InvoiceSearch::search_invoices(&env, query).unwrap()
});

assert_eq!(results.len(), 3);

Expand All @@ -216,6 +221,7 @@ mod test_invoice_search_ranking {
exact_id_bytes[1] = 0x79;
exact_id_bytes[2] = 0x7a;
let exact_id = BytesN::from_array(&env, &exact_id_bytes);
let exact_id_query = "78797a0000000000000000000000000000000000000000000000000000000000";

let dispute = Dispute {
created_by: business.clone(),
Expand Down Expand Up @@ -261,18 +267,19 @@ mod test_invoice_search_ranking {
let invoice_partial = create_test_invoice(
&env,
&business,
"this has 78797a in description",
"this has 78797a0000000000000000000000000000000000000000000000000000000000 in description",
None,
None,
5000,
);

InvoiceStorage::store_invoice(&env, &invoice_exact);
InvoiceStorage::store_invoice(&env, &invoice_partial);

// Query by the hex string of exact_id
let query = String::from_str(&env, "78797a0000000000000000000000000000000000000000000000000000000000");
let results = InvoiceSearch::search_invoices(&env, query).unwrap();
let query = String::from_str(&env, exact_id_query);
let results = with_contract(&env, || {
InvoiceStorage::store_invoice(&env, &invoice_exact);
InvoiceStorage::store_invoice(&env, &invoice_partial);
InvoiceSearch::search_invoices(&env, query).unwrap()
});

assert_eq!(results.len(), 2);
// Exact ID must be first, despite having a lower created_at timestamp
Expand Down
Loading