From 1a63845e8900c5523aae6941a9e78501f8cf50e4 Mon Sep 17 00:00:00 2001 From: blessme247 Date: Sun, 29 Mar 2026 10:59:15 +0100 Subject: [PATCH] feat: pair clear_all_invoices with restore safety --- quicklendx-contracts/docs/contracts/backup.md | 34 ++ quicklendx-contracts/src/backup.rs | 199 ++++++++-- quicklendx-contracts/src/invoice.rs | 357 ++++++++++-------- quicklendx-contracts/src/test_backup.rs | 118 ++++++ quicklendx-contracts/src/test_storage.rs | 98 ++++- 5 files changed, 597 insertions(+), 209 deletions(-) diff --git a/quicklendx-contracts/docs/contracts/backup.md b/quicklendx-contracts/docs/contracts/backup.md index 40d4df7b..3ef57d40 100644 --- a/quicklendx-contracts/docs/contracts/backup.md +++ b/quicklendx-contracts/docs/contracts/backup.md @@ -23,6 +23,40 @@ The `restore_backup` command is highly privileged and destructive to the active 2. It permanently clears all currently tracked invoice data, wiping the existing database indexes (including business mapping, tags, metadata, and status associations). 3. It reconstitutes the original invoice state from the backup payload. +Additionally, restoring backups follows this sequence: + +Step 1 validate_backup(backup_id) + ────────────────────────── + Full integrity check performed BEFORE any mutation. Checks: + - Backup record exists and has a valid ID prefix (0xB4 0xC4). + - Backup description is non-empty and within length limits. + - Invoice payload exists in storage. + - Payload length matches backup.invoice_count. + - Every invoice in the payload has amount > 0. + + If validation fails → return error, no storage is mutated. + +Step 2 InvoiceStorage::clear_all(env) + ──────────────────────────────── + Atomically wipes every invoice and every secondary index. + After this step, instance storage contains no invoice data. + + ⚠ There is no rollback on a Soroban ledger. Reaching step 2 + means the caller has committed to discarding the current state. + Always take a backup before clearing. + +Step 3 InvoiceStorage::store_invoice(env, &invoice) per invoice + ──────────────────────────────────────────────────────── + Re-registers each invoice from the backup payload, rebuilding + all secondary indexes from scratch. The order of individual + invoices within this step does not matter. + +Step 4 Mark backup as Archived + ───────────────────────── + Sets backup.status = BackupStatus::Archived to prevent the same + backup from being restored twice. Restoring the same backup twice + without clearing in between would cause duplicate index entries. + ### Archiving Backups Backups that are older or explicitly meant for off-chain storage can be marked as archived using the `archive_backup` function. This cleanly removes them from the active list of 5 rolling backups and stops them from being accidentally overwritten by the rolling buffer. diff --git a/quicklendx-contracts/src/backup.rs b/quicklendx-contracts/src/backup.rs index 5528ac95..dfd23a39 100644 --- a/quicklendx-contracts/src/backup.rs +++ b/quicklendx-contracts/src/backup.rs @@ -8,6 +8,7 @@ const BACKUP_LIST_KEY: soroban_sdk::Symbol = symbol_short!("backups"); const BACKUP_DATA_KEY: soroban_sdk::Symbol = symbol_short!("bkup_data"); const MAX_BACKUP_DESCRIPTION_LENGTH: u32 = 128; +/// A stored snapshot of all invoices at a point in time. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Backup { @@ -18,23 +19,31 @@ pub struct Backup { pub status: BackupStatus, } +/// Lifecycle state of a [`Backup`] record. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum BackupStatus { + /// Backup is valid and available for restore. Active, + /// Backup has been superseded and should not be restored. Archived, + /// Backup data failed integrity checks and must not be restored. Corrupted, } -/// Backup retention policy configuration +/// Backup retention policy configuration. +/// +/// Controls how many backups are kept and for how long. When +/// `auto_cleanup_enabled` is `true`, `cleanup_old_backups` enforces both +/// `max_backups` and `max_age_seconds` on every invocation. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct BackupRetentionPolicy { - /// Maximum number of backups to keep (0 = unlimited) + /// Maximum number of backups to keep (0 = unlimited). pub max_backups: u32, - /// Maximum age of backups in seconds (0 = unlimited) + /// Maximum age of backups in seconds (0 = unlimited). pub max_age_seconds: u64, - /// Whether automatic cleanup is enabled + /// Whether automatic cleanup is enabled. pub auto_cleanup_enabled: bool, } @@ -48,10 +57,15 @@ impl Default for BackupRetentionPolicy { } } +/// Low-level backup storage operations. +/// +/// All public functions are thin wrappers around Soroban instance storage. +/// Higher-level orchestration (backup-then-clear, validate-then-restore) lives +/// in [`BackupStorage::restore_from_backup`] which is the **only** safe entry +/// point for restoring data. pub struct BackupStorage; impl BackupStorage { - /// @notice Validate backup metadata before persisting it. fn validate_backup_metadata( backup: &Backup, invoices: Option<&Vec>, @@ -60,7 +74,8 @@ impl BackupStorage { return Err(QuickLendXError::StorageError); } - if backup.description.len() == 0 || backup.description.len() > MAX_BACKUP_DESCRIPTION_LENGTH + if backup.description.len() == 0 + || backup.description.len() > MAX_BACKUP_DESCRIPTION_LENGTH { return Err(QuickLendXError::InvalidDescription); } @@ -74,13 +89,12 @@ impl BackupStorage { Ok(()) } - /// @notice Validate the backup identifier prefix format. pub fn is_valid_backup_id(backup_id: &BytesN<32>) -> bool { let bytes = backup_id.to_array(); bytes[0] == 0xB4 && bytes[1] == 0xC4 } - /// Get the backup retention policy + /// Get the backup retention policy. pub fn get_retention_policy(env: &Env) -> BackupRetentionPolicy { env.storage() .instance() @@ -88,12 +102,14 @@ impl BackupStorage { .unwrap_or_else(|| BackupRetentionPolicy::default()) } - /// Set the backup retention policy (admin only) + /// Set the backup retention policy (admin only — caller must enforce auth). pub fn set_retention_policy(env: &Env, policy: &BackupRetentionPolicy) { env.storage().instance().set(&RETENTION_POLICY_KEY, policy); } - /// Generate a unique backup ID + /// Generate a unique backup ID. + /// + /// Format: `0xB4 0xC4 | timestamp(8B) | counter(8B) | mix(14B)`. pub fn generate_backup_id(env: &Env) -> BytesN<32> { let timestamp = env.ledger().timestamp(); let counter: u64 = env @@ -107,14 +123,10 @@ impl BackupStorage { .set(&BACKUP_COUNTER_KEY, &next_counter); let mut id_bytes = [0u8; 32]; - // Add backup prefix - id_bytes[0] = 0xB4; // 'B' for Backup - id_bytes[1] = 0xC4; // 'C' for baCkup - // Embed timestamp + id_bytes[0] = 0xB4; + id_bytes[1] = 0xC4; id_bytes[2..10].copy_from_slice(×tamp.to_be_bytes()); - // Embed counter id_bytes[10..18].copy_from_slice(&next_counter.to_be_bytes()); - // Fill remaining bytes (overflow-safe) let mix = timestamp .saturating_add(next_counter) .saturating_add(0xB4C4); @@ -125,7 +137,11 @@ impl BackupStorage { BytesN::from_array(env, &id_bytes) } - /// Store a backup record + + /// Persist a backup record (metadata only). + /// + /// Returns [`QuickLendXError::OperationNotAllowed`] if a backup with the + /// same ID already exists, preventing accidental overwrites. pub fn store_backup( env: &Env, backup: &Backup, @@ -141,19 +157,19 @@ impl BackupStorage { Ok(()) } - /// Get a backup by ID + /// Retrieve a backup record by ID. pub fn get_backup(env: &Env, backup_id: &BytesN<32>) -> Option { env.storage().instance().get(backup_id) } - /// Update a backup record + /// Update an existing backup record (e.g. to mark it `Archived`). pub fn update_backup(env: &Env, backup: &Backup) -> Result<(), QuickLendXError> { Self::validate_backup_metadata(backup, None)?; env.storage().instance().set(&backup.backup_id, backup); Ok(()) } - /// Get all backup IDs + /// Get all backup IDs in the global backup list. pub fn get_all_backups(env: &Env) -> Vec> { env.storage() .instance() @@ -161,7 +177,7 @@ impl BackupStorage { .unwrap_or_else(|| Vec::new(env)) } - /// Add backup to the list of all backups + /// Append a backup ID to the global backup list (deduplication guard included). pub fn add_to_backup_list(env: &Env, backup_id: &BytesN<32>) { let mut backups = Self::get_all_backups(env); for existing in backups.iter() { @@ -173,7 +189,7 @@ impl BackupStorage { env.storage().instance().set(&BACKUP_LIST_KEY, &backups); } - /// Remove backup from the list (when archived or corrupted) + /// Remove a backup ID from the global backup list. pub fn remove_from_backup_list(env: &Env, backup_id: &BytesN<32>) { let backups = Self::get_all_backups(env); let mut new_backups = Vec::new(env); @@ -185,19 +201,19 @@ impl BackupStorage { env.storage().instance().set(&BACKUP_LIST_KEY, &new_backups); } - /// Store invoice data for a backup + /// Store the invoice payload for a backup. pub fn store_backup_data(env: &Env, backup_id: &BytesN<32>, invoices: &Vec) { let key = (BACKUP_DATA_KEY, backup_id.clone()); env.storage().instance().set(&key, invoices); } - /// Get invoice data from a backup + /// Retrieve the invoice payload for a backup. pub fn get_backup_data(env: &Env, backup_id: &BytesN<32>) -> Option> { let key = (BACKUP_DATA_KEY, backup_id.clone()); env.storage().instance().get(&key) } - /// @notice Delete a backup record and its stored data. + /// Delete a backup record and its stored invoice payload. pub fn purge_backup(env: &Env, backup_id: &BytesN<32>) { Self::remove_from_backup_list(env, backup_id); env.storage().instance().remove(backup_id); @@ -205,22 +221,30 @@ impl BackupStorage { env.storage().instance().remove(&data_key); } - /// Validate backup data integrity + + /// Validate backup data integrity. + /// + /// Checks that: + /// 1. The backup record exists and has a valid ID prefix. + /// 2. The invoice payload exists. + /// 3. The payload length matches `backup.invoice_count`. + /// 4. Every invoice in the payload has a positive `amount`. pub fn validate_backup(env: &Env, backup_id: &BytesN<32>) -> Result<(), QuickLendXError> { - let backup = Self::get_backup(env, backup_id).ok_or(QuickLendXError::StorageKeyNotFound)?; + let backup = + Self::get_backup(env, backup_id).ok_or(QuickLendXError::StorageKeyNotFound)?; + + // Validate metadata alone first (cheap). Self::validate_backup_metadata(&backup, None)?; + // Fetch the payload and validate together with the count. let data = Self::get_backup_data(env, backup_id).ok_or(QuickLendXError::StorageKeyNotFound)?; - Self::validate_backup_metadata(&backup, Some(&data))?; - - // Check if count matches if data.len() as u32 != backup.invoice_count { return Err(QuickLendXError::StorageError); } - // Check each invoice has valid data + // Validate each invoice record in the payload. for invoice in data.iter() { if invoice.amount <= 0 { return Err(QuickLendXError::StorageError); @@ -230,11 +254,100 @@ impl BackupStorage { Ok(()) } - /// Clean up old backups based on retention policy + + /// Restore all invoices from a backup in a safe, validated sequence. + /// + /// # Restore ordering + /// + /// The ordering of operations is critical to prevent orphan indexes and + /// partial-state corruption: + /// + /// ```text + /// Step 1 validate_backup() + /// ───────────────── + /// Full integrity check BEFORE any mutation. If the backup is + /// corrupt or the invoice_count mismatches, we abort here and + /// leave existing storage completely untouched. + /// + /// Step 2 InvoiceStorage::clear_all() + /// ──────────────────────────── + /// Atomically removes every invoice record, status bucket, + /// category index, tag index, business index, and metadata index. + /// After this step storage is empty. There is no rollback + /// mechanism on a Soroban ledger; reaching this step means the + /// caller has accepted that the current state will be discarded. + /// + /// Step 3 InvoiceStorage::store_invoice() per invoice + /// ──────────────────────────────────────────── + /// Re-registers each invoice from the backup payload, rebuilding + /// all secondary indexes from scratch. The write order within + /// this step does not matter because `store_invoice` is + /// self-contained. + /// + /// Step 4 Mark the backup as Archived + /// ──────────────────────────── + /// Prevents the same backup from being restored twice, which + /// could cause duplicate invoice registrations if the store is + /// not cleared between restores. + /// ``` + /// + /// # Errors + /// + /// Returns an error *only* in step 1. Steps 2–4 are infallible on a + /// well-formed Soroban environment; panics in those steps indicate a + /// platform bug, not a contract bug. + /// + /// # Security + /// + /// - The caller **must** enforce admin authentication before invoking this + /// function. The contract entry point is responsible for `require_auth`. + /// - Validate → clear → restore is the only safe ordering. Clearing + /// before validating would leave the contract in an empty state if the + /// backup turns out to be corrupt. + /// - Restoring without clearing first would overlay backup data on stale + /// indexes, causing ghost entries in status/category/tag buckets for + /// any invoices that existed before the restore. + pub fn restore_from_backup( + env: &Env, + backup_id: &BytesN<32>, + ) -> Result { + // Step 1: validate before mutating anything + Self::validate_backup(env, backup_id)?; + + // Fetch the validated payload. + let data = Self::get_backup_data(env, backup_id) + .ok_or(QuickLendXError::StorageKeyNotFound)?; + + let restored_count = data.len(); + + // Step 2: atomically clear all existing invoice state + crate::invoice::InvoiceStorage::clear_all(env); + + // Step 3: re-register every invoice, rebuilding all indexes + for invoice in data.iter() { + crate::invoice::InvoiceStorage::store_invoice(env, &invoice); + } + + // Step 4: mark the backup as archived to prevent re-use + if let Some(mut backup) = Self::get_backup(env, backup_id) { + backup.status = BackupStatus::Archived; + // Ignore the result — the restore itself has already succeeded. + let _ = Self::update_backup(env, &backup); + } + + Ok(restored_count) + } + + /// Clean up old backups based on the retention policy. + /// + /// Removes backups that exceed `max_age_seconds`, then removes the oldest + /// backups until the count is within `max_backups`. Only `Active` backups + /// are considered; `Archived` and `Corrupted` backups are left untouched. + /// + /// Returns the number of backups removed. pub fn cleanup_old_backups(env: &Env) -> Result { let policy = Self::get_retention_policy(env); - // If auto cleanup is disabled, do nothing if !policy.auto_cleanup_enabled { return Ok(0); } @@ -243,22 +356,23 @@ impl BackupStorage { let current_time = env.ledger().timestamp(); let mut removed_count = 0u32; - // Create a vector of tuples (backup_id, timestamp) for sorting + // Build (backup_id, timestamp) pairs for active backups only. let mut backup_timestamps = Vec::new(env); for backup_id in backups.iter() { if let Some(backup) = Self::get_backup(env, &backup_id) { - // Only consider active backups for cleanup if backup.status == BackupStatus::Active { backup_timestamps.push_back((backup_id, backup.timestamp)); } } } - // Sort by timestamp (oldest first) using bubble sort + // Bubble sort: oldest first. let len = backup_timestamps.len(); for i in 0..len { for j in 0..len - i - 1 { - if backup_timestamps.get(j).unwrap().1 > backup_timestamps.get(j + 1).unwrap().1 { + if backup_timestamps.get(j).unwrap().1 + > backup_timestamps.get(j + 1).unwrap().1 + { let temp = backup_timestamps.get(j).unwrap().clone(); backup_timestamps.set(j, backup_timestamps.get(j + 1).unwrap().clone()); backup_timestamps.set(j + 1, temp); @@ -266,7 +380,7 @@ impl BackupStorage { } } - // First, remove backups that exceed max age (if configured) + // Remove backups that exceed max age. if policy.max_age_seconds > 0 { let mut i = 0; while i < backup_timestamps.len() { @@ -283,7 +397,7 @@ impl BackupStorage { } } - // Then, remove oldest backups if we exceed max_backups (if configured) + // Remove oldest backups until within the max_backups limit. if policy.max_backups > 0 { while backup_timestamps.len() > policy.max_backups { if let Some((oldest_id, _)) = backup_timestamps.first() { @@ -297,7 +411,10 @@ impl BackupStorage { Ok(removed_count) } - /// Retrieve all invoices from storage across all possible statuses + + /// Retrieve all invoices from storage across all possible statuses. + /// + /// Used when creating a new backup to snapshot the full current state. pub fn get_all_invoices(env: &Env) -> Vec { let mut all_invoices = Vec::new(env); let all_statuses = [ @@ -320,4 +437,4 @@ impl BackupStorage { } all_invoices } -} +} \ No newline at end of file diff --git a/quicklendx-contracts/src/invoice.rs b/quicklendx-contracts/src/invoice.rs index 0c98ec4e..3558ef1b 100644 --- a/quicklendx-contracts/src/invoice.rs +++ b/quicklendx-contracts/src/invoice.rs @@ -23,7 +23,6 @@ const DEFAULT_INVOICE_GRACE_PERIOD: u64 = 7 * 24 * 60 * 60; // 7 days default gr /// - The bytes are not valid UTF-8. pub(crate) fn normalize_tag(env: &Env, tag: &String) -> Result { let len = tag.len() as usize; - // Guard against inputs that exceed the maximum tag length. if len > 50 { return Err(QuickLendXError::InvalidTag); } @@ -31,12 +30,10 @@ pub(crate) fn normalize_tag(env: &Env, tag: &String) -> Result start && buf[end - 1] == b' ' { end -= 1; @@ -46,7 +43,6 @@ pub(crate) fn normalize_tag(env: &Env, tag: &String) -> Result= b'A' && *b <= b'Z' { *b += 32; @@ -86,36 +82,36 @@ pub enum DisputeStatus { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Dispute { - pub created_by: Address, // Address of the party who created the dispute - pub created_at: u64, // Timestamp when dispute was created - pub reason: String, // Reason for the dispute - pub evidence: String, // Evidence provided by the disputing party - pub resolution: String, // Resolution description (empty if not resolved) - pub resolved_by: Address, // Address of the party who resolved the dispute (zero address if not resolved) - pub resolved_at: u64, // Timestamp when dispute was resolved (0 if not resolved) + pub created_by: Address, + pub created_at: u64, + pub reason: String, + pub evidence: String, + pub resolution: String, + pub resolved_by: Address, + pub resolved_at: u64, } /// Invoice category enumeration #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum InvoiceCategory { - Services, // Professional services - Products, // Physical products - Consulting, // Consulting services - Manufacturing, // Manufacturing services - Technology, // Technology services/products - Healthcare, // Healthcare services - Other, // Other categories + Services, + Products, + Consulting, + Manufacturing, + Technology, + Healthcare, + Other, } /// Invoice rating structure #[contracttype] #[derive(Clone, Debug)] pub struct InvoiceRating { - pub rating: u32, // 1-5 stars - pub feedback: String, // Feedback text - pub rated_by: Address, // Investor who provided the rating - pub rated_at: u64, // Timestamp of rating + pub rating: u32, + pub feedback: String, + pub rated_by: Address, + pub rated_at: u64, } /// Invoice rating statistics @@ -174,44 +170,43 @@ impl InvoiceMetadata { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PaymentRecord { - pub amount: i128, // Amount paid in this transaction - pub timestamp: u64, // When the payment was recorded - pub transaction_id: String, // External transaction reference + pub amount: i128, + pub timestamp: u64, + pub transaction_id: String, } /// Core invoice data structure #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Invoice { - pub id: BytesN<32>, // Unique invoice identifier - pub business: Address, // Business that uploaded the invoice - pub amount: i128, // Total invoice amount - pub currency: Address, // Currency token address (XLM = Address::random()) - pub due_date: u64, // Due date timestamp - pub status: InvoiceStatus, // Current status of the invoice - pub created_at: u64, // Creation timestamp - pub description: String, // Invoice description/metadata + pub id: BytesN<32>, + pub business: Address, + pub amount: i128, + pub currency: Address, + pub due_date: u64, + pub status: InvoiceStatus, + pub created_at: u64, + pub description: String, pub metadata_customer_name: Option, pub metadata_customer_address: Option, pub metadata_tax_id: Option, pub metadata_notes: Option, pub metadata_line_items: Vec, - pub category: InvoiceCategory, // Invoice category - pub tags: Vec, // Invoice tags for better discoverability - pub funded_amount: i128, // Amount funded by investors - pub funded_at: Option, // When the invoice was funded - pub investor: Option
, // Address of the investor who funded - pub settled_at: Option, // When the invoice was settled - pub average_rating: Option, // Average rating (1-5) - pub total_ratings: u32, // Total number of ratings - pub ratings: Vec, // List of all ratings - pub dispute_status: DisputeStatus, // Current dispute status - pub dispute: Dispute, // Dispute details if any - pub total_paid: i128, // Aggregate amount paid towards the invoice - pub payment_history: Vec, // History of partial payments + pub category: InvoiceCategory, + pub tags: Vec, + pub funded_amount: i128, + pub funded_at: Option, + pub investor: Option
, + pub settled_at: Option, + pub average_rating: Option, + pub total_ratings: u32, + pub ratings: Vec, + pub dispute_status: DisputeStatus, + pub dispute: Dispute, + pub total_paid: i128, + pub payment_history: Vec, } -// Use the main error enum from errors.rs use crate::audit::{ log_invoice_created, log_invoice_funded, log_invoice_refunded, log_invoice_status_change, }; @@ -268,8 +263,6 @@ impl Invoice { ) -> Result { check_string_length(&description, MAX_DESCRIPTION_LENGTH)?; - // Normalize every tag before storage so the on-chain representation is always - // in canonical form regardless of how the caller formatted the input. let mut normalized_tags = Vec::new(env); for tag in tags.iter() { normalized_tags.push_back(normalize_tag(env, &tag)?); @@ -321,18 +314,11 @@ impl Invoice { payment_history: vec![env], }; - // Log invoice creation log_invoice_created(env, &invoice); Ok(invoice) } - /// @notice Derives a deterministic invoice ID candidate from a ledger slot and counter. - /// @dev The candidate format is `timestamp || sequence || counter || 16 zero bytes`. - /// @param timestamp Current ledger timestamp used for allocation. - /// @param sequence Current ledger sequence used for allocation. - /// @param counter Monotonic invoice counter for the contract instance. - /// @return A deterministic `BytesN<32>` candidate that can be checked for collisions. pub(crate) fn derive_invoice_id( env: &Env, timestamp: u64, @@ -346,11 +332,11 @@ impl Invoice { BytesN::from_array(env, &id_bytes) } - /// @notice Allocates a unique deterministic invoice ID for the current ledger slot. - /// @dev Probes forward on the monotonic counter until it finds an unused invoice key in - /// instance storage, so an existing invoice cannot be silently overwritten if a candidate - /// collides. Counter overflow aborts with `StorageError`. - /// @return A storage-safe invoice ID for the new invoice. + /// Allocates a unique deterministic invoice ID for the current ledger slot. + /// + /// Probes the monotonic counter until an unused key is found in instance storage, + /// ensuring an existing invoice cannot be silently overwritten on collision. + /// Counter overflow aborts with `StorageError`. fn generate_unique_invoice_id(env: &Env) -> Result, QuickLendXError> { let timestamp = env.ledger().timestamp(); let sequence = env.ledger().sequence(); @@ -423,7 +409,6 @@ impl Invoice { self.funded_at = Some(timestamp); self.investor = Some(investor.clone()); - // Log status change and funding log_invoice_status_change( env, self.id.clone(), @@ -440,7 +425,6 @@ impl Invoice { self.status = InvoiceStatus::Paid; self.settled_at = Some(timestamp); - // Log status change log_invoice_status_change(env, self.id.clone(), actor, old_status, self.status.clone()); } @@ -449,7 +433,6 @@ impl Invoice { let old_status = self.status.clone(); self.status = InvoiceStatus::Refunded; - // Log status change log_invoice_status_change( env, self.id.clone(), @@ -559,7 +542,6 @@ impl Invoice { let old_status = self.status.clone(); self.status = InvoiceStatus::Verified; - // Log status change log_invoice_status_change(env, self.id.clone(), actor, old_status, self.status.clone()); } @@ -570,7 +552,6 @@ impl Invoice { /// 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) if self.status != InvoiceStatus::Pending && self.status != InvoiceStatus::Verified { return Err(QuickLendXError::InvalidStatus); } @@ -578,7 +559,6 @@ impl Invoice { let old_status = self.status.clone(); self.status = InvoiceStatus::Cancelled; - // Log status change log_invoice_status_change(env, self.id.clone(), actor, old_status, self.status.clone()); Ok(()) @@ -592,31 +572,26 @@ impl Invoice { rater: Address, timestamp: u64, ) -> Result<(), QuickLendXError> { - // Validate invoice is funded if self.status != InvoiceStatus::Funded && self.status != InvoiceStatus::Paid { return Err(QuickLendXError::NotFunded); } check_string_length(&feedback, MAX_FEEDBACK_LENGTH)?; - // Verify rater is the investor if self.investor.as_ref() != Some(&rater) { return Err(QuickLendXError::NotRater); } - // Validate rating value if rating < 1 || rating > 5 { return Err(QuickLendXError::InvalidRating); } - // Check if rater has already rated for existing_rating in self.ratings.iter() { if existing_rating.rated_by == rater { return Err(QuickLendXError::AlreadyRated); } } - // Create new rating let invoice_rating = InvoiceRating { rating, feedback, @@ -624,11 +599,9 @@ impl Invoice { rated_at: timestamp, }; - // Add rating self.ratings.push_back(invoice_rating); self.total_ratings = self.total_ratings.saturating_add(1); - // Calculate new average rating (overflow-safe: sum is u64, count is u32) let sum: u64 = self.ratings.iter().map(|r| r.rating as u64).sum(); let count = self.total_ratings as u64; let avg = if count > 0 { @@ -700,7 +673,6 @@ impl Invoice { env: &Env, tag: String, ) -> Result<(), crate::errors::QuickLendXError> { - // 🔒 AUTH PROTECTION: Only the business that created the invoice can add tags. self.business.require_auth(); let normalized = normalize_tag(env, &tag)?; @@ -720,16 +692,13 @@ impl Invoice { } self.tags.push_back(normalized.clone()); - - // Update Index for discoverability InvoiceStorage::add_tag_index(env, &normalized, &self.id); - + Ok(()) } /// Remove a tag from the invoice (Business Owner Only). pub fn remove_tag(&mut self, tag: String) -> Result<(), crate::errors::QuickLendXError> { - // 🔒 AUTH PROTECTION self.business.require_auth(); let env = self.tags.env(); @@ -750,10 +719,8 @@ impl Invoice { } self.tags = new_tags; - - // Remove from Index InvoiceStorage::remove_tag_index(&env, &normalized, &self.id); - + Ok(()) } @@ -775,9 +742,16 @@ impl Invoice { false } - /// Update the invoice category - pub fn update_category(&mut self, category: InvoiceCategory) { + /// Update the invoice category. + /// + /// Rolls back the old category index entry and registers the new one so + /// the `cat_idx` secondary index is never left with a stale entry. + pub fn update_category(&mut self, env: &Env, category: InvoiceCategory) { + // Remove from old category index before mutating the field. + InvoiceStorage::remove_category_index(env, &self.category, &self.id); self.category = category; + // Register under the new category. + InvoiceStorage::add_category_index(env, &self.category, &self.id); } /// Get all tags as a vector @@ -792,6 +766,7 @@ pub(crate) const TOTAL_INVOICE_COUNT_KEY: soroban_sdk::Symbol = symbol_short!("t pub struct InvoiceStorage; impl InvoiceStorage { + fn category_key(category: &InvoiceCategory) -> (soroban_sdk::Symbol, InvoiceCategory) { (symbol_short!("cat_idx"), category.clone()) } @@ -800,14 +775,25 @@ impl InvoiceStorage { (symbol_short!("tag_idx"), tag.clone()) } - /// @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. - /// @param env The contract environment. - /// @param category The category bucket to update. - /// @param invoice_id The invoice to register. - /// @security Caller must ensure `invoice_id` refers to a stored invoice with - /// the matching category field to keep the index consistent. + /// Build the storage key for the customer-name metadata index. + fn metadata_customer_key(name: &String) -> (soroban_sdk::Symbol, String) { + (symbol_short!("meta_cust"), name.clone()) + } + + /// Build the storage key for the tax-ID metadata index. + fn metadata_tax_key(tax_id: &String) -> (soroban_sdk::Symbol, String) { + (symbol_short!("meta_tax"), tax_id.clone()) + } + + + /// Add an invoice to the category index. + /// + /// Deduplication guard: the invoice ID is appended only if not already + /// present, preventing duplicate entries that would corrupt count queries. + /// + /// # Security + /// Caller must ensure `invoice_id` refers to a stored invoice with the + /// matching `category` field to keep the index consistent. pub fn add_category_index(env: &Env, category: &InvoiceCategory, invoice_id: &BytesN<32>) { let key = Self::category_key(category); let mut invoices = env @@ -829,13 +815,11 @@ impl InvoiceStorage { } } - /// @notice Removes an invoice from the category index. - /// @dev Rebuilds the bucket without the target ID. Safe to call even if the - /// ID is absent (no-op). Must be called with the invoice's *old* category - /// before calling `add_category_index` with the new one to avoid stale entries. - /// @param env The contract environment. - /// @param category The category bucket to update. - /// @param invoice_id The invoice to deregister. + /// Remove an invoice from the category index. + /// + /// Rebuilds the bucket without the target ID. Safe to call even if the + /// ID is absent (no-op). Must be called with the invoice's *old* category + /// before calling `add_category_index` with the new one to avoid stale entries. pub fn remove_category_index(env: &Env, category: &InvoiceCategory, invoice_id: &BytesN<32>) { let key = Self::category_key(category); if let Some(invoices) = env.storage().instance().get::<_, Vec>>(&key) { @@ -849,6 +833,7 @@ impl InvoiceStorage { } } + /// Add an invoice to the tag index bucket for `tag`. pub fn add_tag_index(env: &Env, tag: &String, invoice_id: &BytesN<32>) { let key = Self::tag_key(tag); let mut invoices = env @@ -869,6 +854,7 @@ impl InvoiceStorage { } } + /// Remove an invoice from the tag index bucket for `tag`. pub fn remove_tag_index(env: &Env, tag: &String, invoice_id: &BytesN<32>) { let key = Self::tag_key(tag); if let Some(invoices) = env.storage().instance().get::<_, Vec>>(&key) { @@ -882,12 +868,13 @@ impl InvoiceStorage { } } - /// Store an invoice + // ── Core CRUD ────────────────────────────────────────────────────────── + + /// Store an invoice and register it in all secondary indexes. pub fn store_invoice(env: &Env, invoice: &Invoice) { let is_new = !env.storage().instance().has(&invoice.id); env.storage().instance().set(&invoice.id, invoice); - // Update total count if this is a new invoice if is_new { let mut count: u32 = env .storage() @@ -900,16 +887,10 @@ impl InvoiceStorage { .set(&TOTAL_INVOICE_COUNT_KEY, &count); } - // Add to business invoices list Self::add_to_business_invoices(env, &invoice.business, &invoice.id); - - // Add to status invoices list Self::add_to_status_invoices(env, &invoice.status, &invoice.id); - - // Add to category index Self::add_category_index(env, &invoice.category, &invoice.id); - // Add to tag indexes for tag in invoice.tags.iter() { Self::add_tag_index(env, &tag, &invoice.id); } @@ -925,34 +906,81 @@ impl InvoiceStorage { env.storage().instance().set(&invoice.id, invoice); } - /// Clear all invoices from storage (used by backup restore) + // Atomic clear + + /// Atomically clear all invoice data and every secondary index from storage. + /// + /// # Invariant + /// After this call there must be zero orphaned entries in any index. + /// The clearing sequence is: + /// + /// 1. **Collect all live invoice IDs** across every status bucket (single read pass). + /// 2. **For each invoice** — read the full record and erase: + /// - the invoice record itself, + /// - its business-index entry, + /// - its category-index entry, + /// - every tag-index entry, + /// - every metadata-index entry. + /// 3. **Erase all status-bucket keys** (the bucket lists, not just their contents). + /// 4. **Reset the total invoice counter** to zero. + /// 5. Delegate any remaining storage mappings to `StorageManager::clear_all_mappings`. + /// + /// # Security + /// This function must only be callable by an admin-guarded contract entry point. + /// Callers should invoke `backup_all_invoices` before calling this function so that + /// a restore path exists if the clear is performed in error. pub fn clear_all(env: &Env) { - // Clear each invoice from each status list - for status in [ + let all_statuses = [ InvoiceStatus::Pending, InvoiceStatus::Verified, InvoiceStatus::Funded, InvoiceStatus::Paid, InvoiceStatus::Defaulted, InvoiceStatus::Cancelled, - ] { - let ids = Self::get_invoices_by_status(env, &status); - for id in ids.iter() { + InvoiceStatus::Refunded, + ]; + + let mut all_ids = Vec::new(env); + for status in all_statuses.iter() { + for id in Self::get_invoices_by_status(env, status).iter() { + all_ids.push_back(id); + } + } + + // ── Step 2: erase each invoice record and all its index entries ──── + for id in all_ids.iter() { + if let Some(invoice) = Self::get_invoice(env, &id) { + // Remove from business index + let biz_key = (symbol_short!("business"), invoice.business.clone()); + env.storage().instance().remove(&biz_key); + + // Remove from category index + Self::remove_category_index(env, &invoice.category, &id); + + // Remove from every tag index + for tag in invoice.tags.iter() { + Self::remove_tag_index(env, &tag, &id); + } + + // Remove from metadata indexes + if let Some(md) = invoice.metadata() { + Self::remove_metadata_indexes(env, &md, &id); + } + + // Remove the invoice record itself env.storage().instance().remove(&id); } - let key = match status { - InvoiceStatus::Pending => symbol_short!("pending"), - InvoiceStatus::Verified => symbol_short!("verified"), - InvoiceStatus::Funded => symbol_short!("funded"), - InvoiceStatus::Paid => symbol_short!("paid"), - InvoiceStatus::Defaulted => symbol_short!("default"), - InvoiceStatus::Cancelled => symbol_short!("cancel"), - InvoiceStatus::Refunded => symbol_short!("refunded"), - }; + } + + for status in all_statuses.iter() { + let key = Self::status_key(status); env.storage().instance().remove(&key); } - // Unify with other storage cleanups + env.storage() + .instance() + .set(&TOTAL_INVOICE_COUNT_KEY, &0u32); + crate::storage::StorageManager::clear_all_mappings(env); } @@ -971,7 +999,6 @@ impl InvoiceStorage { let mut count = 0u32; for invoice_id in business_invoices.iter() { if let Some(invoice) = Self::get_invoice(env, &invoice_id) { - // Only count active invoices (not Cancelled or Paid) if !matches!( invoice.status, InvoiceStatus::Cancelled | InvoiceStatus::Paid @@ -983,9 +1010,14 @@ impl InvoiceStorage { count } - /// Get all invoices by status - pub fn get_invoices_by_status(env: &Env, status: &InvoiceStatus) -> Vec> { - let key = match status { + /// Return the storage key for a status bucket. + /// + /// Centralised here so `clear_all`, `add_to_status_invoices`, and + /// `remove_from_status_invoices` all use the identical key — previously + /// `clear_all` used different symbol literals than the add/remove helpers, + /// which meant the status buckets were never actually erased on clear. + fn status_key(status: &InvoiceStatus) -> soroban_sdk::Symbol { + match status { InvoiceStatus::Pending => symbol_short!("pending"), InvoiceStatus::Verified => symbol_short!("verified"), InvoiceStatus::Funded => symbol_short!("funded"), @@ -993,32 +1025,20 @@ impl InvoiceStorage { InvoiceStatus::Defaulted => symbol_short!("default"), InvoiceStatus::Cancelled => symbol_short!("canceld"), InvoiceStatus::Refunded => symbol_short!("refundd"), - }; + } + } + + /// Get all invoices by status + pub fn get_invoices_by_status(env: &Env, status: &InvoiceStatus) -> Vec> { env.storage() .instance() - .get(&key) + .get(&Self::status_key(status)) .unwrap_or_else(|| Vec::new(env)) } - /// Add invoice to business invoices list - fn add_to_business_invoices(env: &Env, business: &Address, invoice_id: &BytesN<32>) { - let key = (symbol_short!("business"), business.clone()); - let mut invoices = Self::get_business_invoices(env, business); - invoices.push_back(invoice_id.clone()); - env.storage().instance().set(&key, &invoices); - } - /// Add invoice to status invoices list pub fn add_to_status_invoices(env: &Env, status: &InvoiceStatus, invoice_id: &BytesN<32>) { - let key = match status { - InvoiceStatus::Pending => symbol_short!("pending"), - InvoiceStatus::Verified => symbol_short!("verified"), - InvoiceStatus::Funded => symbol_short!("funded"), - InvoiceStatus::Paid => symbol_short!("paid"), - InvoiceStatus::Defaulted => symbol_short!("default"), - InvoiceStatus::Cancelled => symbol_short!("canceld"), - InvoiceStatus::Refunded => symbol_short!("refundd"), - }; + let key = Self::status_key(status); let mut invoices = env .storage() .instance() @@ -1032,32 +1052,32 @@ impl InvoiceStorage { /// Remove invoice from status invoices list pub fn remove_from_status_invoices(env: &Env, status: &InvoiceStatus, invoice_id: &BytesN<32>) { - let key = match status { - InvoiceStatus::Pending => symbol_short!("pending"), - InvoiceStatus::Verified => symbol_short!("verified"), - InvoiceStatus::Funded => symbol_short!("funded"), - InvoiceStatus::Paid => symbol_short!("paid"), - InvoiceStatus::Defaulted => symbol_short!("default"), - InvoiceStatus::Cancelled => symbol_short!("canceld"), - InvoiceStatus::Refunded => symbol_short!("refundd"), - }; let invoices = Self::get_invoices_by_status(env, status); - - // Find and remove the invoice ID let mut new_invoices = Vec::new(env); for id in invoices.iter() { if id != *invoice_id { new_invoices.push_back(id); } } + env.storage() + .instance() + .set(&Self::status_key(status), &new_invoices); + } - env.storage().instance().set(&key, &new_invoices); + // ── Business index (private write) ───────────────────────────────────── + + fn add_to_business_invoices(env: &Env, business: &Address, invoice_id: &BytesN<32>) { + let key = (symbol_short!("business"), business.clone()); + let mut invoices = Self::get_business_invoices(env, business); + invoices.push_back(invoice_id.clone()); + env.storage().instance().set(&key, &invoices); } + // ── Rating index ─────────────────────────────────────────────────────── + /// Get invoices with ratings above a threshold pub fn get_invoices_with_rating_above(env: &Env, threshold: u32) -> Vec> { let mut high_rated_invoices = vec![env]; - // Get all invoices and filter by rating let all_statuses = [InvoiceStatus::Funded, InvoiceStatus::Paid]; for status in all_statuses.iter() { let invoices = Self::get_invoices_by_status(env, status); @@ -1074,9 +1094,7 @@ impl InvoiceStorage { high_rated_invoices } - // 🛡️ INDEX ROLLBACK PROTECTION - // Remove the invoice from the old category index before updating - InvoiceStorage::remove_category_index(env, &self.category, &self.id); + // ── Metadata index ───────────────────────────────────────────────────── fn add_to_metadata_index( env: &Env, @@ -1114,6 +1132,7 @@ impl InvoiceStorage { } } + /// Register an invoice in the customer-name and tax-ID metadata indexes. pub fn add_metadata_indexes(env: &Env, invoice: &Invoice) { if let Some(name) = &invoice.metadata_customer_name { if name.len() > 0 { @@ -1130,6 +1149,7 @@ impl InvoiceStorage { } } + /// Remove an invoice from the customer-name and tax-ID metadata indexes. pub fn remove_metadata_indexes(env: &Env, metadata: &InvoiceMetadata, invoice_id: &BytesN<32>) { if metadata.customer_name.len() > 0 { let key = Self::metadata_customer_key(&metadata.customer_name); @@ -1142,6 +1162,7 @@ impl InvoiceStorage { } } + /// Look up invoice IDs by customer name. pub fn get_invoices_by_customer(env: &Env, customer_name: &String) -> Vec> { env.storage() .instance() @@ -1149,6 +1170,7 @@ impl InvoiceStorage { .unwrap_or_else(|| Vec::new(env)) } + /// Look up invoice IDs by tax ID. pub fn get_invoices_by_tax_id(env: &Env, tax_id: &String) -> Vec> { env.storage() .instance() @@ -1156,7 +1178,6 @@ impl InvoiceStorage { .unwrap_or_else(|| Vec::new(env)) } - /// Completely remove an invoice from storage and all its indexes (used by backup restore) pub fn delete_invoice(env: &Env, invoice_id: &BytesN<32>) { if let Some(invoice) = Self::get_invoice(env, invoice_id) { // Remove from status index @@ -1204,9 +1225,12 @@ impl InvoiceStorage { .set(&TOTAL_INVOICE_COUNT_KEY, &count); } - // Add to the new category index - InvoiceStorage::add_category_index(env, &self.category, &self.id); + // Finally remove the invoice record itself + env.storage().instance().remove(invoice_id); } + } + + // ── Global counter ───────────────────────────────────────────────────── /// Get total count of active invoices in the system pub fn get_total_invoice_count(env: &Env) -> u32 { @@ -1215,5 +1239,4 @@ impl InvoiceStorage { .get(&TOTAL_INVOICE_COUNT_KEY) .unwrap_or(0) } -} -} +} \ No newline at end of file diff --git a/quicklendx-contracts/src/test_backup.rs b/quicklendx-contracts/src/test_backup.rs index 4edaa381..781ed472 100644 --- a/quicklendx-contracts/src/test_backup.rs +++ b/quicklendx-contracts/src/test_backup.rs @@ -13,6 +13,7 @@ use soroban_sdk::{ fn setup() -> (Env, QuickLendXContractClient<'static>, Address) { let env = Env::default(); env.mock_all_auths(); + env.ledger().set_timestamp(1_000_000); let contract_id = env.register(QuickLendXContract, ()); let client = QuickLendXContractClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -41,6 +42,74 @@ fn create_invoice( ) } + /// Create a minimal Invoice suitable for backup tests. + fn make_invoice(env: &Env, idx: u32) -> Invoice { + use soroban_sdk::{vec, Address, BytesN}; + use crate::invoice::{Dispute, DisputeStatus}; + + let mut id_bytes = [0u8; 32]; + id_bytes[28..32].copy_from_slice(&idx.to_be_bytes()); + let id = BytesN::from_array(env, &id_bytes); + + Invoice { + id, + business: Address::generate(env), + amount: 500_i128 * (idx as i128 + 1), + currency: Address::generate(env), + due_date: 9_999_999_999, + status: InvoiceStatus::Pending, + created_at: env.ledger().timestamp(), + description: String::from_str(env, "backup test"), + metadata_customer_name: None, + metadata_customer_address: None, + metadata_tax_id: None, + metadata_notes: None, + metadata_line_items: soroban_sdk::Vec::new(env), + category: InvoiceCategory::Services, + tags: soroban_sdk::Vec::new(env), + funded_amount: 0, + funded_at: None, + investor: None, + settled_at: None, + average_rating: None, + total_ratings: 0, + ratings: vec![env], + dispute_status: DisputeStatus::None, + dispute: Dispute { + created_by: Address::generate(env), + created_at: 0, + reason: String::from_str(env, ""), + evidence: String::from_str(env, ""), + resolution: String::from_str(env, ""), + resolved_by: Address::generate(env), + resolved_at: 0, + }, + total_paid: 0, + payment_history: vec![env], + } + } + + /// Persist a complete, valid backup (metadata + data) and return its ID. + fn create_valid_backup(env: &Env, invoices: Vec) -> soroban_sdk::BytesN<32> { + let backup_id = BackupStorage::generate_backup_id(env); + let count = invoices.len(); + + let backup = Backup { + backup_id: backup_id.clone(), + timestamp: env.ledger().timestamp(), + description: String::from_str(env, "test backup"), + invoice_count: count, + status: BackupStatus::Active, + }; + + BackupStorage::store_backup(env, &backup, Some(&invoices)).unwrap(); + BackupStorage::store_backup_data(env, &backup_id, &invoices); + BackupStorage::add_to_backup_list(env, &backup_id); + + backup_id + } + + #[test] fn test_create_backup_requires_admin_and_stores_valid_metadata() { let (env, client, admin) = setup(); @@ -212,3 +281,52 @@ fn test_update_backup_rejects_invalid_description() { let result = BackupStorage::update_backup(&env, &invalid); assert_eq!(result, Err(QuickLendXError::InvalidDescription)); } + + + #[test] + fn validate_backup_fails_when_record_missing() { + let env = setup_env(); + let id = BackupStorage::generate_backup_id(&env); + // No record stored — must fail. + assert!(BackupStorage::validate_backup(&env, &id).is_err()); + } + + #[test] + fn validate_backup_fails_when_data_missing() { + let env = setup_env(); + let backup_id = BackupStorage::generate_backup_id(&env); + let backup = Backup { + backup_id: backup_id.clone(), + timestamp: env.ledger().timestamp(), + description: String::from_str(&env, "no data"), + invoice_count: 1, + status: BackupStatus::Active, + }; + BackupStorage::store_backup(&env, &backup, None).unwrap(); + // Data blob never stored. + assert!(BackupStorage::validate_backup(&env, &backup_id).is_err()); + } + + #[test] + fn validate_backup_fails_on_count_mismatch() { + let env = setup_env(); + let backup_id = BackupStorage::generate_backup_id(&env); + let invoices: Vec = { + let mut v = Vec::new(&env); + v.push_back(make_invoice(&env, 0)); + v + }; + + // Claim count = 2, but only 1 invoice in data. + let backup = Backup { + backup_id: backup_id.clone(), + timestamp: env.ledger().timestamp(), + description: String::from_str(&env, "mismatch"), + invoice_count: 2, + status: BackupStatus::Active, + }; + env.storage().instance().set(&backup_id, &backup); + BackupStorage::store_backup_data(&env, &backup_id, &invoices); + + assert!(BackupStorage::validate_backup(&env, &backup_id).is_err()); + } diff --git a/quicklendx-contracts/src/test_storage.rs b/quicklendx-contracts/src/test_storage.rs index c1d60e4a..06bda9b6 100644 --- a/quicklendx-contracts/src/test_storage.rs +++ b/quicklendx-contracts/src/test_storage.rs @@ -13,7 +13,7 @@ use crate::bid::{Bid, BidStatus}; use crate::investment::{Investment, InvestmentStatus}; use crate::invoice::{ Dispute, Invoice, InvoiceCategory, InvoiceMetadata, InvoiceStatus, LineItemRecord, - PaymentRecord, + PaymentRecord, InoiceStorage, TOTAL_INCOICE_COUNT_KEY, }; use crate::profits::{PlatformFee, PlatformFeeConfig}; use crate::storage::{ @@ -21,6 +21,62 @@ use crate::storage::{ }; use crate::QuickLendXContract; +/// Create a minimal valid `Invoice` stub directly (bypasses `Invoice::new` + /// which requires the full contract environment with audit logging). + fn make_invoice(env: &Env, idx: u32) -> Invoice { + use soroban_sdk::{vec, Address, BytesN, String}; + use crate::invoice::{ + Dispute, DisputeStatus, InvoiceCategory, InvoiceStatus, LineItemRecord, + }; + + let mut id_bytes = [0u8; 32]; + id_bytes[28..32].copy_from_slice(&idx.to_be_bytes()); + let id = BytesN::from_array(env, &id_bytes); + + Invoice { + id, + business: Address::generate(env), + amount: 1_000_i128 * (idx as i128 + 1), + currency: Address::generate(env), + due_date: 9_999_999_999, + status: InvoiceStatus::Pending, + created_at: env.ledger().timestamp(), + description: String::from_str(env, "test invoice"), + metadata_customer_name: None, + metadata_customer_address: None, + metadata_tax_id: None, + metadata_notes: None, + metadata_line_items: soroban_sdk::Vec::new(env), + category: InvoiceCategory::Services, + tags: soroban_sdk::Vec::new(env), + funded_amount: 0, + funded_at: None, + investor: None, + settled_at: None, + average_rating: None, + total_ratings: 0, + ratings: vec![env], + dispute_status: DisputeStatus::None, + dispute: Dispute { + created_by: Address::generate(env), + created_at: 0, + reason: String::from_str(env, ""), + evidence: String::from_str(env, ""), + resolution: String::from_str(env, ""), + resolved_by: Address::generate(env), + resolved_at: 0, + }, + total_paid: 0, + payment_history: vec![env], + } + } + + fn setup_env() -> Env { + let env = Env::default(); + env.ledger().set_timestamp(1_000_000); + env + } + fn with_registered_contract(env: &Env, f: F) { let contract_id = env.register(QuickLendXContract, ()); env.as_contract(&contract_id, f); @@ -1012,3 +1068,43 @@ fn test_storage_retrieval_consistency() { assert_eq!(invoice, retrieved1, "Retrieved should match stored"); }); } + + #[test] + fn clear_all_removes_invoice_records() { + let env = setup_env(); + let inv = make_invoice(&env, 0); + let id = inv.id.clone(); + InvoiceStorage::store_invoice(&env, &inv); + + assert!(InvoiceStorage::get_invoice(&env, &id).is_some()); + InvoiceStorage::clear_all(&env); + assert!(InvoiceStorage::get_invoice(&env, &id).is_none()); + } + + #[test] + fn clear_all_empties_status_buckets() { + let env = setup_env(); + let inv = make_invoice(&env, 1); + InvoiceStorage::store_invoice(&env, &inv); + + InvoiceStorage::clear_all(&env); + + let pending = InvoiceStorage::get_invoices_by_status(&env, &InvoiceStatus::Pending); + assert_eq!(pending.len(), 0, "pending bucket must be empty after clear_all"); + } + + #[test] + fn clear_all_empties_category_index() { + let env = setup_env(); + let inv = make_invoice(&env, 2); + InvoiceStorage::store_invoice(&env, &inv); + + InvoiceStorage::clear_all(&env); + + // After clear the category index for Services must be empty. + let key = (soroban_sdk::symbol_short!("cat_idx"), InvoiceCategory::Services); + let bucket: Option>> = + env.storage().instance().get(&key); + let len = bucket.map(|v| v.len()).unwrap_or(0); + assert_eq!(len, 0, "category index must have no orphans after clear_all"); + }