diff --git a/.codex b/.codex
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/contracts/invoice-lifecycle.md b/docs/contracts/invoice-lifecycle.md
index e726b9c3..983c11c5 100644
--- a/docs/contracts/invoice-lifecycle.md
+++ b/docs/contracts/invoice-lifecycle.md
@@ -1,6 +1,11 @@
# Invoice Lifecycle Management
-This document describes the invoice lifecycle management functionality in the QuickLendX protocol, including invoice upload, verification/approval, and cancellation.
+This document describes the invoice lifecycle management functionality in the QuickLendX protocol, including invoice upload, verification/approval, and cancellation.
+
+> Note
+> `update_invoice_status` is an admin-only recovery/backfill pathway. It is not a
+> replacement for the normal `accept_bid`, `settle_invoice`, or overdue-default
+> flows, and it intentionally avoids escrow and payment side effects.
## Overview
@@ -84,7 +89,7 @@ Allows an admin or oracle to verify an uploaded invoice, making it available for
---
-### 3. `cancel_invoice`
+### 3. `cancel_invoice`
Allows a business to cancel their own invoice before it has been funded by an investor.
@@ -111,10 +116,58 @@ Allows a business to cancel their own invoice before it has been funded by an in
**Failure Cases**:
- `InvoiceNotFound` - Invoice does not exist
- `Unauthorized` - Caller is not the business owner
-- `InvalidStatus` - Invoice is already funded, paid, defaulted, or cancelled
-
----
-### 4. `refund_escrow_funds`
+- `InvalidStatus` - Invoice is already funded, paid, defaulted, or cancelled
+
+---
+### 4. `update_invoice_status`
+
+Allows the configured admin to move an invoice through a limited recovery path
+when tests, migrations, or operational repair require a manual state correction.
+
+**Authorization**: Admin only (requires authentication)
+
+**Parameters**:
+- `env: Env` - Contract environment
+- `invoice_id: BytesN<32>` - ID of the invoice to update
+- `new_status: InvoiceStatus` - Target lifecycle status
+
+**Returns**: `Result<(), QuickLendXError>` - Success or error
+
+**Supported transitions**:
+- `Pending` → `Verified`
+- `Verified` → `Funded`
+- `Funded` → `Paid`
+- `Funded` → `Defaulted`
+
+**Unsupported transitions**:
+- Any transition targeting `Pending`, `Cancelled`, or `Refunded`
+- Any transition from terminal invoices (`Cancelled`, `Refunded`)
+- Any transition that skips the supported recovery path, such as `Verified` → `Paid`
+
+**Index updates**:
+- Removes the invoice ID from the previous status bucket before persisting
+- Adds the invoice ID to the new status bucket after persisting
+- Keeps `get_invoices_by_status` and `get_invoice_count_by_status` aligned
+
+**Events Emitted**:
+- `inv_ver` when moving to `Verified`
+- `inv_fnd` when moving to `Funded`
+- `inv_set` when moving to `Paid` through the admin override path
+- `inv_def` when moving to `Defaulted`
+
+**Security Notes**:
+- The function requires the stored admin address and fails with `NotAdmin` if none is configured
+- Manual `Paid` updates emit the canonical settlement event with zeroed settlement values because no payment transfer is executed by this pathway
+- Manual `Funded` updates are bookkeeping-only and do not create escrow or investment records
+- Production flows should prefer `verify_invoice`, `accept_bid`, `settle_invoice`, and `mark_invoice_defaulted`
+
+**Failure Cases**:
+- `NotAdmin` - No admin configured
+- `InvoiceNotFound` - Invoice does not exist
+- `InvalidStatus` - Unsupported target status or invalid transition
+
+---
+### 5. `refund_escrow_funds`
Allows an admin or the business owner to refund a funded invoice, returning funds to the investor.
@@ -159,10 +212,11 @@ Allows an admin or the business owner to refund a funded invoice, returning fund
- Can update invoice metadata
- Can update invoice category and tags
-### Admin/Oracle
-- Can verify invoices
-- Can reject verification
-- Can set admin address
+### Admin/Oracle
+- Can verify invoices
+- Can run the constrained `update_invoice_status` recovery path
+- Can reject verification
+- Can set admin address
### Investor
- Cannot directly interact with invoice lifecycle (can only bid on verified invoices)
@@ -237,7 +291,10 @@ cancel_invoice(env, invoice_id)?;
## Security Considerations
-1. **Authentication**: All state-changing operations require proper authentication
+1. **Authentication**: All state-changing operations require proper authentication
+2. **Recovery pathway isolation**: `update_invoice_status` is admin-only and does not move funds
+3. **Index consistency**: Status-list removals/additions happen in the same override operation
+4. **Canonical events**: Admin overrides emit the same lifecycle topics used by normal flows so indexers do not need a separate schema
- `upload_invoice`: Business must authenticate
- `verify_invoice`: Admin must authenticate
- `cancel_invoice`: Business owner must authenticate
diff --git a/quicklendx-contracts/src/currency.rs b/quicklendx-contracts/src/currency.rs
index 64946c64..25613590 100644
--- a/quicklendx-contracts/src/currency.rs
+++ b/quicklendx-contracts/src/currency.rs
@@ -17,7 +17,7 @@ impl CurrencyWhitelist {
admin: &Address,
currency: &Address,
) -> Result<(), QuickLendXError> {
- AdminStorage::require_admin_auth(env, admin)?;
+ AdminStorage::require_admin(env, admin)?;
let mut list = Self::get_whitelisted_currencies(env);
if list.iter().any(|a| a == *currency) {
@@ -86,7 +86,7 @@ impl CurrencyWhitelist {
admin: &Address,
currencies: &Vec
,
) -> Result<(), QuickLendXError> {
- AdminStorage::require_admin_auth(env, admin)?;
+ AdminStorage::require_admin(env, admin)?;
let mut deduped: Vec = Vec::new(env);
for currency in currencies.iter() {
diff --git a/quicklendx-contracts/src/dispute.rs b/quicklendx-contracts/src/dispute.rs
index 2d63ffb4..4228b44a 100644
--- a/quicklendx-contracts/src/dispute.rs
+++ b/quicklendx-contracts/src/dispute.rs
@@ -1,2 +1,171 @@
+use crate::admin::AdminStorage;
+use crate::errors::QuickLendXError;
+use crate::invoice::{Dispute, DisputeStatus, InvoiceStatus, InvoiceStorage};
+use crate::protocol_limits::{
+ MAX_DISPUTE_EVIDENCE_LENGTH, MAX_DISPUTE_REASON_LENGTH, MAX_DISPUTE_RESOLUTION_LENGTH,
+};
+use soroban_sdk::{symbol_short, Address, BytesN, Env, String, Vec};
+
+fn dispute_index_key() -> soroban_sdk::Symbol {
+ symbol_short!("dispute")
+}
+
+fn get_dispute_index(env: &Env) -> Vec> {
+ env.storage()
+ .instance()
+ .get(&dispute_index_key())
+ .unwrap_or_else(|| Vec::new(env))
+}
+
+fn add_to_dispute_index(env: &Env, invoice_id: &BytesN<32>) {
+ let mut ids = get_dispute_index(env);
+ if !ids.iter().any(|id| id == *invoice_id) {
+ ids.push_back(invoice_id.clone());
+ env.storage().instance().set(&dispute_index_key(), &ids);
+ }
+}
+
+fn zero_address(env: &Env) -> Address {
+ Address::from_str(env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")
+}
+
+#[allow(dead_code)]
+pub fn create_dispute(
+ env: &Env,
+ invoice_id: &BytesN<32>,
+ creator: &Address,
+ reason: &String,
+ evidence: &String,
+) -> Result<(), QuickLendXError> {
+ creator.require_auth();
+
+ let mut invoice =
+ InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;
+
+ if invoice.dispute_status != DisputeStatus::None {
+ return Err(QuickLendXError::DisputeAlreadyExists);
+ }
+
+ match invoice.status {
+ InvoiceStatus::Pending
+ | InvoiceStatus::Verified
+ | InvoiceStatus::Funded
+ | InvoiceStatus::Paid => {}
+ _ => return Err(QuickLendXError::InvalidStatus),
+ }
+
+ let is_business = *creator == invoice.business;
+ let is_investor = invoice
+ .investor
+ .as_ref()
+ .map_or(false, |investor| *creator == *investor);
+ if !is_business && !is_investor {
+ return Err(QuickLendXError::DisputeNotAuthorized);
+ }
+
+ if reason.len() == 0 || reason.len() > MAX_DISPUTE_REASON_LENGTH {
+ return Err(QuickLendXError::InvalidDisputeReason);
+ }
+ if evidence.len() == 0 || evidence.len() > MAX_DISPUTE_EVIDENCE_LENGTH {
+ return Err(QuickLendXError::InvalidDisputeEvidence);
+ }
+
+ invoice.dispute_status = DisputeStatus::Disputed;
+ invoice.dispute = Dispute {
+ created_by: creator.clone(),
+ created_at: env.ledger().timestamp(),
+ reason: reason.clone(),
+ evidence: evidence.clone(),
+ resolution: String::from_str(env, ""),
+ resolved_by: zero_address(env),
+ resolved_at: 0,
+ };
+
+ InvoiceStorage::update_invoice(env, &invoice);
+ add_to_dispute_index(env, invoice_id);
+ Ok(())
+}
+
+#[allow(dead_code)]
+pub fn put_dispute_under_review(
+ env: &Env,
+ admin: &Address,
+ invoice_id: &BytesN<32>,
+) -> Result<(), QuickLendXError> {
+ AdminStorage::require_admin(env, admin)?;
+ let mut invoice =
+ InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;
+
+ if invoice.dispute_status == DisputeStatus::None {
+ return Err(QuickLendXError::DisputeNotFound);
+ }
+ if invoice.dispute_status != DisputeStatus::Disputed {
+ return Err(QuickLendXError::InvalidStatus);
+ }
+
+ invoice.dispute_status = DisputeStatus::UnderReview;
+ InvoiceStorage::update_invoice(env, &invoice);
+ Ok(())
+}
+
+#[allow(dead_code)]
+pub fn resolve_dispute(
+ env: &Env,
+ admin: &Address,
+ invoice_id: &BytesN<32>,
+ resolution: &String,
+) -> Result<(), QuickLendXError> {
+ AdminStorage::require_admin(env, admin)?;
+ let mut invoice =
+ InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;
+
+ if invoice.dispute_status == DisputeStatus::None {
+ return Err(QuickLendXError::DisputeNotFound);
+ }
+ if invoice.dispute_status != DisputeStatus::UnderReview {
+ return Err(QuickLendXError::DisputeNotUnderReview);
+ }
+ if resolution.len() == 0 || resolution.len() > MAX_DISPUTE_RESOLUTION_LENGTH {
+ return Err(QuickLendXError::InvalidDisputeReason);
+ }
+
+ invoice.dispute_status = DisputeStatus::Resolved;
+ invoice.dispute.resolution = resolution.clone();
+ invoice.dispute.resolved_by = admin.clone();
+ invoice.dispute.resolved_at = env.ledger().timestamp();
+ InvoiceStorage::update_invoice(env, &invoice);
+ Ok(())
+}
+
+#[allow(dead_code)]
+pub fn get_dispute_details(env: &Env, invoice_id: &BytesN<32>) -> Option {
+ let invoice = InvoiceStorage::get_invoice(env, invoice_id)?;
+ if invoice.dispute_status == DisputeStatus::None {
+ None
+ } else {
+ Some(invoice.dispute)
+ }
+}
+
+#[allow(dead_code)]
+pub fn get_invoices_with_disputes(env: &Env) -> Vec> {
+ get_dispute_index(env)
+}
+
+#[allow(dead_code)]
+pub fn get_invoices_by_dispute_status(
+ env: &Env,
+ status: &DisputeStatus,
+) -> Vec> {
+ let mut result = Vec::new(env);
+ for invoice_id in get_dispute_index(env).iter() {
+ if let Some(invoice) = InvoiceStorage::get_invoice(env, &invoice_id) {
+ if invoice.dispute_status == *status {
+ result.push_back(invoice_id);
+ }
+ }
+ }
+ result
+}
//! Invoice disputes are represented on [`crate::invoice::Invoice`] and handled by contract
//! entry points in `lib.rs`. This module is reserved for future dispute-specific helpers.
diff --git a/quicklendx-contracts/src/emergency.rs b/quicklendx-contracts/src/emergency.rs
index a6f6467f..e0fe43a8 100644
--- a/quicklendx-contracts/src/emergency.rs
+++ b/quicklendx-contracts/src/emergency.rs
@@ -102,7 +102,7 @@ impl EmergencyWithdraw {
amount: i128,
target: Address,
) -> Result<(), QuickLendXError> {
- AdminStorage::require_admin_auth(env, admin)?;
+ AdminStorage::require_admin(env, admin)?;
if amount <= 0 {
return Err(QuickLendXError::InvalidAmount);
@@ -173,7 +173,7 @@ impl EmergencyWithdraw {
/// * `EmergencyWithdrawCancelled` if withdrawal was cancelled
/// * Transfer errors (e.g. `InsufficientFunds`) if contract balance is insufficient
pub fn execute(env: &Env, admin: &Address) -> Result<(), QuickLendXError> {
- AdminStorage::require_admin_auth(env, admin)?;
+ AdminStorage::require_admin(env, admin)?;
let pending: PendingEmergencyWithdrawal = env
.storage()
@@ -243,7 +243,7 @@ impl EmergencyWithdraw {
/// * `EmergencyWithdrawNotFound` if no pending withdrawal exists
/// * `EmergencyWithdrawCancelled` if withdrawal is already cancelled
pub fn cancel(env: &Env, admin: &Address) -> Result<(), QuickLendXError> {
- AdminStorage::require_admin_auth(env, admin)?;
+ AdminStorage::require_admin(env, admin)?;
let mut pending: PendingEmergencyWithdrawal = env
.storage()
diff --git a/quicklendx-contracts/src/invoice.rs b/quicklendx-contracts/src/invoice.rs
index 8bea702b..09199be9 100644
--- a/quicklendx-contracts/src/invoice.rs
+++ b/quicklendx-contracts/src/invoice.rs
@@ -568,6 +568,60 @@ impl Invoice {
self.status = InvoiceStatus::Defaulted;
}
+ /// Apply an admin-authorized status override used for recovery, backfills, and tests.
+ ///
+ /// The admin pathway is intentionally narrower than arbitrary mutation: only
+ /// lifecycle statuses that already have index/event support can be targeted.
+ /// The normal user-facing settlement and funding flows should still use
+ /// `accept_bid`, `settle_invoice`, and default handling entrypoints.
+ ///
+ /// # Errors
+ /// Returns [`QuickLendXError::InvalidStatus`] when the requested target status
+ /// is unsupported or when the invoice is already terminal (`Cancelled` or `Refunded`).
+ pub fn apply_admin_status_update(
+ &mut self,
+ env: &Env,
+ admin: &Address,
+ new_status: &InvoiceStatus,
+ ) -> Result<(), QuickLendXError> {
+ if matches!(
+ self.status,
+ InvoiceStatus::Cancelled | InvoiceStatus::Refunded
+ ) {
+ return Err(QuickLendXError::InvalidStatus);
+ }
+
+ match new_status {
+ InvoiceStatus::Verified => {
+ if self.status != InvoiceStatus::Pending {
+ return Err(QuickLendXError::InvalidStatus);
+ }
+ self.verify(env, admin.clone());
+ }
+ InvoiceStatus::Funded => {
+ if self.status != InvoiceStatus::Verified {
+ return Err(QuickLendXError::InvalidStatus);
+ }
+ self.mark_as_funded(env, admin.clone(), self.amount, env.ledger().timestamp());
+ }
+ InvoiceStatus::Paid => {
+ if self.status != InvoiceStatus::Funded {
+ return Err(QuickLendXError::InvalidStatus);
+ }
+ self.mark_as_paid(env, admin.clone(), env.ledger().timestamp());
+ }
+ InvoiceStatus::Defaulted => {
+ if self.status != InvoiceStatus::Funded {
+ return Err(QuickLendXError::InvalidStatus);
+ }
+ self.mark_as_defaulted();
+ }
+ _ => return Err(QuickLendXError::InvalidStatus),
+ }
+
+ Ok(())
+ }
+
/// Cancel the invoice (only if Pending or Verified, not Funded)
pub fn cancel(&mut self, env: &Env, actor: Address) -> Result<(), QuickLendXError> {
// Can only cancel if Pending or Verified (not yet funded)
@@ -799,10 +853,30 @@ impl InvoiceStorage {
(symbol_short!("cat_idx"), category.clone())
}
+ fn metadata_customer_key(customer_name: &String) -> (soroban_sdk::Symbol, String) {
+ (symbol_short!("md_cust"), customer_name.clone())
+ }
+
+ fn metadata_tax_key(tax_id: &String) -> (soroban_sdk::Symbol, String) {
+ (symbol_short!("md_tax"), tax_id.clone())
+ }
+
fn tag_key(tag: &String) -> (soroban_sdk::Symbol, String) {
(symbol_short!("tag_idx"), tag.clone())
}
+ pub fn get_all_categories(env: &Env) -> Vec {
+ let mut categories = Vec::new(env);
+ categories.push_back(InvoiceCategory::Services);
+ categories.push_back(InvoiceCategory::Products);
+ categories.push_back(InvoiceCategory::Consulting);
+ categories.push_back(InvoiceCategory::Manufacturing);
+ categories.push_back(InvoiceCategory::Technology);
+ categories.push_back(InvoiceCategory::Healthcare);
+ categories.push_back(InvoiceCategory::Other);
+ categories
+ }
+
/// @notice Adds an invoice to the category index.
/// @dev Deduplication guard: the invoice ID is appended only if not already
/// present, preventing duplicate entries that would corrupt count queries.
@@ -1179,6 +1253,20 @@ impl InvoiceStorage {
high_rated_invoices
}
+ /// Count invoices that have received at least one rating.
+ pub fn get_invoices_with_ratings_count(env: &Env) -> u32 {
+ let mut count = 0u32;
+ for status in [InvoiceStatus::Funded, InvoiceStatus::Paid].iter() {
+ for invoice_id in Self::get_invoices_by_status(env, status).iter() {
+ if let Some(invoice) = Self::get_invoice(env, &invoice_id) {
+ if invoice.total_ratings > 0 {
+ count = count.saturating_add(1);
+ }
+ }
+ }
+ }
+ count
+ }
fn add_to_metadata_index(
env: &Env,
key: &(soroban_sdk::Symbol, String),
diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs
index cb4d90f7..e3aa8d2f 100644
--- a/quicklendx-contracts/src/lib.rs
+++ b/quicklendx-contracts/src/lib.rs
@@ -8,8 +8,6 @@ mod scratch_events;
mod test_default;
#[cfg(test)]
mod test_fees;
-#[cfg(test)]
-mod test_fees_extended;
use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Map, String, Vec};
mod admin;
@@ -45,7 +43,7 @@ mod test_admin_simple;
#[cfg(test)]
mod test_admin_standalone;
#[cfg(test)]
-mod test_init;
+mod test_investment_consistency;
#[cfg(test)]
mod test_investment_consistency;
#[cfg(test)]
@@ -55,19 +53,13 @@ mod test_max_invoices_per_business;
#[cfg(test)]
mod test_overflow;
#[cfg(test)]
-mod test_pause;
-#[cfg(test)]
mod test_profit_fee;
#[cfg(test)]
-mod test_refund;
-#[cfg(test)]
mod test_storage;
#[cfg(test)]
mod test_string_limits;
#[cfg(test)]
mod test_types;
-#[cfg(test)]
-mod test_vesting;
pub mod types;
pub use invoice::{InvoiceCategory, InvoiceStatus};
mod verification;
@@ -85,8 +77,9 @@ use escrow::{
use events::{
emit_bid_accepted, emit_bid_placed, emit_bid_withdrawn, emit_escrow_created,
emit_escrow_released, emit_insurance_added, emit_insurance_premium_collected,
- emit_investor_verified, emit_invoice_cancelled, emit_invoice_metadata_cleared,
- emit_invoice_metadata_updated, emit_invoice_uploaded, emit_invoice_verified,
+ emit_investor_verified, emit_invoice_cancelled, emit_invoice_defaulted, emit_invoice_funded,
+ emit_invoice_metadata_cleared, emit_invoice_metadata_updated, emit_invoice_settled,
+ emit_invoice_uploaded, emit_invoice_verified,
};
use investment::{InsuranceCoverage, Investment, InvestmentStatus, InvestmentStorage};
use invoice::{Invoice, InvoiceMetadata, InvoiceStorage};
@@ -737,7 +730,17 @@ impl QuickLendXContract {
InvoiceStorage::get_invoices_by_status(&env, &InvoiceStatus::Verified)
}
- /// Update invoice status (admin function)
+ /// Update an invoice status through the admin recovery pathway.
+ ///
+ /// This entrypoint is an admin-only override used for controlled lifecycle
+ /// repairs in tests and operational backfills. It is not the normal funding,
+ /// settlement, or default workflow and therefore emits the canonical
+ /// lifecycle event for the target status without attempting side effects such
+ /// as escrow transfers.
+ ///
+ /// # Errors
+ /// Returns [`QuickLendXError::NotAdmin`] when no admin is configured, and
+ /// [`QuickLendXError::InvalidStatus`] for unsupported or unsafe transitions.
pub fn update_invoice_status(
env: Env,
invoice_id: BytesN<32>,
@@ -746,28 +749,10 @@ impl QuickLendXContract {
pause::PauseControl::require_not_paused(&env)?;
let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id)
.ok_or(QuickLendXError::InvoiceNotFound)?;
-
// Remove from old status list
InvoiceStorage::remove_from_status_invoices(&env, &invoice.status, &invoice_id);
- // Update status
- match new_status {
- InvoiceStatus::Verified => invoice.verify(&env, invoice.business.clone()),
- InvoiceStatus::Paid => {
- invoice.mark_as_paid(&env, invoice.business.clone(), env.ledger().timestamp())
- }
- InvoiceStatus::Defaulted => invoice.mark_as_defaulted(),
- InvoiceStatus::Funded => {
- // For testing purposes - normally funding happens via accept_bid
- invoice.mark_as_funded(
- &env,
- invoice.business.clone(),
- invoice.amount,
- env.ledger().timestamp(),
- );
- }
- _ => return Err(QuickLendXError::InvalidStatus),
- }
+ invoice.apply_admin_status_update(&env, &admin, &new_status)?;
// Store updated invoice
InvoiceStorage::update_invoice(&env, &invoice);
@@ -775,17 +760,14 @@ impl QuickLendXContract {
// Add to new status list
InvoiceStorage::add_to_status_invoices(&env, &invoice.status, &invoice_id);
- // Emit event
- env.events().publish(
- (symbol_short!("updated"),),
- (invoice_id, new_status.clone()),
- );
-
- // Send notifications based on status change
match new_status {
- InvoiceStatus::Verified => {
- // No notifications
+ InvoiceStatus::Verified => emit_invoice_verified(&env, &invoice),
+ InvoiceStatus::Funded => {
+ let investor = invoice.investor.as_ref().unwrap_or(&admin);
+ emit_invoice_funded(&env, &invoice.id, investor, invoice.funded_amount);
}
+ InvoiceStatus::Paid => emit_invoice_settled(&env, &invoice, 0, 0),
+ InvoiceStatus::Defaulted => emit_invoice_defaulted(&env, &invoice),
_ => {}
}
@@ -883,7 +865,7 @@ impl QuickLendXContract {
/// status, this call returns `OperationNotAllowed` without mutating state,
/// preventing double-action execution.
pub fn withdraw_bid(env: Env, bid_id: BytesN<32>) -> Result<(), QuickLendXError> {
- pause::PauseControl::require_not_paused(&env)?;
+ pause::PauseControl::require_not_paused(&env);
let mut bid =
BidStorage::get_bid(&env, &bid_id).ok_or(QuickLendXError::StorageKeyNotFound)?;
bid.investor.require_auth();
@@ -1085,7 +1067,7 @@ impl QuickLendXContract {
provider: Address,
coverage_percentage: u32,
) -> Result<(), QuickLendXError> {
- pause::PauseControl::require_not_paused(&env)?;
+ pause::PauseControl::require_not_paused(&env);
let mut investment = InvestmentStorage::get_investment(&env, &investment_id)
.ok_or(QuickLendXError::StorageKeyNotFound)?;
@@ -1126,7 +1108,7 @@ impl QuickLendXContract {
invoice_id: BytesN<32>,
payment_amount: i128,
) -> Result<(), QuickLendXError> {
- pause::PauseControl::require_not_paused(&env)?;
+ pause::PauseControl::require_not_paused(&env);
let _investment = InvestmentStorage::get_investment_by_invoice(&env, &invoice_id);
let result = reentrancy::with_payment_guard(&env, || {
@@ -1721,7 +1703,7 @@ impl QuickLendXContract {
invoice_id: BytesN<32>,
new_category: invoice::InvoiceCategory,
) -> Result<(), QuickLendXError> {
- pause::PauseControl::require_not_paused(&env)?;
+ pause::PauseControl::require_not_paused(&env);
let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id)
.ok_or(QuickLendXError::InvoiceNotFound)?;
@@ -1759,7 +1741,7 @@ impl QuickLendXContract {
invoice_id: BytesN<32>,
tag: String,
) -> Result<(), QuickLendXError> {
- pause::PauseControl::require_not_paused(&env)?;
+ pause::PauseControl::require_not_paused(&env);
let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id)
.ok_or(QuickLendXError::InvoiceNotFound)?;
@@ -1788,7 +1770,7 @@ impl QuickLendXContract {
invoice_id: BytesN<32>,
tag: String,
) -> Result<(), QuickLendXError> {
- pause::PauseControl::require_not_paused(&env)?;
+ pause::PauseControl::require_not_paused(&env);
let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id)
.ok_or(QuickLendXError::InvoiceNotFound)?;
@@ -2299,7 +2281,7 @@ impl QuickLendXContract {
/// Create a backup of all invoice data (admin only).
pub fn create_backup(env: Env, admin: Address) -> Result, QuickLendXError> {
- pause::PauseControl::require_not_paused(&env)?;
+ pause::PauseControl::require_not_paused(&env);
AdminStorage::require_admin(&env, &admin)?;
let backup_id = backup::BackupStorage::generate_backup_id(&env);
let invoices = backup::BackupStorage::get_all_invoices(&env);
@@ -2520,23 +2502,21 @@ impl QuickLendXContract {
analytics::AnalyticsStorage::get_business_report(&env, &report_id)
}
+ pub fn get_platform_metrics(env: Env) -> Option {
+ analytics::AnalyticsStorage::get_platform_metrics(&env)
+ }
+
+ pub fn get_performance_metrics(env: Env) -> Option {
+ analytics::AnalyticsStorage::get_performance_metrics(&env)
+ }
+
/// Generate an investor report for a specific period
pub fn generate_investor_report(
env: Env,
investor: Address,
- invoice_id: BytesN<32>,
- amount: i128,
- ) -> Result<(), QuickLendXError> {
- investor.require_auth();
- let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id)
- .ok_or(QuickLendXError::InvoiceNotFound)?;
- if invoice.status != InvoiceStatus::Verified {
- return Err(QuickLendXError::InvalidStatus);
- }
- let ts = env.ledger().timestamp();
- invoice.mark_as_funded(&env, investor, amount, ts);
- InvoiceStorage::update_invoice(&env, &invoice);
- Ok(())
+ period: analytics::TimePeriod,
+ ) -> Result {
+ analytics::AnalyticsCalculator::generate_investor_report(&env, &investor, period)
}
// =========================================================================
diff --git a/quicklendx-contracts/src/test/test_status_consistency.rs b/quicklendx-contracts/src/test/test_status_consistency.rs
index 1566fea1..2ec011dc 100644
--- a/quicklendx-contracts/src/test/test_status_consistency.rs
+++ b/quicklendx-contracts/src/test/test_status_consistency.rs
@@ -2,12 +2,14 @@ use super::*;
use crate::invoice::{InvoiceCategory, InvoiceStatus};
use soroban_sdk::{testutils::Address as _, token, Address, BytesN, Env, String, Vec};
-fn setup_env_and_client() -> (Env, QuickLendXContractClient<'static>) {
+fn setup_env_and_client() -> (Env, QuickLendXContractClient<'static>, Address) {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(QuickLendXContract, ());
let client = QuickLendXContractClient::new(&env, &contract_id);
- (env, client)
+ let admin = Address::generate(&env);
+ client.set_admin(&admin);
+ (env, client, admin)
}
fn create_invoice(
@@ -69,7 +71,7 @@ fn assert_status_consistency(
#[test]
fn test_status_list_after_verify() {
- let (env, client) = setup_env_and_client();
+ let (env, client, _admin) = setup_env_and_client();
let business = Address::generate(&env);
let currency = Address::generate(&env);
@@ -92,7 +94,7 @@ fn test_status_list_after_verify() {
#[test]
fn test_status_list_after_cancel() {
- let (env, client) = setup_env_and_client();
+ let (env, client, _admin) = setup_env_and_client();
let business = Address::generate(&env);
let currency = Address::generate(&env);
@@ -114,7 +116,7 @@ fn test_status_list_after_cancel() {
#[test]
fn test_status_list_after_update_invoice_status_funded() {
- let (env, client) = setup_env_and_client();
+ let (env, client, _admin) = setup_env_and_client();
let business = Address::generate(&env);
let currency = Address::generate(&env);
@@ -135,7 +137,7 @@ fn test_status_list_after_update_invoice_status_funded() {
#[test]
fn test_status_list_through_full_lifecycle() {
- let (env, client) = setup_env_and_client();
+ let (env, client, _admin) = setup_env_and_client();
let business = Address::generate(&env);
let currency = Address::generate(&env);
@@ -168,7 +170,7 @@ fn test_status_list_through_full_lifecycle() {
#[test]
fn test_status_list_no_duplicates_on_repeated_add() {
- let (env, client) = setup_env_and_client();
+ let (env, client, _admin) = setup_env_and_client();
let business = Address::generate(&env);
let currency = Address::generate(&env);
@@ -186,7 +188,7 @@ fn test_status_list_no_duplicates_on_repeated_add() {
#[test]
fn test_status_list_multiple_invoices_mixed_transitions() {
- let (env, client) = setup_env_and_client();
+ let (env, client, _admin) = setup_env_and_client();
let business = Address::generate(&env);
let currency = Address::generate(&env);
@@ -258,19 +260,16 @@ fn test_status_list_multiple_invoices_mixed_transitions() {
#[test]
fn test_accept_bid_updates_status_list() {
- let (env, client) = setup_env_and_client();
+ let (env, client, _admin) = setup_env_and_client();
let business = Address::generate(&env);
let investor = Address::generate(&env);
- let admin = Address::generate(&env);
-
let token_admin = Address::generate(&env);
let currency = env.register_stellar_asset_contract(token_admin);
let token_client = token::Client::new(&env, ¤cy);
let token_admin_client = token::StellarAssetClient::new(&env, ¤cy);
token_admin_client.mint(&investor, &10000);
- client.set_admin(&admin);
let due_date = env.ledger().timestamp() + 86400;
let invoice_id = client.store_invoice(
@@ -320,7 +319,7 @@ fn test_accept_bid_updates_status_list() {
#[test]
fn test_count_matches_list_length_all_statuses() {
- let (env, client) = setup_env_and_client();
+ let (env, client, _admin) = setup_env_and_client();
let business = Address::generate(&env);
let currency = Address::generate(&env);
@@ -334,6 +333,7 @@ fn test_count_matches_list_length_all_statuses() {
client.update_invoice_status(&id2, &InvoiceStatus::Verified);
client.update_invoice_status(&id2, &InvoiceStatus::Funded);
client.update_invoice_status(&id3, &InvoiceStatus::Verified);
+ client.update_invoice_status(&id3, &InvoiceStatus::Funded);
client.update_invoice_status(&id3, &InvoiceStatus::Paid);
client.cancel_invoice(&id4);
diff --git a/quicklendx-contracts/src/test_errors.rs b/quicklendx-contracts/src/test_errors.rs
index 550d0338..8fde8b8d 100644
--- a/quicklendx-contracts/src/test_errors.rs
+++ b/quicklendx-contracts/src/test_errors.rs
@@ -355,10 +355,13 @@ fn test_invalid_status_error() {
let business = create_verified_business(&env, &client, &admin);
let invoice_id = create_verified_invoice(&env, &client, &admin, &business, 1000);
- // Try to update status to invalid transition
- let result = client.update_invoice_status(&invoice_id, &crate::invoice::InvoiceStatus::Paid);
- // This might succeed or fail depending on implementation, but should not panic
- let _ = result;
+ // Verified -> Paid must be rejected by the admin override pathway.
+ let result =
+ client.try_update_invoice_status(&invoice_id, &crate::invoice::InvoiceStatus::Paid);
+ assert!(result.is_err());
+ let err = result.err().unwrap();
+ let contract_err = err.expect("expected contract error");
+ assert_eq!(contract_err, QuickLendXError::InvalidStatus);
}
#[test]
diff --git a/quicklendx-contracts/src/test_events.rs b/quicklendx-contracts/src/test_events.rs
index f0afe1c4..f2446e03 100644
--- a/quicklendx-contracts/src/test_events.rs
+++ b/quicklendx-contracts/src/test_events.rs
@@ -23,12 +23,13 @@
use super::*;
use crate::audit::{AuditOperationFilter, AuditQueryFilter};
+use crate::errors::QuickLendXError;
use crate::events::{
TOPIC_BID_ACCEPTED, TOPIC_BID_EXPIRED, TOPIC_BID_PLACED, TOPIC_BID_WITHDRAWN,
- TOPIC_ESCROW_CREATED, TOPIC_ESCROW_REFUNDED, TOPIC_ESCROW_RELEASED,
- TOPIC_INVOICE_CANCELLED, TOPIC_INVOICE_DEFAULTED, TOPIC_INVOICE_EXPIRED,
- TOPIC_INVOICE_SETTLED, TOPIC_INVOICE_UPLOADED, TOPIC_INVOICE_VERIFIED,
- TOPIC_PARTIAL_PAYMENT, TOPIC_PAYMENT_RECORDED, TOPIC_INVOICE_SETTLED_FINAL,
+ TOPIC_ESCROW_CREATED, TOPIC_ESCROW_REFUNDED, TOPIC_ESCROW_RELEASED, TOPIC_INVOICE_CANCELLED,
+ TOPIC_INVOICE_DEFAULTED, TOPIC_INVOICE_EXPIRED, TOPIC_INVOICE_SETTLED,
+ TOPIC_INVOICE_SETTLED_FINAL, TOPIC_INVOICE_UPLOADED, TOPIC_INVOICE_VERIFIED,
+ TOPIC_PARTIAL_PAYMENT, TOPIC_PAYMENT_RECORDED,
};
use crate::invoice::{InvoiceCategory, InvoiceStatus};
use crate::payments::EscrowStatus;
@@ -126,8 +127,7 @@ where
for t in topics.iter() {
if let Ok(s) = soroban_sdk::Symbol::try_from_val(env, &t) {
if s == topic {
- return T::try_from_val(env, &data)
- .expect("payload decode failed");
+ return T::try_from_val(env, &data).expect("payload decode failed");
}
}
}
@@ -165,22 +165,50 @@ fn count_events_with_topic(env: &Env, topic: soroban_sdk::Symbol) -> usize {
#[test]
fn test_topic_constants_are_stable() {
- assert_eq!(TOPIC_INVOICE_UPLOADED, symbol_short!("inv_up"));
- assert_eq!(TOPIC_INVOICE_VERIFIED, symbol_short!("inv_ver"));
- assert_eq!(TOPIC_INVOICE_CANCELLED, symbol_short!("inv_canc"));
- assert_eq!(TOPIC_INVOICE_SETTLED, symbol_short!("inv_set"));
- assert_eq!(TOPIC_INVOICE_DEFAULTED, symbol_short!("inv_def"));
- assert_eq!(TOPIC_INVOICE_EXPIRED, symbol_short!("inv_exp"));
- assert_eq!(TOPIC_PARTIAL_PAYMENT, symbol_short!("inv_pp"));
- assert_eq!(TOPIC_PAYMENT_RECORDED, symbol_short!("pay_rec"));
+ assert_eq!(TOPIC_INVOICE_UPLOADED, symbol_short!("inv_up"));
+ assert_eq!(TOPIC_INVOICE_VERIFIED, symbol_short!("inv_ver"));
+ assert_eq!(TOPIC_INVOICE_CANCELLED, symbol_short!("inv_canc"));
+ assert_eq!(TOPIC_INVOICE_SETTLED, symbol_short!("inv_set"));
+ assert_eq!(TOPIC_INVOICE_DEFAULTED, symbol_short!("inv_def"));
+ assert_eq!(TOPIC_INVOICE_EXPIRED, symbol_short!("inv_exp"));
+ assert_eq!(TOPIC_PARTIAL_PAYMENT, symbol_short!("inv_pp"));
+ assert_eq!(TOPIC_PAYMENT_RECORDED, symbol_short!("pay_rec"));
assert_eq!(TOPIC_INVOICE_SETTLED_FINAL, symbol_short!("inv_stlf"));
- assert_eq!(TOPIC_BID_PLACED, symbol_short!("bid_plc"));
- assert_eq!(TOPIC_BID_ACCEPTED, symbol_short!("bid_acc"));
- assert_eq!(TOPIC_BID_WITHDRAWN, symbol_short!("bid_wdr"));
- assert_eq!(TOPIC_BID_EXPIRED, symbol_short!("bid_exp"));
- assert_eq!(TOPIC_ESCROW_CREATED, symbol_short!("esc_cr"));
- assert_eq!(TOPIC_ESCROW_RELEASED, symbol_short!("esc_rel"));
- assert_eq!(TOPIC_ESCROW_REFUNDED, symbol_short!("esc_ref"));
+ assert_eq!(TOPIC_BID_PLACED, symbol_short!("bid_plc"));
+ assert_eq!(TOPIC_BID_ACCEPTED, symbol_short!("bid_acc"));
+ assert_eq!(TOPIC_BID_WITHDRAWN, symbol_short!("bid_wdr"));
+ assert_eq!(TOPIC_BID_EXPIRED, symbol_short!("bid_exp"));
+ assert_eq!(TOPIC_ESCROW_CREATED, symbol_short!("esc_cr"));
+ assert_eq!(TOPIC_ESCROW_RELEASED, symbol_short!("esc_rel"));
+ assert_eq!(TOPIC_ESCROW_REFUNDED, symbol_short!("esc_ref"));
+}
+
+#[test]
+fn test_admin_update_invoice_status_requires_configured_admin() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let contract_id = env.register(QuickLendXContract, ());
+ let client = QuickLendXContractClient::new(&env, &contract_id);
+ let business = Address::generate(&env);
+ let currency = Address::generate(&env);
+ let due = env.ledger().timestamp() + 86_400;
+
+ let id = client.store_invoice(
+ &business,
+ &INV_AMOUNT,
+ ¤cy,
+ &due,
+ &String::from_str(&env, "missing admin"),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
+ );
+
+ let result = client.try_update_invoice_status(&id, &InvoiceStatus::Verified);
+ assert!(result.is_err());
+ assert_eq!(
+ result.err().unwrap().expect("expected contract error"),
+ QuickLendXError::NotAdmin
+ );
}
// ============================================================================
@@ -199,19 +227,23 @@ fn test_invoice_uploaded_field_order() {
let ts = env.ledger().timestamp();
let due = ts + 86_400;
let id = client.upload_invoice(
- &biz, &INV_AMOUNT, ¤cy, &due,
+ &biz,
+ &INV_AMOUNT,
+ ¤cy,
+ &due,
&String::from_str(&env, "upload field order"),
- &InvoiceCategory::Services, &Vec::new(&env),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
);
let p: (BytesN<32>, Address, i128, Address, u64, u64) =
latest_payload(&env, TOPIC_INVOICE_UPLOADED);
- assert_eq!(p.0, id); // field 0: invoice_id
- assert_eq!(p.1, biz); // field 1: business
- assert_eq!(p.2, INV_AMOUNT); // field 2: amount
- assert_eq!(p.3, currency); // field 3: currency
- assert_eq!(p.4, due); // field 4: due_date
- assert_eq!(p.5, ts); // field 5: timestamp
+ assert_eq!(p.0, id); // field 0: invoice_id
+ assert_eq!(p.1, biz); // field 1: business
+ assert_eq!(p.2, INV_AMOUNT); // field 2: amount
+ assert_eq!(p.3, currency); // field 3: currency
+ assert_eq!(p.4, due); // field 4: due_date
+ assert_eq!(p.5, ts); // field 5: timestamp
}
// ============================================================================
@@ -236,6 +268,33 @@ fn test_invoice_verified_field_order() {
assert_payload(&env, TOPIC_INVOICE_VERIFIED, (id.clone(), biz.clone(), ts));
}
+#[test]
+fn test_admin_update_invoice_status_verified_emits_canonical_event_and_moves_index() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin, cid) = setup(&env);
+ let biz = Address::generate(&env);
+ let currency = mint_currency(&env, &cid, &biz, None);
+ kyc_business(&env, &client, &admin, &biz);
+
+ let (id, _) = upload_invoice(&env, &client, &biz, ¤cy, "admin verify override");
+ let ts = env.ledger().timestamp() + 7;
+ env.ledger().set_timestamp(ts);
+
+ client.update_invoice_status(&id, &InvoiceStatus::Verified);
+
+ assert_payload(&env, TOPIC_INVOICE_VERIFIED, (id.clone(), biz.clone(), ts));
+ assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Verified);
+ assert!(!client
+ .get_invoices_by_status(&InvoiceStatus::Pending)
+ .iter()
+ .any(|existing| existing == id));
+ assert!(client
+ .get_invoices_by_status(&InvoiceStatus::Verified)
+ .iter()
+ .any(|existing| existing == id));
+}
+
// ============================================================================
// 4. Invoice Cancelled — field order
// ============================================================================
@@ -284,13 +343,113 @@ fn test_invoice_defaulted_field_order() {
env.ledger().set_timestamp(ts);
client.handle_default(&id);
- let p: (BytesN<32>, Address, Address, u64) =
- latest_payload(&env, TOPIC_INVOICE_DEFAULTED);
- assert_eq!(p.0, id); // field 0: invoice_id
- assert_eq!(p.1, biz); // field 1: business
- assert_eq!(p.2, inv); // field 2: investor
- assert_eq!(p.3, ts); // field 3: timestamp
+ let p: (BytesN<32>, Address, Address, u64) = latest_payload(&env, TOPIC_INVOICE_DEFAULTED);
+ assert_eq!(p.0, id); // field 0: invoice_id
+ assert_eq!(p.1, biz); // field 1: business
+ assert_eq!(p.2, inv); // field 2: investor
+ assert_eq!(p.3, ts); // field 3: timestamp
+ assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Defaulted);
+}
+
+#[test]
+fn test_admin_update_invoice_status_funded_emits_canonical_event_and_moves_index() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin, cid) = setup(&env);
+ let biz = Address::generate(&env);
+ let currency = mint_currency(&env, &cid, &biz, None);
+ kyc_business(&env, &client, &admin, &biz);
+
+ let (id, _) = upload_invoice(&env, &client, &biz, ¤cy, "admin funded override");
+ client.update_invoice_status(&id, &InvoiceStatus::Verified);
+
+ let ts = env.ledger().timestamp() + 9;
+ env.ledger().set_timestamp(ts);
+ client.update_invoice_status(&id, &InvoiceStatus::Funded);
+
+ let p: (BytesN<32>, Address, i128, u64) = latest_payload(&env, symbol_short!("inv_fnd"));
+ assert_eq!(p.0, id);
+ assert_eq!(p.1, admin);
+ assert_eq!(p.2, INV_AMOUNT);
+ assert_eq!(p.3, ts);
+ assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Funded);
+ assert!(!client
+ .get_invoices_by_status(&InvoiceStatus::Verified)
+ .iter()
+ .any(|existing| existing == id));
+ assert!(client
+ .get_invoices_by_status(&InvoiceStatus::Funded)
+ .iter()
+ .any(|existing| existing == id));
+}
+
+#[test]
+fn test_admin_update_invoice_status_paid_emits_canonical_event_and_moves_index() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin, cid) = setup(&env);
+ let biz = Address::generate(&env);
+ let currency = mint_currency(&env, &cid, &biz, None);
+ kyc_business(&env, &client, &admin, &biz);
+
+ let (id, _) = upload_invoice(&env, &client, &biz, ¤cy, "admin paid override");
+ client.update_invoice_status(&id, &InvoiceStatus::Verified);
+ client.update_invoice_status(&id, &InvoiceStatus::Funded);
+
+ let ts = env.ledger().timestamp() + 11;
+ env.ledger().set_timestamp(ts);
+ client.update_invoice_status(&id, &InvoiceStatus::Paid);
+
+ let p: (BytesN<32>, Address, Address, i128, i128, u64) =
+ latest_payload(&env, TOPIC_INVOICE_SETTLED);
+ assert_eq!(p.0, id);
+ assert_eq!(p.1, biz);
+ assert_eq!(p.2, admin);
+ assert_eq!(p.3, 0);
+ assert_eq!(p.4, 0);
+ assert_eq!(p.5, ts);
+ assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Paid);
+ assert!(!client
+ .get_invoices_by_status(&InvoiceStatus::Funded)
+ .iter()
+ .any(|existing| existing == id));
+ assert!(client
+ .get_invoices_by_status(&InvoiceStatus::Paid)
+ .iter()
+ .any(|existing| existing == id));
+}
+
+#[test]
+fn test_admin_update_invoice_status_defaulted_emits_canonical_event_and_moves_index() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let (client, admin, cid) = setup(&env);
+ let biz = Address::generate(&env);
+ let currency = mint_currency(&env, &cid, &biz, None);
+ kyc_business(&env, &client, &admin, &biz);
+
+ let (id, _) = upload_invoice(&env, &client, &biz, ¤cy, "admin default override");
+ client.update_invoice_status(&id, &InvoiceStatus::Verified);
+ client.update_invoice_status(&id, &InvoiceStatus::Funded);
+
+ let ts = env.ledger().timestamp() + 13;
+ env.ledger().set_timestamp(ts);
+ client.update_invoice_status(&id, &InvoiceStatus::Defaulted);
+
+ assert_payload(
+ &env,
+ TOPIC_INVOICE_DEFAULTED,
+ (id.clone(), biz.clone(), admin.clone(), ts),
+ );
assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Defaulted);
+ assert!(!client
+ .get_invoices_by_status(&InvoiceStatus::Funded)
+ .iter()
+ .any(|existing| existing == id));
+ assert!(client
+ .get_invoices_by_status(&InvoiceStatus::Defaulted)
+ .iter()
+ .any(|existing| existing == id));
}
// ============================================================================
@@ -320,12 +479,12 @@ fn test_invoice_settled_field_order() {
// Field order: (invoice_id, business, investor, investor_return, platform_fee, timestamp)
let p: (BytesN<32>, Address, Address, i128, i128, u64) =
latest_payload(&env, TOPIC_INVOICE_SETTLED);
- assert_eq!(p.0, id); // field 0: invoice_id
- assert_eq!(p.1, biz); // field 1: business
- assert_eq!(p.2, inv); // field 2: investor
- assert!(p.3 >= 0); // field 3: investor_return
- assert!(p.4 >= 0); // field 4: platform_fee
- assert_eq!(p.5, ts); // field 5: timestamp
+ assert_eq!(p.0, id); // field 0: invoice_id
+ assert_eq!(p.1, biz); // field 1: business
+ assert_eq!(p.2, inv); // field 2: investor
+ assert!(p.3 >= 0); // field 3: investor_return
+ assert!(p.4 >= 0); // field 4: platform_fee
+ assert_eq!(p.5, ts); // field 5: timestamp
}
// ============================================================================
@@ -348,11 +507,10 @@ fn test_invoice_expired_field_order() {
env.ledger().set_timestamp(due + 1);
client.expire_invoice(&id);
- let p: (BytesN<32>, Address, u64) =
- latest_payload(&env, TOPIC_INVOICE_EXPIRED);
- assert_eq!(p.0, id); // field 0: invoice_id
- assert_eq!(p.1, biz); // field 1: business
- assert_eq!(p.2, due); // field 2: due_date (original, not current ts)
+ let p: (BytesN<32>, Address, u64) = latest_payload(&env, TOPIC_INVOICE_EXPIRED);
+ assert_eq!(p.0, id); // field 0: invoice_id
+ assert_eq!(p.1, biz); // field 1: business
+ assert_eq!(p.2, due); // field 2: due_date (original, not current ts)
}
// ============================================================================
@@ -370,7 +528,13 @@ fn test_partial_payment_field_order() {
kyc_business(&env, &client, &admin, &biz);
kyc_investor(&env, &client, &inv, INV_LIMIT);
- let (id, _) = upload_invoice(&env, &client, &biz, ¤cy, "partial payment field order");
+ let (id, _) = upload_invoice(
+ &env,
+ &client,
+ &biz,
+ ¤cy,
+ "partial payment field order",
+ );
client.verify_invoice(&id);
let bid_id = client.place_bid(&inv, &id, &INV_AMOUNT, &EXP_RETURN);
client.accept_bid(&id, &bid_id);
@@ -382,12 +546,12 @@ fn test_partial_payment_field_order() {
// Field order: (invoice_id, business, payment_amount, total_paid, progress_bps, tx_id)
let p: (BytesN<32>, Address, i128, i128, u32, String) =
latest_payload(&env, TOPIC_PARTIAL_PAYMENT);
- assert_eq!(p.0, id); // field 0: invoice_id
- assert_eq!(p.1, biz); // field 1: business
- assert_eq!(p.2, pay_amount); // field 2: payment_amount
- assert_eq!(p.3, pay_amount); // field 3: total_paid (first payment)
- assert!(p.4 <= 10_000); // field 4: progress_bps
- assert_eq!(p.5, tx_id); // field 5: transaction_id
+ assert_eq!(p.0, id); // field 0: invoice_id
+ assert_eq!(p.1, biz); // field 1: business
+ assert_eq!(p.2, pay_amount); // field 2: payment_amount
+ assert_eq!(p.3, pay_amount); // field 3: total_paid (first payment)
+ assert!(p.4 <= 10_000); // field 4: progress_bps
+ assert_eq!(p.5, tx_id); // field 5: transaction_id
}
// ============================================================================
@@ -414,13 +578,13 @@ fn test_bid_placed_field_order() {
let p: (BytesN<32>, BytesN<32>, Address, i128, i128, u64, u64) =
latest_payload(&env, TOPIC_BID_PLACED);
- assert_eq!(p.0, bid_id); // field 0: bid_id
- assert_eq!(p.1, id); // field 1: invoice_id
- assert_eq!(p.2, inv); // field 2: investor
- assert_eq!(p.3, INV_AMOUNT); // field 3: bid_amount
- assert_eq!(p.4, EXP_RETURN); // field 4: expected_return
- assert_eq!(p.5, ts); // field 5: timestamp
- assert!(p.6 > ts); // field 6: expiration_timestamp > placed_ts
+ assert_eq!(p.0, bid_id); // field 0: bid_id
+ assert_eq!(p.1, id); // field 1: invoice_id
+ assert_eq!(p.2, inv); // field 2: investor
+ assert_eq!(p.3, INV_AMOUNT); // field 3: bid_amount
+ assert_eq!(p.4, EXP_RETURN); // field 4: expected_return
+ assert_eq!(p.5, ts); // field 5: timestamp
+ assert!(p.6 > ts); // field 6: expiration_timestamp > placed_ts
}
// ============================================================================
@@ -448,13 +612,13 @@ fn test_bid_accepted_field_order() {
let p: (BytesN<32>, BytesN<32>, Address, Address, i128, i128, u64) =
latest_payload(&env, TOPIC_BID_ACCEPTED);
- assert_eq!(p.0, bid_id); // field 0: bid_id
- assert_eq!(p.1, id); // field 1: invoice_id
- assert_eq!(p.2, inv); // field 2: investor
- assert_eq!(p.3, biz); // field 3: business
- assert_eq!(p.4, INV_AMOUNT); // field 4: bid_amount
- assert_eq!(p.5, EXP_RETURN); // field 5: expected_return
- assert_eq!(p.6, ts); // field 6: timestamp
+ assert_eq!(p.0, bid_id); // field 0: bid_id
+ assert_eq!(p.1, id); // field 1: invoice_id
+ assert_eq!(p.2, inv); // field 2: investor
+ assert_eq!(p.3, biz); // field 3: business
+ assert_eq!(p.4, INV_AMOUNT); // field 4: bid_amount
+ assert_eq!(p.5, EXP_RETURN); // field 5: expected_return
+ assert_eq!(p.6, ts); // field 6: timestamp
}
// ============================================================================
@@ -480,13 +644,12 @@ fn test_bid_withdrawn_field_order() {
env.ledger().set_timestamp(ts);
client.withdraw_bid(&bid_id);
- let p: (BytesN<32>, BytesN<32>, Address, i128, u64) =
- latest_payload(&env, TOPIC_BID_WITHDRAWN);
- assert_eq!(p.0, bid_id); // field 0: bid_id
- assert_eq!(p.1, id); // field 1: invoice_id
- assert_eq!(p.2, inv); // field 2: investor
- assert_eq!(p.3, INV_AMOUNT); // field 3: bid_amount
- assert_eq!(p.4, ts); // field 4: timestamp
+ let p: (BytesN<32>, BytesN<32>, Address, i128, u64) = latest_payload(&env, TOPIC_BID_WITHDRAWN);
+ assert_eq!(p.0, bid_id); // field 0: bid_id
+ assert_eq!(p.1, id); // field 1: invoice_id
+ assert_eq!(p.2, inv); // field 2: investor
+ assert_eq!(p.3, INV_AMOUNT); // field 3: bid_amount
+ assert_eq!(p.4, ts); // field 4: timestamp
}
// ============================================================================
@@ -515,13 +678,12 @@ fn test_bid_expired_field_order() {
env.ledger().set_timestamp(expiry + 1);
client.clean_expired_bids(&id);
- let p: (BytesN<32>, BytesN<32>, Address, i128, u64) =
- latest_payload(&env, TOPIC_BID_EXPIRED);
- assert_eq!(p.0, bid_id); // field 0: bid_id
- assert_eq!(p.1, id); // field 1: invoice_id
- assert_eq!(p.2, inv); // field 2: investor
- assert_eq!(p.3, INV_AMOUNT); // field 3: bid_amount
- assert_eq!(p.4, expiry); // field 4: expiration_timestamp
+ let p: (BytesN<32>, BytesN<32>, Address, i128, u64) = latest_payload(&env, TOPIC_BID_EXPIRED);
+ assert_eq!(p.0, bid_id); // field 0: bid_id
+ assert_eq!(p.1, id); // field 1: invoice_id
+ assert_eq!(p.2, inv); // field 2: investor
+ assert_eq!(p.3, INV_AMOUNT); // field 3: bid_amount
+ assert_eq!(p.4, expiry); // field 4: expiration_timestamp
}
// ============================================================================
@@ -547,11 +709,11 @@ fn test_escrow_created_field_order() {
let escrow = client.get_escrow_details(&id);
let p: (BytesN<32>, BytesN<32>, Address, Address, i128) =
latest_payload(&env, TOPIC_ESCROW_CREATED);
- assert_eq!(p.0, escrow.escrow_id); // field 0: escrow_id
- assert_eq!(p.1, id); // field 1: invoice_id
- assert_eq!(p.2, inv); // field 2: investor
- assert_eq!(p.3, biz); // field 3: business
- assert_eq!(p.4, escrow.amount); // field 4: amount
+ assert_eq!(p.0, escrow.escrow_id); // field 0: escrow_id
+ assert_eq!(p.1, id); // field 1: invoice_id
+ assert_eq!(p.2, inv); // field 2: investor
+ assert_eq!(p.3, biz); // field 3: business
+ assert_eq!(p.4, escrow.amount); // field 4: amount
}
// ============================================================================
@@ -569,7 +731,13 @@ fn test_escrow_released_field_order() {
kyc_business(&env, &client, &admin, &biz);
kyc_investor(&env, &client, &inv, INV_LIMIT);
- let (id, _) = upload_invoice(&env, &client, &biz, ¤cy, "escrow released field order");
+ let (id, _) = upload_invoice(
+ &env,
+ &client,
+ &biz,
+ ¤cy,
+ "escrow released field order",
+ );
client.verify_invoice(&id);
let bid_id = client.place_bid(&inv, &id, &INV_AMOUNT, &EXP_RETURN);
client.accept_bid(&id, &bid_id);
@@ -577,12 +745,11 @@ fn test_escrow_released_field_order() {
let escrow = client.get_escrow_details(&id);
client.release_escrow_funds(&id);
- let p: (BytesN<32>, BytesN<32>, Address, i128) =
- latest_payload(&env, TOPIC_ESCROW_RELEASED);
- assert_eq!(p.0, escrow.escrow_id); // field 0: escrow_id
- assert_eq!(p.1, id); // field 1: invoice_id
- assert_eq!(p.2, biz); // field 2: business
- assert_eq!(p.3, escrow.amount); // field 3: amount
+ let p: (BytesN<32>, BytesN<32>, Address, i128) = latest_payload(&env, TOPIC_ESCROW_RELEASED);
+ assert_eq!(p.0, escrow.escrow_id); // field 0: escrow_id
+ assert_eq!(p.1, id); // field 1: invoice_id
+ assert_eq!(p.2, biz); // field 2: business
+ assert_eq!(p.3, escrow.amount); // field 3: amount
assert_eq!(client.get_escrow_status(&id), EscrowStatus::Released);
}
@@ -609,12 +776,11 @@ fn test_escrow_refunded_field_order_on_cancellation() {
let escrow = client.get_escrow_details(&id);
client.refund_escrow(&id);
- let p: (BytesN<32>, BytesN<32>, Address, i128) =
- latest_payload(&env, TOPIC_ESCROW_REFUNDED);
- assert_eq!(p.0, escrow.escrow_id); // field 0: escrow_id
- assert_eq!(p.1, id); // field 1: invoice_id
- assert_eq!(p.2, inv); // field 2: investor
- assert_eq!(p.3, escrow.amount); // field 3: amount
+ let p: (BytesN<32>, BytesN<32>, Address, i128) = latest_payload(&env, TOPIC_ESCROW_REFUNDED);
+ assert_eq!(p.0, escrow.escrow_id); // field 0: escrow_id
+ assert_eq!(p.1, id); // field 1: invoice_id
+ assert_eq!(p.2, inv); // field 2: investor
+ assert_eq!(p.3, escrow.amount); // field 3: amount
assert_eq!(client.get_escrow_status(&id), EscrowStatus::Refunded);
}
@@ -644,23 +810,21 @@ fn test_dispute_lifecycle_field_orders() {
env.ledger().set_timestamp(cr_ts);
client.create_dispute(&biz, &id, &reason);
- let p0: (BytesN<32>, Address, String, u64) =
- latest_payload(&env, symbol_short!("dsp_cr"));
- assert_eq!(p0.0, id); // field 0: invoice_id
- assert_eq!(p0.1, biz); // field 1: created_by
- assert_eq!(p0.2, reason); // field 2: reason
- assert_eq!(p0.3, cr_ts); // field 3: timestamp
+ let p0: (BytesN<32>, Address, String, u64) = latest_payload(&env, symbol_short!("dsp_cr"));
+ assert_eq!(p0.0, id); // field 0: invoice_id
+ assert_eq!(p0.1, biz); // field 1: created_by
+ assert_eq!(p0.2, reason); // field 2: reason
+ assert_eq!(p0.3, cr_ts); // field 3: timestamp
// DisputeUnderReview
let ur_ts = cr_ts + 5;
env.ledger().set_timestamp(ur_ts);
client.put_dispute_under_review(&id);
- let p1: (BytesN<32>, Address, u64) =
- latest_payload(&env, symbol_short!("dsp_ur"));
- assert_eq!(p1.0, id); // field 0: invoice_id
- // field 1: reviewed_by (admin)
- assert_eq!(p1.2, ur_ts); // field 2: timestamp
+ let p1: (BytesN<32>, Address, u64) = latest_payload(&env, symbol_short!("dsp_ur"));
+ assert_eq!(p1.0, id); // field 0: invoice_id
+ // field 1: reviewed_by (admin)
+ assert_eq!(p1.2, ur_ts); // field 2: timestamp
// DisputeResolved
let resolution = String::from_str(&env, "Resolved with partial refund");
@@ -668,12 +832,11 @@ fn test_dispute_lifecycle_field_orders() {
env.ledger().set_timestamp(rs_ts);
client.resolve_dispute(&id, &resolution);
- let p2: (BytesN<32>, Address, String, u64) =
- latest_payload(&env, symbol_short!("dsp_rs"));
- assert_eq!(p2.0, id); // field 0: invoice_id
- // field 1: resolved_by (admin)
- assert_eq!(p2.2, resolution); // field 2: resolution
- assert_eq!(p2.3, rs_ts); // field 3: timestamp
+ let p2: (BytesN<32>, Address, String, u64) = latest_payload(&env, symbol_short!("dsp_rs"));
+ assert_eq!(p2.0, id); // field 0: invoice_id
+ // field 1: resolved_by (admin)
+ assert_eq!(p2.2, resolution); // field 2: resolution
+ assert_eq!(p2.3, rs_ts); // field 3: timestamp
}
// ============================================================================
@@ -691,11 +854,10 @@ fn test_platform_fee_updated_field_order() {
client.set_platform_fee(&250i128);
// fee_upd payload: (fee_bps, updated_at, updated_by)
- let p: (i128, u64, Address) =
- latest_payload(&env, symbol_short!("fee_upd"));
- assert_eq!(p.0, 250i128); // field 0: fee_bps
- assert_eq!(p.1, ts); // field 1: updated_at
- assert_eq!(p.2, admin); // field 2: updated_by
+ let p: (i128, u64, Address) = latest_payload(&env, symbol_short!("fee_upd"));
+ assert_eq!(p.0, 250i128); // field 0: fee_bps
+ assert_eq!(p.1, ts); // field 1: updated_at
+ assert_eq!(p.2, admin); // field 2: updated_by
}
// ============================================================================
@@ -798,14 +960,13 @@ fn test_event_ordering_across_lifecycle() {
// Verify timestamps are strictly increasing
let up_p: (BytesN<32>, Address, i128, Address, u64, u64) =
latest_payload(&env, TOPIC_INVOICE_UPLOADED);
- let ver_p: (BytesN<32>, Address, u64) =
- latest_payload(&env, TOPIC_INVOICE_VERIFIED);
+ let ver_p: (BytesN<32>, Address, u64) = latest_payload(&env, TOPIC_INVOICE_VERIFIED);
let bid_p: (BytesN<32>, BytesN<32>, Address, i128, i128, u64, u64) =
latest_payload(&env, TOPIC_BID_PLACED);
let acc_p: (BytesN<32>, BytesN<32>, Address, Address, i128, i128, u64) =
latest_payload(&env, TOPIC_BID_ACCEPTED);
- assert_eq!(up_p.5, 10u64, "upload ts");
+ assert_eq!(up_p.5, 10u64, "upload ts");
assert_eq!(ver_p.2, 20u64, "verify ts");
assert_eq!(bid_p.5, 30u64, "bid ts");
assert_eq!(acc_p.6, 40u64, "accept ts");
@@ -832,27 +993,49 @@ fn test_invoice_events_emit_correct_topics_and_payloads() {
let upload_ts = env.ledger().timestamp();
let invoice_id = client.upload_invoice(
- &business, &amount, ¤cy, &due_date,
+ &business,
+ &amount,
+ ¤cy,
+ &due_date,
&String::from_str(&env, "Invoice event test"),
- &InvoiceCategory::Services, &Vec::new(&env),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
);
assert_payload(
&env,
symbol_short!("inv_up"),
- (invoice_id.clone(), business.clone(), amount, currency.clone(), due_date, upload_ts),
+ (
+ invoice_id.clone(),
+ business.clone(),
+ amount,
+ currency.clone(),
+ due_date,
+ upload_ts,
+ ),
);
let verify_ts = upload_ts + 10;
env.ledger().set_timestamp(verify_ts);
client.verify_invoice(&invoice_id);
- assert_payload(&env, symbol_short!("inv_ver"), (invoice_id.clone(), business.clone(), verify_ts));
+ assert_payload(
+ &env,
+ symbol_short!("inv_ver"),
+ (invoice_id.clone(), business.clone(), verify_ts),
+ );
let cancel_ts = verify_ts + 10;
env.ledger().set_timestamp(cancel_ts);
client.cancel_invoice(&invoice_id);
- assert_payload(&env, symbol_short!("inv_canc"), (invoice_id.clone(), business.clone(), cancel_ts));
- assert_eq!(client.get_invoice(&invoice_id).status, InvoiceStatus::Cancelled);
+ assert_payload(
+ &env,
+ symbol_short!("inv_canc"),
+ (invoice_id.clone(), business.clone(), cancel_ts),
+ );
+ assert_eq!(
+ client.get_invoice(&invoice_id).status,
+ InvoiceStatus::Cancelled
+ );
}
#[test]
@@ -869,9 +1052,13 @@ fn test_bid_placed_and_withdrawn_events_emit_correct_payloads() {
kyc_investor(&env, &client, &investor, INV_LIMIT);
let invoice_id = client.upload_invoice(
- &business, &INV_AMOUNT, ¤cy, &due_date,
+ &business,
+ &INV_AMOUNT,
+ ¤cy,
+ &due_date,
&String::from_str(&env, "Bid events test"),
- &InvoiceCategory::Services, &Vec::new(&env),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
);
client.verify_invoice(&invoice_id);
@@ -887,16 +1074,29 @@ fn test_bid_placed_and_withdrawn_events_emit_correct_payloads() {
assert_eq!(bid_placed_payload.3, INV_AMOUNT);
assert_eq!(bid_placed_payload.4, EXP_RETURN);
assert_eq!(bid_placed_payload.5, placed_ts);
- assert_eq!(bid_placed_payload.6, crate::bid::Bid::default_expiration(placed_ts));
+ assert_eq!(
+ bid_placed_payload.6,
+ crate::bid::Bid::default_expiration(placed_ts)
+ );
let withdraw_ts = 120u64;
env.ledger().set_timestamp(withdraw_ts);
client.withdraw_bid(&bid_id);
assert_payload(
- &env, symbol_short!("bid_wdr"),
- (bid_id.clone(), invoice_id.clone(), investor.clone(), INV_AMOUNT, withdraw_ts),
+ &env,
+ symbol_short!("bid_wdr"),
+ (
+ bid_id.clone(),
+ invoice_id.clone(),
+ investor.clone(),
+ INV_AMOUNT,
+ withdraw_ts,
+ ),
+ );
+ assert_eq!(
+ client.get_bid(&bid_id).unwrap().status,
+ crate::bid::BidStatus::Withdrawn
);
- assert_eq!(client.get_bid(&bid_id).unwrap().status, crate::bid::BidStatus::Withdrawn);
}
#[test]
@@ -913,9 +1113,13 @@ fn test_bid_accepted_and_escrow_created_events_emit_correct_payloads() {
kyc_investor(&env, &client, &investor, INV_LIMIT);
let invoice_id = client.upload_invoice(
- &business, &INV_AMOUNT, ¤cy, &due_date,
+ &business,
+ &INV_AMOUNT,
+ ¤cy,
+ &due_date,
&String::from_str(&env, "Bid accepted event test"),
- &InvoiceCategory::Services, &Vec::new(&env),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
);
client.verify_invoice(&invoice_id);
@@ -942,7 +1146,10 @@ fn test_bid_accepted_and_escrow_created_events_emit_correct_payloads() {
assert_eq!(escrow_created_payload.2, investor.clone());
assert_eq!(escrow_created_payload.3, business.clone());
assert_eq!(escrow_created_payload.4, escrow.amount);
- assert_eq!(client.get_invoice(&invoice_id).status, InvoiceStatus::Funded);
+ assert_eq!(
+ client.get_invoice(&invoice_id).status,
+ InvoiceStatus::Funded
+ );
}
#[test]
@@ -959,9 +1166,13 @@ fn test_escrow_released_event_emits_correct_topic_and_payload() {
kyc_investor(&env, &client, &investor, INV_LIMIT);
let invoice_id = client.upload_invoice(
- &business, &INV_AMOUNT, ¤cy, &due_date,
+ &business,
+ &INV_AMOUNT,
+ ¤cy,
+ &due_date,
&String::from_str(&env, "Escrow release event test"),
- &InvoiceCategory::Services, &Vec::new(&env),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
);
client.verify_invoice(&invoice_id);
let bid_id = client.place_bid(&investor, &invoice_id, &INV_AMOUNT, &EXP_RETURN);
@@ -970,10 +1181,19 @@ fn test_escrow_released_event_emits_correct_topic_and_payload() {
client.release_escrow_funds(&invoice_id);
assert_payload(
- &env, symbol_short!("esc_rel"),
- (escrow.escrow_id.clone(), invoice_id.clone(), business.clone(), escrow.amount),
+ &env,
+ symbol_short!("esc_rel"),
+ (
+ escrow.escrow_id.clone(),
+ invoice_id.clone(),
+ business.clone(),
+ escrow.amount,
+ ),
+ );
+ assert_eq!(
+ client.get_escrow_status(&invoice_id),
+ EscrowStatus::Released
);
- assert_eq!(client.get_escrow_status(&invoice_id), EscrowStatus::Released);
}
#[test]
@@ -990,9 +1210,13 @@ fn test_invoice_defaulted_event_emits_correct_topic_and_payload() {
kyc_investor(&env, &client, &investor, INV_LIMIT);
let invoice_id = client.upload_invoice(
- &business, &INV_AMOUNT, ¤cy, &due_date,
+ &business,
+ &INV_AMOUNT,
+ ¤cy,
+ &due_date,
&String::from_str(&env, "Default event test"),
- &InvoiceCategory::Services, &Vec::new(&env),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
);
client.verify_invoice(&invoice_id);
let bid_id = client.place_bid(&investor, &invoice_id, &INV_AMOUNT, &EXP_RETURN);
@@ -1003,10 +1227,19 @@ fn test_invoice_defaulted_event_emits_correct_topic_and_payload() {
client.handle_default(&invoice_id);
assert_payload(
- &env, symbol_short!("inv_def"),
- (invoice_id.clone(), business.clone(), investor.clone(), default_ts),
+ &env,
+ symbol_short!("inv_def"),
+ (
+ invoice_id.clone(),
+ business.clone(),
+ investor.clone(),
+ default_ts,
+ ),
+ );
+ assert_eq!(
+ client.get_invoice(&invoice_id).status,
+ InvoiceStatus::Defaulted
);
- assert_eq!(client.get_invoice(&invoice_id).status, InvoiceStatus::Defaulted);
}
#[test]
@@ -1020,9 +1253,13 @@ fn test_audit_events_emit_correct_topics_and_payloads() {
kyc_business(&env, &client, &admin, &business);
let invoice_id = client.upload_invoice(
- &business, &INV_AMOUNT, ¤cy, &due_date,
+ &business,
+ &INV_AMOUNT,
+ ¤cy,
+ &due_date,
&String::from_str(&env, "Audit events test"),
- &InvoiceCategory::Services, &Vec::new(&env),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
);
let filter = AuditQueryFilter {
@@ -1034,15 +1271,20 @@ fn test_audit_events_emit_correct_topics_and_payloads() {
};
let results = client.query_audit_logs(&filter, &50u32);
assert_payload(
- &env, symbol_short!("aud_qry"),
- (String::from_str(&env, "query_audit_logs"), results.len() as u32),
+ &env,
+ symbol_short!("aud_qry"),
+ (
+ String::from_str(&env, "query_audit_logs"),
+ results.len() as u32,
+ ),
);
let validation_ts = 300u64;
env.ledger().set_timestamp(validation_ts);
let is_valid = client.validate_invoice_audit_integrity(&invoice_id);
assert_payload(
- &env, symbol_short!("aud_val"),
+ &env,
+ symbol_short!("aud_val"),
(invoice_id.clone(), is_valid, validation_ts),
);
}
@@ -1055,7 +1297,11 @@ fn test_platform_fee_updated_event_emits_correct_topic_and_payload() {
let update_ts = 400u64;
env.ledger().set_timestamp(update_ts);
client.set_platform_fee(&250i128);
- assert_payload(&env, symbol_short!("fee_upd"), (250i128, update_ts, admin.clone()));
+ assert_payload(
+ &env,
+ symbol_short!("fee_upd"),
+ (250i128, update_ts, admin.clone()),
+ );
assert_eq!(client.get_platform_fee().fee_bps, 250i128);
}
@@ -1074,9 +1320,13 @@ fn test_event_timestamp_ordering() {
let time_upload = env.ledger().timestamp();
let invoice_id = client.upload_invoice(
- &business, &INV_AMOUNT, ¤cy, &due_date,
+ &business,
+ &INV_AMOUNT,
+ ¤cy,
+ &due_date,
&String::from_str(&env, "Test invoice"),
- &InvoiceCategory::Services, &Vec::new(&env),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
);
env.ledger().set_timestamp(time_upload + 1000);
diff --git a/quicklendx-contracts/src/test_lifecycle.rs b/quicklendx-contracts/src/test_lifecycle.rs
index f54dab55..44213657 100644
--- a/quicklendx-contracts/src/test_lifecycle.rs
+++ b/quicklendx-contracts/src/test_lifecycle.rs
@@ -38,6 +38,7 @@
use super::*;
use crate::bid::BidStatus;
+use crate::errors::QuickLendXError;
use crate::investment::InvestmentStatus;
use crate::invoice::{InvoiceCategory, InvoiceStatus};
use crate::verification::BusinessVerificationStatus;
@@ -163,7 +164,11 @@ fn assert_counts_invariant(client: &QuickLendXContractClient) {
+ client.get_invoice_count_by_status(&InvoiceStatus::Defaulted)
+ client.get_invoice_count_by_status(&InvoiceStatus::Cancelled)
+ client.get_invoice_count_by_status(&InvoiceStatus::Refunded);
- assert_eq!(total, sum, "Invariant failure: global count {} != bucket sum {}", total, sum);
+ assert_eq!(
+ total, sum,
+ "Invariant failure: global count {} != bucket sum {}",
+ total, sum
+ );
}
/// Shared KYC + upload + verify + investor + bid sequence.
@@ -536,15 +541,17 @@ fn test_full_lifecycle_step_by_step() {
// ── Step 3: Business uploads invoice (status → Pending) ──────────────────────
let due_date = env.ledger().timestamp() + 86_400;
- let invoice_id = client.upload_invoice(
- &business,
- &invoice_amount,
- ¤cy,
- &due_date,
- &String::from_str(&env, "Consulting services invoice"),
- &InvoiceCategory::Consulting,
- &Vec::new(&env),
- ).unwrap();
+ let invoice_id = client
+ .upload_invoice(
+ &business,
+ &invoice_amount,
+ ¤cy,
+ &due_date,
+ &String::from_str(&env, "Consulting services invoice"),
+ &InvoiceCategory::Consulting,
+ &Vec::new(&env),
+ )
+ .unwrap();
let invoice = client.get_invoice(&invoice_id);
assert_eq!(invoice.status, InvoiceStatus::Pending);
assert_eq!(invoice.amount, invoice_amount);
@@ -579,14 +586,19 @@ fn test_full_lifecycle_step_by_step() {
);
let inv_ver = client.get_investor_verification(&investor).unwrap();
// investment_limit is adjusted by risk tier calculation; just verify it's positive
- assert!(inv_ver.investment_limit > 0, "Investment limit should be set");
+ assert!(
+ inv_ver.investment_limit > 0,
+ "Investment limit should be set"
+ );
assert!(
has_event_with_topic(&env, symbol_short!("inv_veri")),
"inv_veri expected after verify investor"
);
// ── Step 7: Investor places bid (status → Placed) ──────────────────────────
- let bid_id = client.place_bid(&investor, &invoice_id, &bid_amount, &invoice_amount).unwrap();
+ let bid_id = client
+ .place_bid(&investor, &invoice_id, &bid_amount, &invoice_amount)
+ .unwrap();
let bid = client.get_bid(&bid_id).unwrap();
assert_eq!(bid.status, BidStatus::Placed);
assert_eq!(bid.bid_amount, bid_amount);
@@ -662,3 +674,65 @@ fn test_full_lifecycle_step_by_step() {
assert_lifecycle_events_emitted(&env);
}
+
+#[test]
+fn test_admin_update_invoice_status_pathway_moves_indexes_and_rejects_invalid_transition() {
+ let (env, client, _admin) = make_env();
+ let business = Address::generate(&env);
+ let currency = Address::generate(&env);
+
+ let invoice_id = client.store_invoice(
+ &business,
+ &5_000i128,
+ ¤cy,
+ &(env.ledger().timestamp() + 86_400),
+ &String::from_str(&env, "admin status pathway"),
+ &InvoiceCategory::Services,
+ &Vec::new(&env),
+ );
+
+ assert_eq!(
+ client.get_invoice_count_by_status(&InvoiceStatus::Pending),
+ 1
+ );
+
+ client.update_invoice_status(&invoice_id, &InvoiceStatus::Verified);
+ assert_eq!(
+ client.get_invoice_count_by_status(&InvoiceStatus::Pending),
+ 0
+ );
+ assert_eq!(
+ client.get_invoice_count_by_status(&InvoiceStatus::Verified),
+ 1
+ );
+ assert!(has_event_with_topic(&env, symbol_short!("inv_ver")));
+
+ client.update_invoice_status(&invoice_id, &InvoiceStatus::Funded);
+ assert_eq!(
+ client.get_invoice_count_by_status(&InvoiceStatus::Verified),
+ 0
+ );
+ assert_eq!(
+ client.get_invoice_count_by_status(&InvoiceStatus::Funded),
+ 1
+ );
+ assert!(has_event_with_topic(&env, symbol_short!("inv_fnd")));
+
+ let invalid = client.try_update_invoice_status(&invoice_id, &InvoiceStatus::Verified);
+ assert!(invalid.is_err());
+ assert_eq!(
+ invalid.err().unwrap().expect("expected contract error"),
+ QuickLendXError::InvalidStatus
+ );
+
+ client.update_invoice_status(&invoice_id, &InvoiceStatus::Defaulted);
+ assert_eq!(
+ client.get_invoice_count_by_status(&InvoiceStatus::Funded),
+ 0
+ );
+ assert_eq!(
+ client.get_invoice_count_by_status(&InvoiceStatus::Defaulted),
+ 1
+ );
+ assert!(has_event_with_topic(&env, symbol_short!("inv_def")));
+}
diff --git a/quicklendx-contracts/src/test_pause.rs b/quicklendx-contracts/src/test_pause.rs
index 003eea85..d64dca0f 100644
--- a/quicklendx-contracts/src/test_pause.rs
+++ b/quicklendx-contracts/src/test_pause.rs
@@ -35,6 +35,16 @@ fn submit_investor_kyc(env: &Env, client: &QuickLendXContractClient, investor: &
client.submit_investor_kyc(investor, &String::from_str(env, "Investor KYC"));
}
+fn verify_investor_for_test(
+ env: &Env,
+ client: &QuickLendXContractClient,
+ investor: &Address,
+ limit: i128,
+) {
+ submit_investor_kyc(env, client, investor);
+ client.verify_investor(investor, &limit);
+}
+
#[test]
fn test_pause_blocks_user_and_invoice_state_mutations() {
let env = Env::default();
@@ -222,7 +232,7 @@ fn test_pause_blocks_accept_bid_and_fund() {
let result = client.try_accept_bid_and_fund(&invoice_id, &bid_id);
let err = result.err().expect("expected contract error");
let contract_error = err.expect("expected contract invoke error");
- assert_eq!(contract_error, QuickLendXError::OperationNotAllowed);
+ assert_eq!(contract_error, QuickLendXError::OperationNotAllowed.into());
}
#[test]
@@ -419,32 +429,6 @@ fn test_pause_blocks_kyc_submission() {
assert_eq!(contract_error, QuickLendXError::OperationNotAllowed);
}
-#[test]
-fn test_pause_blocks_cancel_bid() {
- let env = Env::default();
- let (client, admin, business, investor, currency) = setup(&env);
- let due_date = env.ledger().timestamp() + 86400;
-
- let invoice_id = client.store_invoice(
- &business,
- &1000i128,
- ¤cy,
- &due_date,
- &String::from_str(&env, "Invoice"),
- &InvoiceCategory::Services,
- &Vec::new(&env),
- );
- client.verify_invoice(&invoice_id);
- verify_investor_for_test(&env, &client, &investor, 10_000);
- let bid_id = client.place_bid(&investor, &invoice_id, &1000i128, &1100i128);
-
- client.pause(&admin);
- let result = client.try_cancel_bid(&bid_id);
- let err = result.err().expect("expected contract error");
- let contract_error = err.expect("expected contract invoke error");
- assert_eq!(contract_error, QuickLendXError::OperationNotAllowed);
-}
-
#[test]
fn test_pause_blocks_protocol_limits_update() {
let env = Env::default();