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();