diff --git a/docs/contracts/dispute.md b/docs/contracts/dispute.md index 121d82a6..fa4336bd 100644 --- a/docs/contracts/dispute.md +++ b/docs/contracts/dispute.md @@ -1,269 +1,614 @@ -# Dispute Resolution - -## Overview - -Complete dispute lifecycle management for invoice financing disputes. Enables business owners and investors to raise disputes on funded or settled invoices, with admin-controlled review and resolution process. - -Dispute data is embedded within the `Invoice` struct to keep dispute state co-located with the invoice it belongs to. All string fields are bounded by protocol-enforced limits to prevent abusive on-chain storage growth. - -## Dispute Lifecycle - -``` -None → Disputed → UnderReview → Resolved -``` - -1. **None**: No dispute exists (default state) -2. **Disputed**: Dispute created by business or investor -3. **UnderReview**: Admin has acknowledged and is investigating -4. **Resolved**: Admin has provided final resolution - -## Data Structure - -### DisputeStatus - -```rust -pub enum DisputeStatus { - None, // No dispute exists (default) - Disputed, // Dispute has been created - UnderReview, // Admin reviewing - Resolved, // Final state -} -``` - -### Dispute - -| Field | Type | Description | -|-------|------|-------------| -| `created_by` | `Address` | Dispute initiator (business or investor) | -| `created_at` | `u64` | Creation timestamp | -| `reason` | `String` | Dispute reason (1–1000 chars) | -| `evidence` | `String` | Supporting evidence (1–2000 chars) | -| `resolution` | `String` | Admin resolution text (1–2000 chars when set) | -| `resolved_by` | `Address` | Admin who resolved the dispute | -| `resolved_at` | `u64` | Resolution timestamp (0 if unresolved) | - -## Input Validation — Storage Growth Prevention - -All text fields are validated against protocol limits defined in `protocol_limits.rs` to prevent adversarial callers from inflating on-chain storage costs with oversized payloads. - -### Field Length Constraints - -| Field | Minimum | Maximum | Constant | Error Code | -|-------|---------|---------|----------|------------| -| Reason | 1 char | 1000 chars | `MAX_DISPUTE_REASON_LENGTH` | `InvalidDisputeReason` (1905) | -| Evidence | 1 char | 2000 chars | `MAX_DISPUTE_EVIDENCE_LENGTH` | `InvalidDisputeEvidence` (1906) | -| Resolution | 1 char | 2000 chars | `MAX_DISPUTE_RESOLUTION_LENGTH` | `InvalidDisputeReason` (1905) | - -### Validation Functions (`verification.rs`) - -| Function | Validates | Rejects | -|----------|-----------|---------| -| `validate_dispute_reason(reason)` | Non-empty, ≤ 1000 chars | Empty or oversized reason | -| `validate_dispute_evidence(evidence)` | Non-empty, ≤ 2000 chars | Empty or oversized evidence | -| `validate_dispute_resolution(resolution)` | Non-empty, ≤ 2000 chars | Empty or oversized resolution | -| `validate_dispute_eligibility(invoice, creator)` | Invoice status, authorization, no duplicate | Ineligible invoices | - -### Security Assumptions - -- **No empty payloads**: Empty reason or evidence is rejected to prevent frivolous disputes. -- **Bounded storage**: Maximum total dispute payload per invoice ≤ 5000 chars (reason + evidence + resolution). -- **One dispute per invoice**: Prevents spam by allowing only a single dispute per invoice. -- **Immutable once created**: Dispute creator and creation timestamp cannot be modified after creation. - -## Contract Interface - -### User Functions - -#### `create_dispute(invoice_id: BytesN<32>, creator: Address, reason: String, evidence: String) -> Result<(), QuickLendXError>` - -Creates a new dispute for an invoice. - -**Preconditions:** -- `creator.require_auth()` passes -- Invoice exists and is in Pending, Verified, Funded, or Paid status -- Creator is either business owner or investor on the invoice -- No existing dispute for this invoice (`dispute_status == None`) -- Reason: 1–1000 characters (non-empty, bounded) -- Evidence: 1–2000 characters (non-empty, bounded) - -**Errors:** -- `InvoiceNotFound`: Invoice does not exist -- `InvoiceNotAvailableForFunding`: Invoice not in valid state for disputes -- `DisputeNotAuthorized`: Creator is not business or investor -- `DisputeAlreadyExists`: Dispute already exists for this invoice -- `InvalidDisputeReason` (1905): Reason empty or exceeds 1000 chars -- `InvalidDisputeEvidence` (1906): Evidence empty or exceeds 2000 chars - -### Admin Functions - -#### `put_dispute_under_review(invoice_id: BytesN<32>, admin: Address) -> Result<(), QuickLendXError>` - -Moves dispute from Disputed to UnderReview status. - -**Preconditions:** -- Caller must be admin -- Invoice exists -- Dispute status must be Disputed - -**Errors:** -- `Unauthorized`: Caller not admin -- `NotAdmin`: Admin not configured -- `InvoiceNotFound`: Invoice does not exist -- `DisputeNotFound`: No dispute exists (status is not Disputed) - -#### `resolve_dispute(invoice_id: BytesN<32>, admin: Address, resolution: String) -> Result<(), QuickLendXError>` - -Finalizes dispute with resolution text. - -**Preconditions:** -- Caller must be admin -- Dispute must be in UnderReview status -- Resolution: 1–2000 characters (non-empty, bounded) - -**Errors:** -- `Unauthorized`: Caller not admin -- `NotAdmin`: Admin not configured -- `InvoiceNotFound`: Invoice does not exist -- `DisputeNotUnderReview`: Dispute not in UnderReview status -- `InvalidDisputeReason` (1905): Resolution empty or exceeds 2000 chars - -### Query Functions - -#### `get_dispute_details(invoice_id: BytesN<32>) -> Option` - -Returns dispute details if a dispute exists, `None` otherwise. - -#### `get_invoice_dispute_status(invoice_id: BytesN<32>) -> DisputeStatus` - -Returns the current dispute status for an invoice. - -#### `get_invoices_with_disputes() -> Vec>` - -Returns all invoice IDs that have an active or resolved dispute (status != None). - -#### `get_invoices_by_dispute_status(status: DisputeStatus) -> Vec>` - -Returns invoice IDs filtered by the given dispute status. - -## Integration - -### Invoice State Requirements - -Disputes can only be created for invoices in specific states: - -| Invoice Status | Can Create Dispute | -|----------------|-------------------| -| Pending | Yes | -| Verified | Yes | -| Funded | Yes | -| Paid | Yes | -| Defaulted | No | -| Cancelled | No | - -### Authorization Model - -**Create Dispute:** -- Business owner of the invoice -- Investor who funded the invoice - -**Review/Resolve:** -- Platform admin only - -### Usage Example - -```rust -// Business creates dispute -client.create_dispute( - &invoice_id, - &business_address, - &String::from_str(&env, "Payment not received after due date"), - &String::from_str(&env, "Transaction ID: ABC123, Expected: 2025-01-15"), -); - -// Admin puts under review -client.put_dispute_under_review(&invoice_id, &admin_address); - -// Admin resolves -client.resolve_dispute( - &invoice_id, - &admin_address, - &String::from_str(&env, "Verified payment delay. Instructed business to release funds."), -); - -// Query dispute -let dispute = client.get_dispute_details(&invoice_id); -assert!(dispute.is_some()); -``` - -## State Transition Rules - -| Current Status | Allowed Transition | Required Role | -|----------------|-------------------|---------------| -| None | Disputed | Business / Investor | -| Disputed | UnderReview | Admin | -| UnderReview | Resolved | Admin | -| Resolved | None (terminal) | - | - -## Error Handling - -All operations return `Result`: - -| Error | Code | Symbol | Condition | -|-------|------|--------|-----------| -| `DisputeNotFound` | 1900 | `DSP_NF` | Dispute does not exist | -| `DisputeAlreadyExists` | 1901 | `DSP_EX` | Duplicate dispute creation | -| `DisputeNotAuthorized` | 1902 | `DSP_NA` | Unauthorized creator | -| `DisputeAlreadyResolved` | 1903 | `DSP_RS` | Dispute already finalized | -| `DisputeNotUnderReview` | 1904 | `DSP_UR` | Invalid status for resolution | -| `InvalidDisputeReason` | 1905 | `DSP_RN` | Reason/resolution validation failed | -| `InvalidDisputeEvidence` | 1906 | `DSP_EV` | Evidence validation failed | - -## Test Coverage - -Test suites: `test_dispute.rs`, `test_string_limits.rs`, and `test.rs`. - -### Covered Scenarios - -1. **Dispute Creation** (8 tests): - - Business can create dispute - - Unauthorized parties rejected - - Duplicate disputes rejected - - Reason validation: empty, too long, boundary (1 char, 1000 chars) - - Evidence validation: empty, too long - - Nonexistent invoice rejected - -2. **Status Transitions** (6 tests): - - Disputed → UnderReview (admin only) - - UnderReview → Resolved (admin only) - - Invalid transitions rejected - - Cannot re-review resolved disputes - - Cannot resolve non-reviewed disputes - -3. **Resolution Validation** (2 tests): - - Empty resolution rejected - - Oversized resolution rejected - -4. **Query Functions** (7 tests): - - get_dispute_details returns correct data - - get_invoices_with_disputes lists all disputed invoices - - get_invoices_by_dispute_status filters by status (None, Disputed, UnderReview, Resolved) - - Status lists update correctly during transitions - - Multiple disputes on different invoices - -5. **String Limits** (1 test in test_string_limits.rs): - - Dispute reason and evidence at exact boundary - -**Estimated Coverage: 95%+** - -## Deployment Checklist - -- [ ] Initialize contract with admin address -- [ ] Verify admin authorization works correctly -- [ ] Confirm dispute creation restricted to eligible invoice states -- [ ] Test state transitions (None → Disputed → UnderReview → Resolved) -- [ ] Validate field length constraints (reason ≤ 1000, evidence ≤ 2000, resolution ≤ 2000) -- [ ] Verify empty payloads are rejected -- [ ] Verify only invoice participants can create disputes -- [ ] Test query functions (get_dispute_details, get_invoices_with_disputes, get_invoices_by_dispute_status) -- [ ] Document admin dispute resolution procedures -- [ ] Set up monitoring for open disputes +<<<<<<< feature/dispute-resolution-finality +# Dispute Resolution + +## Overview + +Complete dispute lifecycle management for invoice financing disputes. Enables +business owners and investors to raise disputes on invoices, with an +admin-controlled review and resolution process. + +The central security property is **dispute locking**: once a dispute reaches +the `Resolved` state it is **terminal and immutable**. No further state +transitions are possible without an explicit policy-override path, preventing +accidental or malicious overwrites of finalized resolutions. + +--- + +## Dispute Lifecycle + +``` +(none) ──create──▶ Disputed ──review──▶ UnderReview ──resolve──▶ Resolved + │ + TERMINAL / LOCKED +``` + +| Step | Transition | Actor | Notes | +|------|-----------|-------|-------| +| 1 | `None → Disputed` | Business or Investor | One dispute per invoice | +| 2 | `Disputed → UnderReview` | Admin only | Forward-only | +| 3 | `UnderReview → Resolved` | Admin only | **Terminal — locked** | + +Any attempt to call `resolve_dispute` on an already-`Resolved` dispute returns +`DisputeNotUnderReview` because the status is no longer `UnderReview`. This is +the locking mechanism — no special flag is needed; the state machine itself +enforces immutability. + +--- + +## Data Structures + +### `DisputeStatus` (in `invoice.rs`) + +```rust +pub enum DisputeStatus { + None, // No dispute exists + Disputed, // Dispute opened by business or investor + UnderReview, // Admin is investigating + Resolved, // Admin has issued a final resolution (TERMINAL) +} +``` + +### `Dispute` (in `invoice.rs`) + +| Field | Type | Description | +|-------|------|-------------| +| `created_by` | `Address` | Dispute initiator (business or investor) | +| `created_at` | `u64` | Creation timestamp (write-once) | +| `reason` | `String` | Dispute reason (1–1000 chars) | +| `evidence` | `String` | Supporting evidence (1–2000 chars) | +| `resolution` | `String` | Admin resolution text (empty until resolved) | +| `resolved_by` | `Address` | Admin who resolved (placeholder until resolved) | +| `resolved_at` | `u64` | Resolution timestamp (0 until resolved) | + +--- + +## API Functions + +### User Functions + +#### `create_dispute(invoice_id, creator, reason, evidence) → Result<(), Error>` + +Opens a dispute on an invoice. + +**Preconditions:** +- `creator` must sign the transaction (`require_auth`) +- Invoice must exist +- No existing dispute on this invoice (`DisputeStatus::None`) +- `creator` must be the invoice's business owner or its investor +- `reason`: 1–1000 characters +- `evidence`: 1–2000 characters + +**Errors:** + +| Error | Condition | +|-------|-----------| +| `InvoiceNotFound` | Invoice does not exist | +| `DisputeAlreadyExists` | A dispute already exists on this invoice | +| `DisputeNotAuthorized` | Caller is neither business nor investor | +| `InvalidDisputeReason` | Reason is empty or exceeds 1000 chars | +| `InvalidDisputeEvidence` | Evidence is empty or exceeds 2000 chars | + +--- + +### Admin Functions + +#### `put_dispute_under_review(invoice_id, admin) → Result<(), Error>` + +Advances a dispute from `Disputed` to `UnderReview`. + +**Preconditions:** +- `admin` must sign the transaction (`require_auth`) +- `admin` must match the stored admin address +- A dispute must exist on the invoice +- Dispute must be in `Disputed` state + +**Errors:** + +| Error | Condition | +|-------|-----------| +| `NotAdmin` | Caller is not the stored admin | +| `InvoiceNotFound` | Invoice does not exist | +| `DisputeNotFound` | No dispute exists on this invoice | +| `InvalidStatus` | Dispute is not in `Disputed` state (includes `UnderReview` and `Resolved`) | + +--- + +#### `resolve_dispute(invoice_id, admin, resolution) → Result<(), Error>` + +Finalizes a dispute with a resolution text. **This is the locking operation.** + +**Preconditions:** +- `admin` must sign the transaction (`require_auth`) +- `admin` must match the stored admin address +- A dispute must exist on the invoice +- Dispute must be in `UnderReview` state +- `resolution`: 1–2000 characters + +**Locking invariant:** A second call on an already-`Resolved` dispute returns +`DisputeNotUnderReview` because the status is no longer `UnderReview`. The +`resolution`, `resolved_by`, and `resolved_at` fields are written atomically +and cannot be overwritten. + +**Errors:** + +| Error | Condition | +|-------|-----------| +| `NotAdmin` | Caller is not the stored admin | +| `InvoiceNotFound` | Invoice does not exist | +| `DisputeNotFound` | No dispute exists on this invoice | +| `DisputeNotUnderReview` | Dispute is not in `UnderReview` state (includes already-resolved disputes) | +| `InvalidDisputeReason` | Resolution is empty or exceeds 2000 chars | + +--- + +### Query Functions + +#### `get_dispute_details(invoice_id) → Result, Error>` + +Returns the full dispute record, or `None` if no dispute exists. + +#### `get_invoice_dispute_status(invoice_id) → Result` + +Returns the current `DisputeStatus` (including `None`). + +#### `get_invoices_with_disputes() → Vec>` + +Returns all invoice IDs that have any dispute (any status other than `None`). + +#### `get_invoices_by_dispute_status(status) → Vec>` + +Returns invoice IDs filtered by a specific `DisputeStatus`. +Passing `DisputeStatus::None` always returns an empty list. + +--- + +## Security Model + +### Dispute Locking + +The `Resolved` state is **terminal**. The locking mechanism is the state +machine itself: + +``` +resolve_dispute checks: dispute_status == UnderReview + → if Resolved: returns DisputeNotUnderReview ← LOCK + → if Disputed: returns DisputeNotUnderReview ← LOCK + → if None: returns DisputeNotFound + → if UnderReview: proceeds to write resolution +``` + +No additional flag or mutex is needed. The forward-only state machine +guarantees that once `Resolved` is written, no code path can overwrite it +without an explicit policy-override function (which does not currently exist). + +### Authorization + +| Operation | Required Role | +|-----------|--------------| +| `create_dispute` | Business owner or investor on the invoice | +| `put_dispute_under_review` | Platform admin | +| `resolve_dispute` | Platform admin | +| All queries | Anyone (read-only) | + +Every mutating function calls `require_auth()` on the caller before any state +is read or written, preventing replay attacks. + +### Input Validation + +| Field | Min | Max | Error | +|-------|-----|-----|-------| +| `reason` | 1 char | 1000 chars | `InvalidDisputeReason` | +| `evidence` | 1 char | 2000 chars | `InvalidDisputeEvidence` | +| `resolution` | 1 char | 2000 chars | `InvalidDisputeReason` | + +### One-Dispute-Per-Invoice + +`create_dispute` checks `dispute_status == None` before writing. Any status +other than `None` returns `DisputeAlreadyExists`, preventing storage-bloat +attacks and ensuring a clean audit trail. + +### Dual-Check Authorization + +Admin operations perform two independent checks: +1. `admin.require_auth()` — cryptographic signature verification +2. `AdminStorage::require_admin(&env, &admin)` — role verification against + the stored admin address + +Both must pass. This prevents an attacker who knows the admin address from +calling admin functions without the private key. + +--- + +## `dispute.rs` Module + +The `dispute.rs` module provides shared types and helper logic: + +```rust +// Validation helpers +pub fn validate_reason_len(len: u32) -> Result<(), QuickLendXError> +pub fn validate_evidence_len(len: u32) -> Result<(), QuickLendXError> +pub fn validate_resolution_len(len: u32) -> Result<(), QuickLendXError> + +// State-machine helpers +pub fn require_disputed(status: &DisputeStatus) -> Result<(), QuickLendXError> +pub fn require_under_review(status: &DisputeStatus) -> Result<(), QuickLendXError> +pub fn is_locked(status: &DisputeStatus) -> bool +``` + +The `is_locked` predicate can be used by future policy-override logic to gate +any controlled exception path. + +--- + +## Error Reference + +| Error | Code | Condition | +|-------|------|-----------| +| `InvoiceNotFound` | 1000 | Invoice does not exist | +| `InvalidStatus` | 1401 | Invalid state transition (e.g. re-reviewing) | +| `NotAdmin` | 1103 | Caller is not the stored admin | +| `DisputeNotFound` | 1900 | No dispute exists on this invoice | +| `DisputeAlreadyExists` | 1901 | Duplicate dispute creation attempt | +| `DisputeNotAuthorized` | 1902 | Caller is not business or investor | +| `DisputeAlreadyResolved` | 1903 | (reserved for future use) | +| `DisputeNotUnderReview` | 1904 | Dispute is not in `UnderReview` state | +| `InvalidDisputeReason` | 1905 | Reason/resolution validation failed | +| `InvalidDisputeEvidence` | 1906 | Evidence validation failed | + +--- + +## Usage Examples + +```rust +// Business opens a dispute +create_dispute( + env.clone(), + &invoice_id, + &business_address, + String::from_str(&env, "Payment not received after due date"), + String::from_str(&env, "Transaction ID: ABC123, Expected: 2025-01-15"), +)?; + +// Admin puts under review +put_dispute_under_review(env.clone(), &invoice_id, &admin_address)?; + +// Admin resolves (LOCKS the dispute) +resolve_dispute( + env.clone(), + &invoice_id, + &admin_address, + String::from_str(&env, "Verified payment delay. Instructed business to release funds."), +)?; + +// Second resolve attempt — returns DisputeNotUnderReview (locked) +let err = resolve_dispute(env.clone(), &invoice_id, &admin_address, &new_text); +assert_eq!(err, Err(QuickLendXError::DisputeNotUnderReview)); + +// Query +let dispute = get_dispute_details(env.clone(), &invoice_id).unwrap(); +assert_eq!(dispute.unwrap().resolved_by, admin_address); +``` + +--- + +## Test Coverage + +`src/test_dispute.rs` contains 43 test cases (TC-01 through TC-43): + +| Range | Area | +|-------|------| +| TC-01 – TC-10 | Dispute creation, authorization, boundary values | +| TC-11 – TC-14 | `put_dispute_under_review` state machine | +| TC-15 – TC-20 | `resolve_dispute` state machine and validation | +| TC-21 – TC-26 | Query functions | +| TC-27 – TC-29 | Multi-invoice isolation | +| TC-30 – TC-43 | **Regression tests — dispute locking** | + +Key regression tests: +- **TC-30**: Resolved dispute cannot be overwritten (core locking test) +- **TC-31**: Resolved dispute cannot be re-opened via `put_dispute_under_review` +- **TC-32**: `resolved_at` is set exactly once and never zero after resolution +- **TC-33**: Cannot skip the `UnderReview` step +- **TC-34/35**: Non-admin cannot resolve or advance disputes +- **TC-38**: Double-resolution preserves original `resolved_by`/`resolved_at` +- **TC-39**: Invalid invoice ID returns `InvoiceNotFound` for all operations + +--- + +## Deployment Checklist + +- [ ] Initialize contract with admin address via `set_admin` / `initialize` +- [ ] Verify admin authorization works (test non-admin rejection) +- [ ] Confirm dispute creation restricted to business/investor only +- [ ] Test complete state machine: Disputed → UnderReview → Resolved +- [ ] Verify locking: second `resolve_dispute` returns `DisputeNotUnderReview` +- [ ] Validate field length constraints +- [ ] Verify one-dispute-per-invoice enforcement +- [ ] Test query functions return correct results for each status +- [ ] Verify multi-invoice isolation +- [ ] Run `cargo test test_dispute` — all 43 tests must pass + +--- + +## Security Assumptions + +1. The admin private key is kept secure. Compromise of the admin key allows + dispute resolution but not dispute creation (which requires business/investor + auth). +2. The Soroban `require_auth()` mechanism correctly enforces cryptographic + signatures. This is a platform-level assumption. +3. The `AdminStorage::require_admin` check is the sole source of truth for + admin identity. Admin key rotation via `transfer_admin` is atomic. +4. There is no policy-override path today. Any future override must be + implemented as an explicit, separately audited function. +======= +# Dispute Resolution + +## Overview + +Complete dispute lifecycle management for invoice financing disputes. Enables business owners and investors to raise disputes on funded or settled invoices, with admin-controlled review and resolution process. + +Dispute data is embedded within the `Invoice` struct to keep dispute state co-located with the invoice it belongs to. All string fields are bounded by protocol-enforced limits to prevent abusive on-chain storage growth. + +## Dispute Lifecycle + +``` +None → Disputed → UnderReview → Resolved +``` + +1. **None**: No dispute exists (default state) +2. **Disputed**: Dispute created by business or investor +3. **UnderReview**: Admin has acknowledged and is investigating +4. **Resolved**: Admin has provided final resolution + +## Data Structure + +### DisputeStatus + +```rust +pub enum DisputeStatus { + None, // No dispute exists (default) + Disputed, // Dispute has been created + UnderReview, // Admin reviewing + Resolved, // Final state +} +``` + +### Dispute + +| Field | Type | Description | +|-------|------|-------------| +| `created_by` | `Address` | Dispute initiator (business or investor) | +| `created_at` | `u64` | Creation timestamp | +| `reason` | `String` | Dispute reason (1–1000 chars) | +| `evidence` | `String` | Supporting evidence (1–2000 chars) | +| `resolution` | `String` | Admin resolution text (1–2000 chars when set) | +| `resolved_by` | `Address` | Admin who resolved the dispute | +| `resolved_at` | `u64` | Resolution timestamp (0 if unresolved) | + +## Input Validation — Storage Growth Prevention + +All text fields are validated against protocol limits defined in `protocol_limits.rs` to prevent adversarial callers from inflating on-chain storage costs with oversized payloads. + +### Field Length Constraints + +| Field | Minimum | Maximum | Constant | Error Code | +|-------|---------|---------|----------|------------| +| Reason | 1 char | 1000 chars | `MAX_DISPUTE_REASON_LENGTH` | `InvalidDisputeReason` (1905) | +| Evidence | 1 char | 2000 chars | `MAX_DISPUTE_EVIDENCE_LENGTH` | `InvalidDisputeEvidence` (1906) | +| Resolution | 1 char | 2000 chars | `MAX_DISPUTE_RESOLUTION_LENGTH` | `InvalidDisputeReason` (1905) | + +### Validation Functions (`verification.rs`) + +| Function | Validates | Rejects | +|----------|-----------|---------| +| `validate_dispute_reason(reason)` | Non-empty, ≤ 1000 chars | Empty or oversized reason | +| `validate_dispute_evidence(evidence)` | Non-empty, ≤ 2000 chars | Empty or oversized evidence | +| `validate_dispute_resolution(resolution)` | Non-empty, ≤ 2000 chars | Empty or oversized resolution | +| `validate_dispute_eligibility(invoice, creator)` | Invoice status, authorization, no duplicate | Ineligible invoices | + +### Security Assumptions + +- **No empty payloads**: Empty reason or evidence is rejected to prevent frivolous disputes. +- **Bounded storage**: Maximum total dispute payload per invoice ≤ 5000 chars (reason + evidence + resolution). +- **One dispute per invoice**: Prevents spam by allowing only a single dispute per invoice. +- **Immutable once created**: Dispute creator and creation timestamp cannot be modified after creation. + +## Contract Interface + +### User Functions + +#### `create_dispute(invoice_id: BytesN<32>, creator: Address, reason: String, evidence: String) -> Result<(), QuickLendXError>` + +Creates a new dispute for an invoice. + +**Preconditions:** +- `creator.require_auth()` passes +- Invoice exists and is in Pending, Verified, Funded, or Paid status +- Creator is either business owner or investor on the invoice +- No existing dispute for this invoice (`dispute_status == None`) +- Reason: 1–1000 characters (non-empty, bounded) +- Evidence: 1–2000 characters (non-empty, bounded) + +**Errors:** +- `InvoiceNotFound`: Invoice does not exist +- `InvoiceNotAvailableForFunding`: Invoice not in valid state for disputes +- `DisputeNotAuthorized`: Creator is not business or investor +- `DisputeAlreadyExists`: Dispute already exists for this invoice +- `InvalidDisputeReason` (1905): Reason empty or exceeds 1000 chars +- `InvalidDisputeEvidence` (1906): Evidence empty or exceeds 2000 chars + +### Admin Functions + +#### `put_dispute_under_review(invoice_id: BytesN<32>, admin: Address) -> Result<(), QuickLendXError>` + +Moves dispute from Disputed to UnderReview status. + +**Preconditions:** +- Caller must be admin +- Invoice exists +- Dispute status must be Disputed + +**Errors:** +- `Unauthorized`: Caller not admin +- `NotAdmin`: Admin not configured +- `InvoiceNotFound`: Invoice does not exist +- `DisputeNotFound`: No dispute exists (status is not Disputed) + +#### `resolve_dispute(invoice_id: BytesN<32>, admin: Address, resolution: String) -> Result<(), QuickLendXError>` + +Finalizes dispute with resolution text. + +**Preconditions:** +- Caller must be admin +- Dispute must be in UnderReview status +- Resolution: 1–2000 characters (non-empty, bounded) + +**Errors:** +- `Unauthorized`: Caller not admin +- `NotAdmin`: Admin not configured +- `InvoiceNotFound`: Invoice does not exist +- `DisputeNotUnderReview`: Dispute not in UnderReview status +- `InvalidDisputeReason` (1905): Resolution empty or exceeds 2000 chars + +### Query Functions + +#### `get_dispute_details(invoice_id: BytesN<32>) -> Option` + +Returns dispute details if a dispute exists, `None` otherwise. + +#### `get_invoice_dispute_status(invoice_id: BytesN<32>) -> DisputeStatus` + +Returns the current dispute status for an invoice. + +#### `get_invoices_with_disputes() -> Vec>` + +Returns all invoice IDs that have an active or resolved dispute (status != None). + +#### `get_invoices_by_dispute_status(status: DisputeStatus) -> Vec>` + +Returns invoice IDs filtered by the given dispute status. + +## Integration + +### Invoice State Requirements + +Disputes can only be created for invoices in specific states: + +| Invoice Status | Can Create Dispute | +|----------------|-------------------| +| Pending | Yes | +| Verified | Yes | +| Funded | Yes | +| Paid | Yes | +| Defaulted | No | +| Cancelled | No | + +### Authorization Model + +**Create Dispute:** +- Business owner of the invoice +- Investor who funded the invoice + +**Review/Resolve:** +- Platform admin only + +### Usage Example + +```rust +// Business creates dispute +client.create_dispute( + &invoice_id, + &business_address, + &String::from_str(&env, "Payment not received after due date"), + &String::from_str(&env, "Transaction ID: ABC123, Expected: 2025-01-15"), +); + +// Admin puts under review +client.put_dispute_under_review(&invoice_id, &admin_address); + +// Admin resolves +client.resolve_dispute( + &invoice_id, + &admin_address, + &String::from_str(&env, "Verified payment delay. Instructed business to release funds."), +); + +// Query dispute +let dispute = client.get_dispute_details(&invoice_id); +assert!(dispute.is_some()); +``` + +## State Transition Rules + +| Current Status | Allowed Transition | Required Role | +|----------------|-------------------|---------------| +| None | Disputed | Business / Investor | +| Disputed | UnderReview | Admin | +| UnderReview | Resolved | Admin | +| Resolved | None (terminal) | - | + +## Error Handling + +All operations return `Result`: + +| Error | Code | Symbol | Condition | +|-------|------|--------|-----------| +| `DisputeNotFound` | 1900 | `DSP_NF` | Dispute does not exist | +| `DisputeAlreadyExists` | 1901 | `DSP_EX` | Duplicate dispute creation | +| `DisputeNotAuthorized` | 1902 | `DSP_NA` | Unauthorized creator | +| `DisputeAlreadyResolved` | 1903 | `DSP_RS` | Dispute already finalized | +| `DisputeNotUnderReview` | 1904 | `DSP_UR` | Invalid status for resolution | +| `InvalidDisputeReason` | 1905 | `DSP_RN` | Reason/resolution validation failed | +| `InvalidDisputeEvidence` | 1906 | `DSP_EV` | Evidence validation failed | + +## Test Coverage + +Test suites: `test_dispute.rs`, `test_string_limits.rs`, and `test.rs`. + +### Covered Scenarios + +1. **Dispute Creation** (8 tests): + - Business can create dispute + - Unauthorized parties rejected + - Duplicate disputes rejected + - Reason validation: empty, too long, boundary (1 char, 1000 chars) + - Evidence validation: empty, too long + - Nonexistent invoice rejected + +2. **Status Transitions** (6 tests): + - Disputed → UnderReview (admin only) + - UnderReview → Resolved (admin only) + - Invalid transitions rejected + - Cannot re-review resolved disputes + - Cannot resolve non-reviewed disputes + +3. **Resolution Validation** (2 tests): + - Empty resolution rejected + - Oversized resolution rejected + +4. **Query Functions** (7 tests): + - get_dispute_details returns correct data + - get_invoices_with_disputes lists all disputed invoices + - get_invoices_by_dispute_status filters by status (None, Disputed, UnderReview, Resolved) + - Status lists update correctly during transitions + - Multiple disputes on different invoices + +5. **String Limits** (1 test in test_string_limits.rs): + - Dispute reason and evidence at exact boundary + +**Estimated Coverage: 95%+** + +## Deployment Checklist + +- [ ] Initialize contract with admin address +- [ ] Verify admin authorization works correctly +- [ ] Confirm dispute creation restricted to eligible invoice states +- [ ] Test state transitions (None → Disputed → UnderReview → Resolved) +- [ ] Validate field length constraints (reason ≤ 1000, evidence ≤ 2000, resolution ≤ 2000) +- [ ] Verify empty payloads are rejected +- [ ] Verify only invoice participants can create disputes +- [ ] Test query functions (get_dispute_details, get_invoices_with_disputes, get_invoices_by_dispute_status) +- [ ] Document admin dispute resolution procedures +- [ ] Set up monitoring for open disputes +>>>>>>> main diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index f2269fab..bc8966bb 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -1,3 +1,2941 @@ +<<<<<<< feature/dispute-resolution-finality +#![cfg_attr(target_family = "wasm", no_std)] +#[cfg(target_family = "wasm")] +extern crate alloc; + +#[cfg(test)] +mod scratch_events; +#[cfg(test)] +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; +mod analytics; +mod audit; +mod backup; +mod bid; +mod currency; +mod defaults; +mod dispute; +mod emergency; +mod errors; +mod escrow; +mod events; +mod fees; +mod init; +mod investment; +mod investment_queries; +mod invoice; +mod notifications; +mod pause; +mod payments; +mod profits; +mod protocol_limits; +mod reentrancy; +mod settlement; +mod storage; +#[cfg(test)] +#[cfg(test)] +mod test_admin; +#[cfg(test)] +mod test_admin_simple; +#[cfg(test)] +mod test_admin_standalone; +#[cfg(test)] +mod test_dispute; +#[cfg(test)] +mod test_init; +#[cfg(test)] +mod test_investment_consistency; +#[cfg(test)] +mod test_investment_queries; +#[cfg(test)] +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; +mod vesting; +use admin::AdminStorage; +use bid::{Bid, BidStorage}; +use defaults::{ + handle_default as do_handle_default, mark_invoice_defaulted as do_mark_invoice_defaulted, + OverdueScanResult, +}; +use errors::QuickLendXError; +use escrow::{ + accept_bid_and_fund as do_accept_bid_and_fund, refund_escrow_funds as do_refund_escrow_funds, +}; +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, +}; +use investment::{InsuranceCoverage, Investment, InvestmentStatus, InvestmentStorage}; +use invoice::{Invoice, InvoiceMetadata, InvoiceStorage}; +use payments::{create_escrow, release_escrow, EscrowStorage}; +use profits::{calculate_profit as do_calculate_profit, PlatformFee, PlatformFeeConfig}; +use settlement::{ + process_partial_payment as do_process_partial_payment, settle_invoice as do_settle_invoice, +}; +use verification::{ + calculate_investment_limit, calculate_investor_risk_score, determine_investor_tier, + get_investor_verification as do_get_investor_verification, normalize_tag, reject_business, + reject_investor as do_reject_investor, require_business_not_pending, + require_investor_not_pending, submit_investor_kyc as do_submit_investor_kyc, + submit_kyc_application, validate_bid, validate_investor_investment, validate_invoice_metadata, + verify_business, verify_investor as do_verify_investor, verify_invoice_data, + BusinessVerificationStatus, BusinessVerificationStorage, InvestorRiskLevel, InvestorTier, + InvestorVerification, InvestorVerificationStorage, +}; + +pub use crate::types::*; + +#[contract] +pub struct QuickLendXContract; + +/// Maximum number of records returned by paginated query endpoints. +pub(crate) const MAX_QUERY_LIMIT: u32 = 100; + +/// @notice Validates and caps query limit to prevent resource abuse +/// @param limit The requested limit value +/// @return The capped limit value, never exceeding MAX_QUERY_LIMIT +/// @dev Returns 0 if limit is 0, enforcing empty result behavior +#[inline] +fn cap_query_limit(limit: u32) -> u32 { + investment_queries::InvestmentQueries::cap_query_limit(limit) +} + +/// @notice Validates query parameters for security and resource protection +/// @param offset The pagination offset +/// @param limit The requested result limit +/// @return Result indicating validation success or failure +/// @dev Prevents potential overflow and ensures reasonable query bounds +fn validate_query_params(offset: u32, limit: u32) -> Result<(), QuickLendXError> { + // Check for potential overflow in offset + limit calculation + if offset > u32::MAX - MAX_QUERY_LIMIT { + return Err(QuickLendXError::InvalidAmount); + } + + // Limit is automatically capped by cap_query_limit, but we validate the input + // Note: limit=0 is allowed and results in empty response + Ok(()) +} + +/// Map the contract-exported `types::BidStatus` filter to the bid-storage enum. +fn map_public_bid_status(s: BidStatus) -> bid::BidStatus { + match s { + BidStatus::Placed => bid::BidStatus::Placed, + BidStatus::Withdrawn => bid::BidStatus::Withdrawn, + BidStatus::Accepted => bid::BidStatus::Accepted, + BidStatus::Expired => bid::BidStatus::Expired, + } +} + +#[contractimpl] +impl QuickLendXContract { + // ============================================================================ + // Admin Management Functions + // ============================================================================ + + /// Initialize the protocol with all required configuration (one-time setup) + pub fn initialize(env: Env, params: init::InitializationParams) -> Result<(), QuickLendXError> { + init::ProtocolInitializer::initialize(&env, ¶ms) + } + + /// Check if the protocol has been initialized + pub fn is_initialized(env: Env) -> bool { + init::ProtocolInitializer::is_initialized(&env) + } + + /// Get the protocol/contract version + /// + /// Returns the version written during initialization, or the current + /// PROTOCOL_VERSION constant if the contract has not been initialized yet. + /// + /// # Returns + /// * `u32` - The protocol version number + /// + /// # Version Format + /// Version is a simple integer increment (e.g., 1, 2, 3...) + /// Major versions indicate breaking changes that require migration. + pub fn get_version(_env: Env) -> u32 { + 1u32 + } + + /// Get current protocol limits + pub fn get_protocol_limits(env: Env) -> protocol_limits::ProtocolLimits { + protocol_limits::ProtocolLimitsContract::get_protocol_limits(env) + } + + /// Initialize the admin address (deprecated: use initialize) + pub fn initialize_admin(env: Env, admin: Address) -> Result<(), QuickLendXError> { + AdminStorage::initialize(&env, &admin) + } + + /// Transfer admin role to a new address + /// + /// # Arguments + /// * `env` - The contract environment + /// * `new_admin` - The new admin address + /// + /// # Returns + /// * `Ok(())` if transfer succeeds + /// * `Err(QuickLendXError::NotAdmin)` if caller is not current admin + /// + /// # Security + /// - Requires authorization from current admin + pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), QuickLendXError> { + let current_admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + AdminStorage::transfer_admin(&env, ¤t_admin, &new_admin) + } + + /// Get the current admin address + /// + /// # Returns + /// * `Some(Address)` if admin is set + /// * `None` if admin has not been initialized + pub fn get_current_admin(env: Env) -> Option
{ + AdminStorage::get_admin(&env) + } + + /// Set protocol configuration (admin only) + pub fn set_protocol_config( + env: Env, + admin: Address, + min_invoice_amount: i128, + max_due_date_days: u64, + grace_period_seconds: u64, + ) -> Result<(), QuickLendXError> { + init::ProtocolInitializer::set_protocol_config( + &env, + &admin, + min_invoice_amount, + max_due_date_days, + grace_period_seconds, + ) + } + + /// Set fee configuration (admin only) + pub fn set_fee_config(env: Env, admin: Address, fee_bps: u32) -> Result<(), QuickLendXError> { + init::ProtocolInitializer::set_fee_config(&env, &admin, fee_bps) + } + + /// Set treasury address (admin only) + pub fn set_treasury( + env: Env, + admin: Address, + treasury: Address, + ) -> Result<(), QuickLendXError> { + init::ProtocolInitializer::set_treasury(&env, &admin, &treasury) + } + + /// Get current fee in basis points + pub fn get_fee_bps(env: Env) -> u32 { + init::ProtocolInitializer::get_fee_bps(&env) + } + + /// Get treasury address + pub fn get_treasury(env: Env) -> Option
{ + init::ProtocolInitializer::get_treasury(&env) + } + + /// Get minimum invoice amount + pub fn get_min_invoice_amount(env: Env) -> i128 { + init::ProtocolInitializer::get_min_invoice_amount(&env) + } + + /// Get maximum due date days + pub fn get_max_due_date_days(env: Env) -> u64 { + init::ProtocolInitializer::get_max_due_date_days(&env) + } + + /// Get grace period in seconds + pub fn get_grace_period_seconds(env: Env) -> u64 { + init::ProtocolInitializer::get_grace_period_seconds(&env) + } + + /// Admin-only: configure default bid TTL (days). Bounds: 1..=30. + pub fn set_bid_ttl_days(env: Env, days: u64) -> Result { + let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + bid::BidStorage::set_bid_ttl_days(&env, &admin, days) + } + + /// Get configured bid TTL in days (returns default 7 if not set) + pub fn get_bid_ttl_days(env: Env) -> u64 { + bid::BidStorage::get_bid_ttl_days(&env) + } + + /// Get current bid TTL configuration snapshot + pub fn get_bid_ttl_config(env: Env) -> bid::BidTtlConfig { + bid::BidStorage::get_bid_ttl_config(&env) + } + + /// Reset bid TTL to the compile-time default + pub fn reset_bid_ttl_to_default(env: Env) -> Result { + let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + bid::BidStorage::reset_bid_ttl_to_default(&env, &admin) + } + + /// Get maximum active bids allowed per investor + pub fn get_max_active_bids_per_investor(env: Env) -> u32 { + bid::BidStorage::get_max_active_bids_per_investor(&env) + } + + /// Set maximum active bids allowed per investor (admin only) + pub fn set_max_active_bids_per_investor(env: Env, limit: u32) -> Result { + let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + bid::BidStorage::set_max_active_bids_per_investor(&env, &admin, limit) + } + + /// Initiate emergency withdraw for stuck funds (admin only). Timelock applies before execute. + /// See docs/contracts/emergency-recovery.md. Last-resort only. + pub fn initiate_emergency_withdraw( + env: Env, + admin: Address, + token: Address, + amount: i128, + target_address: Address, + ) -> Result<(), QuickLendXError> { + emergency::EmergencyWithdraw::initiate(&env, &admin, token, amount, target_address) + } + + /// Execute emergency withdraw after timelock has elapsed (admin only). + pub fn execute_emergency_withdraw(env: Env, admin: Address) -> Result<(), QuickLendXError> { + emergency::EmergencyWithdraw::execute(&env, &admin) + } + + /// Get pending emergency withdrawal if any. + pub fn get_pending_emergency_withdraw( + env: Env, + ) -> Option { + emergency::EmergencyWithdraw::get_pending(&env) + } + + /// Check if the pending emergency withdrawal can be executed. + /// + /// Returns true if the withdrawal exists, is not cancelled, timelock has elapsed, + /// and has not expired. + pub fn can_exec_emergency(env: Env) -> bool { + emergency::EmergencyWithdraw::can_execute(&env).unwrap_or(false) + } + + /// Get time remaining until the emergency withdrawal can be executed. + /// + /// Returns seconds until unlock (0 if already unlocked). + pub fn emg_time_until_unlock(env: Env) -> u64 { + emergency::EmergencyWithdraw::time_until_unlock(&env).unwrap_or(0) + } + + /// Get time remaining until the emergency withdrawal expires. + /// + /// Returns seconds until expiration (0 if already expired). + pub fn emg_time_until_expire(env: Env) -> u64 { + emergency::EmergencyWithdraw::time_until_expiration(&env).unwrap_or(0) + } + + /// Add a token address to the currency whitelist (admin only). + pub fn add_currency( + env: Env, + admin: Address, + currency: Address, + ) -> Result<(), QuickLendXError> { + currency::CurrencyWhitelist::add_currency(&env, &admin, ¤cy) + } + + /// Remove a token address from the currency whitelist (admin only). + pub fn remove_currency( + env: Env, + admin: Address, + currency: Address, + ) -> Result<(), QuickLendXError> { + currency::CurrencyWhitelist::remove_currency(&env, &admin, ¤cy) + } + + /// Check if a token is allowed for invoice currency. + pub fn is_allowed_currency(env: Env, currency: Address) -> bool { + currency::CurrencyWhitelist::is_allowed_currency(&env, ¤cy) + } + + /// Get all whitelisted token addresses. + pub fn get_whitelisted_currencies(env: Env) -> Vec
{ + currency::CurrencyWhitelist::get_whitelisted_currencies(&env) + } + + /// Replace the entire currency whitelist atomically (admin only). + pub fn set_currencies( + env: Env, + admin: Address, + currencies: Vec
, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + currency::CurrencyWhitelist::set_currencies(&env, &admin, ¤cies) + } + + /// Clear the entire currency whitelist (admin only). + /// After this call all currencies are allowed (empty-list backward-compat rule). + pub fn clear_currencies(env: Env, admin: Address) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + currency::CurrencyWhitelist::clear_currencies(&env, &admin) + } + + /// Return the number of whitelisted currencies. + pub fn currency_count(env: Env) -> u32 { + currency::CurrencyWhitelist::currency_count(&env) + } + + /// Return a paginated slice of the whitelist. + pub fn get_whitelisted_currencies_paged(env: Env, offset: u32, limit: u32) -> Vec
{ + currency::CurrencyWhitelist::get_whitelisted_currencies_paged(&env, offset, limit) + } + + /// Cancel a pending emergency withdrawal (admin only). + pub fn cancel_emergency_withdraw(env: Env, admin: Address) -> Result<(), QuickLendXError> { + emergency::EmergencyWithdraw::cancel(&env, &admin) + } + + /// Pause the contract (admin only). When paused, mutating operations fail with ContractPaused; getters succeed. + pub fn pause(env: Env, admin: Address) -> Result<(), QuickLendXError> { + pause::PauseControl::set_paused(&env, &admin, true) + } + + /// Unpause the contract (admin only). + pub fn unpause(env: Env, admin: Address) -> Result<(), QuickLendXError> { + pause::PauseControl::set_paused(&env, &admin, false) + } + + /// Return whether the contract is currently paused. + pub fn is_paused(env: Env) -> bool { + pause::PauseControl::is_paused(&env) + } + + // ============================================================================ + // Invoice Management Functions + // ============================================================================ + + /// Store an invoice in the contract (unauthenticated; use `upload_invoice` for business flow). + /// + /// # Arguments + /// * `business` - Address of the business that owns the invoice + /// * `amount` - Invoice amount in smallest currency unit (e.g. cents) + /// * `currency` - Token contract address for the invoice currency + /// * `due_date` - Unix timestamp when the invoice is due + /// * `description` - Human-readable description + /// * `category` - Invoice category (e.g. Services, Goods) + /// * `tags` - Optional tags for filtering + /// + /// # Returns + /// * `Ok(BytesN<32>)` - The new invoice ID + /// + /// # Errors + /// * `InvalidAmount` if amount <= 0 + /// * `InvoiceDueDateInvalid` if due_date is not in the future + /// * `InvalidDescription` if description is empty + pub fn store_invoice( + env: Env, + business: Address, + amount: i128, + currency: Address, + due_date: u64, + description: String, + category: invoice::InvoiceCategory, + tags: Vec, + ) -> Result, QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + // Validate input parameters + if amount <= 0 { + return Err(QuickLendXError::InvalidAmount); + } + + let current_timestamp = env.ledger().timestamp(); + if due_date <= current_timestamp { + return Err(QuickLendXError::InvoiceDueDateInvalid); + } + + // Validate amount and due date using protocol limits + // Validate due date is not too far in the future using protocol limits + protocol_limits::ProtocolLimitsContract::validate_invoice(env.clone(), amount, due_date)?; + + if description.len() == 0 { + return Err(QuickLendXError::InvalidDescription); + } + + currency::CurrencyWhitelist::require_allowed_currency(&env, ¤cy)?; + + // Check if business is verified (temporarily disabled for debugging) + // if !verification::BusinessVerificationStorage::is_business_verified(&env, &business) { + // return Err(QuickLendXError::BusinessNotVerified); + // } + + // Validate category and tags + verification::validate_invoice_category(&category)?; + verification::validate_invoice_tags(&env, &tags)?; + + // Create new invoice + let invoice = Invoice::new( + &env, + business.clone(), + amount, + currency.clone(), + due_date, + description, + category, + tags, + )?; + + // Store the invoice + InvoiceStorage::store_invoice(&env, &invoice); + + // Emit event + env.events().publish( + (symbol_short!("created"),), + (invoice.id.clone(), business, amount, currency, due_date), + ); + + Ok(invoice.id) + } + + /// Upload an invoice (business only) + pub fn upload_invoice( + env: Env, + business: Address, + amount: i128, + currency: Address, + due_date: u64, + description: String, + category: invoice::InvoiceCategory, + tags: Vec, + ) -> Result, QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + // Only the business can upload their own invoice + business.require_auth(); + + // Enforce KYC: reject pending and unverified/rejected businesses with distinct errors. + // Pending businesses get KYCAlreadyPending; unverified/rejected get BusinessNotVerified. + require_business_not_pending(&env, &business)?; + + // Basic validation + verify_invoice_data(&env, &business, amount, ¤cy, due_date, &description)?; + currency::CurrencyWhitelist::require_allowed_currency(&env, ¤cy)?; + + // Validate category and tags + verification::validate_invoice_category(&category)?; + verification::validate_invoice_tags(&env, &tags)?; + + // Check max invoices per business limit + let limits = protocol_limits::ProtocolLimitsContract::get_protocol_limits(env.clone()); + if limits.max_invoices_per_business > 0 { + let active_count = InvoiceStorage::count_active_business_invoices(&env, &business); + if active_count >= limits.max_invoices_per_business { + return Err(QuickLendXError::MaxInvoicesPerBusinessExceeded); + } + } + + // Create and store invoice + let invoice = Invoice::new( + &env, + business.clone(), + amount, + currency.clone(), + due_date, + description.clone(), + category, + tags, + )?; + InvoiceStorage::store_invoice(&env, &invoice); + emit_invoice_uploaded(&env, &invoice); + + Ok(invoice.id) + } + + /// Accept a bid and fund the invoice using escrow (transfer in from investor). + /// + /// Business must be authorized. Invoice must be Verified and bid Placed. + /// Protected by reentrancy guard (see docs/contracts/security.md). + /// + /// # Returns + /// * `Ok(BytesN<32>)` - The new escrow ID + /// + /// # Errors + /// * `InvoiceNotFound`, `StorageKeyNotFound`, `InvalidStatus`, `InvoiceAlreadyFunded`, `InvoiceNotAvailableForFunding`, `Unauthorized` + /// * `OperationNotAllowed` if reentrancy is detected + pub fn accept_bid_and_fund( + env: Env, + invoice_id: BytesN<32>, + bid_id: BytesN<32>, + ) -> Result, QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + reentrancy::with_payment_guard(&env, || do_accept_bid_and_fund(&env, &invoice_id, &bid_id)) + } + + /// Verify an invoice (admin or automated process) + pub fn verify_invoice(env: Env, invoice_id: BytesN<32>) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + admin.require_auth(); + + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + // When invoice is already funded, verify_invoice triggers release_escrow_funds (Issue #300) + if invoice.status == InvoiceStatus::Funded { + return Self::release_escrow_funds(env, invoice_id); + } + + // Only allow verification if pending + if invoice.status != InvoiceStatus::Pending { + return Err(QuickLendXError::InvalidStatus); + } + + // Remove from pending status list + // Remove from old status list (Pending) + InvoiceStorage::remove_from_status_invoices(&env, &InvoiceStatus::Pending, &invoice_id); + + invoice.verify(&env, admin.clone()); + InvoiceStorage::update_invoice(&env, &invoice); + + // Add to verified status list + // Add to new status list (Verified) + InvoiceStorage::add_to_status_invoices(&env, &InvoiceStatus::Verified, &invoice_id); + + emit_invoice_verified(&env, &invoice); + + // If invoice is funded (has escrow), release escrow funds to business + if invoice.status == InvoiceStatus::Funded { + Self::release_escrow_funds(env.clone(), invoice_id)?; + } + + Ok(()) + } + + /// Cancel an invoice (business only, before funding) + pub fn cancel_invoice(env: Env, invoice_id: BytesN<32>) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + // Only the business owner can cancel their own invoice + invoice.business.require_auth(); + + // Enforce KYC: a pending business must not cancel invoices. + require_business_not_pending(&env, &invoice.business)?; + + // Remove from old status list + InvoiceStorage::remove_from_status_invoices(&env, &invoice.status, &invoice_id); + + // Cancel the invoice (only works if Pending or Verified) + invoice.cancel(&env, invoice.business.clone())?; + + // Update storage + InvoiceStorage::update_invoice(&env, &invoice); + + // Add to cancelled status list + InvoiceStorage::add_to_status_invoices(&env, &InvoiceStatus::Cancelled, &invoice_id); + + // Emit event + emit_invoice_cancelled(&env, &invoice); + + Ok(()) + } + + /// Get an invoice by ID. + /// + /// # Returns + /// * `Ok(Invoice)` - The invoice data + /// * `Err(InvoiceNotFound)` if the ID does not exist + pub fn get_invoice(env: Env, invoice_id: BytesN<32>) -> Result { + InvoiceStorage::get_invoice(&env, &invoice_id).ok_or(QuickLendXError::InvoiceNotFound) + } + + /// Get all invoices for a business + pub fn get_invoice_by_business(env: Env, business: Address) -> Vec> { + InvoiceStorage::get_business_invoices(&env, &business) + } + + /// Get all invoices for a specific business + pub fn get_business_invoices(env: Env, business: Address) -> Vec> { + InvoiceStorage::get_business_invoices(&env, &business) + } + + /// Update structured metadata for an invoice + pub fn update_invoice_metadata( + env: Env, + invoice_id: BytesN<32>, + metadata: InvoiceMetadata, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + invoice.business.require_auth(); + validate_invoice_metadata(&metadata, invoice.amount)?; + + if let Some(existing) = invoice.metadata() { + InvoiceStorage::remove_metadata_indexes(&env, &existing, &invoice.id); + } + + invoice.set_metadata(&env, Some(metadata.clone()))?; + InvoiceStorage::update_invoice(&env, &invoice); + InvoiceStorage::add_metadata_indexes(&env, &invoice); + + emit_invoice_metadata_updated(&env, &invoice, &metadata); + Ok(()) + } + + /// Clear metadata attached to an invoice + pub fn clear_invoice_metadata(env: Env, invoice_id: BytesN<32>) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + invoice.business.require_auth(); + + if let Some(existing) = invoice.metadata() { + InvoiceStorage::remove_metadata_indexes(&env, &existing, &invoice.id); + invoice.set_metadata(&env, None)?; + InvoiceStorage::update_invoice(&env, &invoice); + emit_invoice_metadata_cleared(&env, &invoice); + } + + Ok(()) + } + + /// Get invoices indexed by customer name + pub fn get_invoices_by_customer(env: Env, customer_name: String) -> Vec> { + InvoiceStorage::get_invoices_by_customer(&env, &customer_name) + } + + /// Get invoices indexed by tax id + pub fn get_invoices_by_tax_id(env: Env, tax_id: String) -> Vec> { + InvoiceStorage::get_invoices_by_tax_id(&env, &tax_id) + } + + /// Get all invoices by status + pub fn get_invoices_by_status(env: Env, status: InvoiceStatus) -> Vec> { + InvoiceStorage::get_invoices_by_status(&env, &status) + } + + /// Get all available invoices (verified and not funded) + pub fn get_available_invoices(env: Env) -> Vec> { + InvoiceStorage::get_invoices_by_status(&env, &InvoiceStatus::Verified) + } + + /// Update invoice status (admin function) + pub fn update_invoice_status( + env: Env, + invoice_id: BytesN<32>, + new_status: InvoiceStatus, + ) -> Result<(), QuickLendXError> { + 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), + } + + // Store updated invoice + InvoiceStorage::update_invoice(&env, &invoice); + + // 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 + } + _ => {} + } + + Ok(()) + } + + /// Get invoice count by status + pub fn get_invoice_count_by_status(env: Env, status: InvoiceStatus) -> u32 { + let invoices = InvoiceStorage::get_invoices_by_status(&env, &status); + invoices.len() as u32 + } + + /// Get total invoice count + pub fn get_total_invoice_count(env: Env) -> u32 { + let pending = Self::get_invoice_count_by_status(env.clone(), InvoiceStatus::Pending); + let verified = Self::get_invoice_count_by_status(env.clone(), InvoiceStatus::Verified); + let funded = Self::get_invoice_count_by_status(env.clone(), InvoiceStatus::Funded); + let paid = Self::get_invoice_count_by_status(env.clone(), InvoiceStatus::Paid); + let defaulted = Self::get_invoice_count_by_status(env.clone(), InvoiceStatus::Defaulted); + let cancelled = Self::get_invoice_count_by_status(env.clone(), InvoiceStatus::Cancelled); + let refunded = Self::get_invoice_count_by_status(env.clone(), InvoiceStatus::Refunded); + + pending + .saturating_add(verified) + .saturating_add(funded) + .saturating_add(paid) + .saturating_add(defaulted) + .saturating_add(cancelled) + .saturating_add(refunded) + } + + /// Clear all invoices from storage (admin only, used for restore operations) + pub fn clear_all_invoices(env: Env) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + use crate::invoice::InvoiceStorage; + InvoiceStorage::clear_all(&env); + Ok(()) + } + + /// Get a bid by ID + pub fn get_bid(env: Env, bid_id: BytesN<32>) -> Option { + BidStorage::get_bid(&env, &bid_id) + } + + /// Get the highest ranked bid for an invoice + pub fn get_best_bid(env: Env, invoice_id: BytesN<32>) -> Option { + BidStorage::get_best_bid(&env, &invoice_id) + } + + /// Get all bids for an invoice sorted using the platform ranking rules + pub fn get_ranked_bids(env: Env, invoice_id: BytesN<32>) -> Vec { + BidStorage::rank_bids(&env, &invoice_id) + } + + /// Get bids filtered by status + pub fn get_bids_by_status(env: Env, invoice_id: BytesN<32>, status: BidStatus) -> Vec { + BidStorage::get_bids_by_status(&env, &invoice_id, map_public_bid_status(status)) + } + + /// Get bids filtered by investor + pub fn get_bids_by_investor(env: Env, invoice_id: BytesN<32>, investor: Address) -> Vec { + BidStorage::get_bids_by_investor(&env, &invoice_id, &investor) + } + + /// Get all bids for an invoice + /// Returns a list of all bid records (including expired, withdrawn, etc.) + /// Use get_bids_by_status to filter by status if needed + pub fn get_bids_for_invoice(env: Env, invoice_id: BytesN<32>) -> Vec { + BidStorage::get_bid_records_for_invoice(&env, &invoice_id) + } + + /// Remove bids that have passed their expiration window + pub fn cleanup_expired_bids(env: Env, invoice_id: BytesN<32>) -> u32 { + BidStorage::cleanup_expired_bids(&env, &invoice_id) + } + + /// Cancel a placed bid (investor only, Placed → Cancelled). + /// + /// # Race Safety + /// Uses a read-check-write pattern that validates the bid is still in `Placed` + /// status before transitioning. Terminal statuses (`Withdrawn`, `Accepted`, + /// `Expired`, `Cancelled`) are immutable — a bid that has already left `Placed` + /// will cause this function to return `false` without any state mutation, + /// preventing double-action execution regardless of call ordering. + pub fn cancel_bid(env: Env, bid_id: BytesN<32>) -> bool { + pause::PauseControl::require_not_paused(&env).is_ok() + && bid::BidStorage::cancel_bid(&env, &bid_id) + } + + /// Withdraw a bid (investor only, Placed → Withdrawn). + /// + /// # Race Safety + /// Validates `BidStatus::Placed` atomically before transitioning. If a + /// concurrent `cancel_bid` or expiry has already moved the bid to a terminal + /// 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)?; + let mut bid = + BidStorage::get_bid(&env, &bid_id).ok_or(QuickLendXError::StorageKeyNotFound)?; + bid.investor.require_auth(); + require_investor_not_pending(&env, &bid.investor)?; + // Re-read status after auth to guard against concurrent transitions. + let bid_fresh = + BidStorage::get_bid(&env, &bid_id).ok_or(QuickLendXError::StorageKeyNotFound)?; + if bid_fresh.status != bid::BidStatus::Placed { + return Err(QuickLendXError::OperationNotAllowed); + } + bid.status = bid::BidStatus::Withdrawn; + BidStorage::update_bid(&env, &bid); + emit_bid_withdrawn(&env, &bid); + Ok(()) + } + + /// Get all bids placed by an investor across all invoices. + pub fn get_all_bids_by_investor(env: Env, investor: Address) -> Vec { + bid::BidStorage::get_all_bids_by_investor(&env, &investor) + } + + /// Place a bid on an invoice + /// + /// Validates: + /// - Invoice exists and is verified + /// - Bid amount is positive + /// - Investor is authorized and verified + /// - Creates and stores the bid + pub fn place_bid( + env: Env, + investor: Address, + invoice_id: BytesN<32>, + bid_amount: i128, + expected_return: i128, + ) -> Result, QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + // Authorization check: Only the investor can place their own bid + investor.require_auth(); + + // Validate bid amount is positive + if bid_amount <= 0 { + return Err(QuickLendXError::InvalidAmount); + } + + // Validate invoice exists and is verified + let invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + if invoice.status != InvoiceStatus::Verified { + return Err(QuickLendXError::InvalidStatus); + } + currency::CurrencyWhitelist::require_allowed_currency(&env, &invoice.currency)?; + + let verification = do_get_investor_verification(&env, &investor) + .ok_or(QuickLendXError::BusinessNotVerified)?; + match verification.status { + BusinessVerificationStatus::Verified => { + if bid_amount > verification.investment_limit { + return Err(QuickLendXError::InvalidAmount); + } + } + BusinessVerificationStatus::Pending => return Err(QuickLendXError::KYCAlreadyPending), + BusinessVerificationStatus::Rejected => { + return Err(QuickLendXError::BusinessNotVerified) + } + } + + BidStorage::cleanup_expired_bids(&env, &invoice_id); + // Check if maximum bids per invoice limit is reached + let active_bid_count = BidStorage::get_active_bid_count(&env, &invoice_id); + if active_bid_count >= bid::MAX_BIDS_PER_INVOICE { + return Err(QuickLendXError::MaxBidsPerInvoiceExceeded); + } + + let max_active_bids = BidStorage::get_max_active_bids_per_investor(&env); + if max_active_bids > 0 { + let active_bids = BidStorage::count_active_placed_bids_for_investor(&env, &investor); + if active_bids >= max_active_bids { + return Err(QuickLendXError::OperationNotAllowed); + } + } + validate_bid(&env, &invoice, bid_amount, expected_return, &investor)?; + // Create bid + let bid_id = BidStorage::generate_unique_bid_id(&env); + let current_timestamp = env.ledger().timestamp(); + let bid = Bid { + bid_id: bid_id.clone(), + invoice_id: invoice_id.clone(), + investor: investor.clone(), + bid_amount, + expected_return, + timestamp: current_timestamp, + status: bid::BidStatus::Placed, + expiration_timestamp: Bid::default_expiration_with_env(&env, current_timestamp), + }; + BidStorage::store_bid(&env, &bid); + // Track bid for this invoice + BidStorage::add_bid_to_invoice(&env, &invoice_id, &bid_id); + + // Emit bid placed event + emit_bid_placed(&env, &bid); + + Ok(bid_id) + } + + /// Accept a bid (business only) + pub fn accept_bid( + env: Env, + invoice_id: BytesN<32>, + bid_id: BytesN<32>, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + reentrancy::with_payment_guard(&env, || { + Self::accept_bid_impl(env.clone(), invoice_id.clone(), bid_id.clone()) + }) + } + + fn accept_bid_impl( + env: Env, + invoice_id: BytesN<32>, + bid_id: BytesN<32>, + ) -> Result<(), QuickLendXError> { + BidStorage::cleanup_expired_bids(&env, &invoice_id); + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + let bid = BidStorage::get_bid(&env, &bid_id).ok_or(QuickLendXError::StorageKeyNotFound)?; + let invoice_id = bid.invoice_id.clone(); + BidStorage::cleanup_expired_bids(&env, &invoice_id); + let mut bid = + BidStorage::get_bid(&env, &bid_id).ok_or(QuickLendXError::StorageKeyNotFound)?; + invoice.business.require_auth(); + + // Enforce KYC: a pending business must not accept bids. + require_business_not_pending(&env, &invoice.business)?; + + if invoice.status != InvoiceStatus::Verified || bid.status != bid::BidStatus::Placed { + return Err(QuickLendXError::InvalidStatus); + } + + let escrow_id = create_escrow( + &env, + &invoice_id, + &bid.investor, + &invoice.business, + bid.bid_amount, + &invoice.currency, + )?; + bid.status = bid::BidStatus::Accepted; + BidStorage::update_bid(&env, &bid); + // Remove from old status list before changing status + InvoiceStorage::remove_from_status_invoices(&env, &InvoiceStatus::Verified, &invoice_id); + + invoice.mark_as_funded( + &env, + bid.investor.clone(), + bid.bid_amount, + env.ledger().timestamp(), + ); + InvoiceStorage::update_invoice(&env, &invoice); + + // Add to new status list after status change + InvoiceStorage::add_to_status_invoices(&env, &InvoiceStatus::Funded, &invoice_id); + let investment_id = InvestmentStorage::generate_unique_investment_id(&env); + let investment = Investment { + investment_id: investment_id.clone(), + invoice_id: invoice_id.clone(), + investor: bid.investor.clone(), + amount: bid.bid_amount, + funded_at: env.ledger().timestamp(), + status: InvestmentStatus::Active, + insurance: Vec::new(&env), + }; + InvestmentStorage::store_investment(&env, &investment); + + let escrow = EscrowStorage::get_escrow(&env, &escrow_id) + .expect("Escrow should exist after creation"); + emit_escrow_created(&env, &escrow); + emit_bid_accepted(&env, &bid, &invoice_id, &invoice.business); + + Ok(()) + } + + /// Add insurance coverage to an active investment (investor only). + /// + /// # Arguments + /// * `investment_id` - The investment to insure + /// * `provider` - Insurance provider address + /// * `coverage_percentage` - Coverage as a percentage (e.g. 80 for 80%) + /// + /// # Returns + /// * `Ok(())` on success + /// + /// # Errors + /// * `StorageKeyNotFound` if investment does not exist + /// * `InvalidStatus` if investment is not Active + /// * `InvalidAmount` if computed premium is zero + pub fn add_investment_insurance( + env: Env, + investment_id: BytesN<32>, + provider: Address, + coverage_percentage: u32, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let mut investment = InvestmentStorage::get_investment(&env, &investment_id) + .ok_or(QuickLendXError::StorageKeyNotFound)?; + + investment.investor.require_auth(); + + if investment.status != InvestmentStatus::Active { + return Err(QuickLendXError::InvalidStatus); + } + + let premium = Investment::calculate_premium(investment.amount, coverage_percentage); + if premium <= 0 { + return Err(QuickLendXError::InvalidAmount); + } + + let coverage_amount = + investment.add_insurance(provider.clone(), coverage_percentage, premium)?; + + InvestmentStorage::update_investment(&env, &investment); + + emit_insurance_added( + &env, + &investment_id, + &investment.invoice_id, + &investment.investor, + &provider, + coverage_percentage, + coverage_amount, + premium, + ); + emit_insurance_premium_collected(&env, &investment_id, &provider, premium); + + Ok(()) + } + + /// Settle an invoice (business or automated process) + pub fn settle_invoice( + env: Env, + invoice_id: BytesN<32>, + payment_amount: i128, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let _investment = InvestmentStorage::get_investment_by_invoice(&env, &invoice_id); + + let result = reentrancy::with_payment_guard(&env, || { + do_settle_invoice(&env, &invoice_id, payment_amount) + }); + + if result.is_ok() { + // Success + } + + result + } + + /// Get the investment record for a funded invoice. + /// + /// # Returns + /// * `Ok(Investment)` - The investment tied to the invoice + /// * `Err(StorageKeyNotFound)` if the invoice has no investment + pub fn get_invoice_investment( + env: Env, + invoice_id: BytesN<32>, + ) -> Result { + InvestmentStorage::get_investment_by_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::StorageKeyNotFound) + } + + /// Get an investment by ID. + /// + /// # Returns + /// * `Ok(Investment)` - The investment record + /// * `Err(StorageKeyNotFound)` if the ID does not exist + pub fn get_investment( + env: Env, + investment_id: BytesN<32>, + ) -> Result { + InvestmentStorage::get_investment(&env, &investment_id) + .ok_or(QuickLendXError::StorageKeyNotFound) + } + + /// Query insurance coverage for an investment. + /// + /// # Arguments + /// * `investment_id` - The investment to query + /// + /// # Returns + /// * `Ok(Vec)` - All insurance records for the investment + /// * `Err(StorageKeyNotFound)` if the investment does not exist + /// + /// # Security Notes + /// - Returns all insurance records (active and inactive) + /// - No authorization required for queries + pub fn query_investment_insurance( + env: Env, + investment_id: BytesN<32>, + ) -> Result, QuickLendXError> { + let investment = InvestmentStorage::get_investment(&env, &investment_id) + .ok_or(QuickLendXError::StorageKeyNotFound)?; + Ok(investment.insurance) + } + + /// Process a partial payment towards an invoice + pub fn process_partial_payment( + env: Env, + invoice_id: BytesN<32>, + payment_amount: i128, + transaction_id: String, + ) -> Result<(), QuickLendXError> { + reentrancy::with_payment_guard(&env, || { + do_process_partial_payment(&env, &invoice_id, payment_amount, transaction_id.clone()) + }) + } + + /// Handle invoice default (admin only) + /// This is the internal handler - use mark_invoice_defaulted for public API + pub fn handle_default(env: Env, invoice_id: BytesN<32>) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + admin.require_auth(); + + // Get the investment to track investor analytics + let _investment = InvestmentStorage::get_investment_by_invoice(&env, &invoice_id); + + let result = do_handle_default(&env, &invoice_id); + + result + } + + /// Mark an invoice as defaulted (admin only) + /// Checks due date + grace period before marking as defaulted. + /// Requires admin authorization to prevent unauthorized default marking. + /// + /// # Arguments + /// * `invoice_id` - The invoice ID to mark as defaulted + /// * `grace_period` - Optional grace period in seconds (defaults to 7 days) + /// + /// # Returns + /// * `Ok(())` if the invoice was successfully marked as defaulted + /// * `Err(QuickLendXError)` if the operation fails + /// + /// # Errors + /// * `NotAdmin` - No admin configured or caller is not admin + /// * `InvoiceNotFound` - Invoice does not exist + /// * `InvoiceAlreadyDefaulted` - Invoice is already defaulted + /// * `InvoiceNotAvailableForFunding` - Invoice is not in Funded status + /// * `OperationNotAllowed` - Grace period has not expired yet + pub fn mark_invoice_defaulted( + env: Env, + invoice_id: BytesN<32>, + grace_period: Option, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + admin.require_auth(); + + // Get the investment to track investor analytics + let _investment = InvestmentStorage::get_investment_by_invoice(&env, &invoice_id); + + let result = do_mark_invoice_defaulted(&env, &invoice_id, grace_period); + + result + } + + /// Calculate profit and platform fee + pub fn calculate_profit( + env: Env, + investment_amount: i128, + payment_amount: i128, + ) -> (i128, i128) { + do_calculate_profit(&env, investment_amount, payment_amount) + } + + /// Retrieve the current platform fee configuration + pub fn get_platform_fee(env: Env) -> PlatformFeeConfig { + PlatformFee::get_config(&env) + } + + /// Update the platform fee basis points (admin only) + pub fn set_platform_fee(env: Env, new_fee_bps: i128) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + PlatformFee::set_config(&env, &admin, new_fee_bps)?; + Ok(()) + } + + // Business KYC/Verification Functions (from main) + + /// Submit KYC application (business only) + pub fn submit_kyc_application( + env: Env, + business: Address, + kyc_data: String, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + submit_kyc_application(&env, &business, kyc_data) + } + + /// Submit investor verification request + pub fn submit_investor_kyc( + env: Env, + investor: Address, + kyc_data: String, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + do_submit_investor_kyc(&env, &investor, kyc_data) + } + + /// Verify an investor and set an investment limit + pub fn verify_investor( + env: Env, + investor: Address, + investment_limit: i128, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let admin = + BusinessVerificationStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + let verification = do_verify_investor(&env, &admin, &investor, investment_limit)?; + emit_investor_verified(&env, &verification); + Ok(()) + } + + /// Reject an investor verification requbusinesses + pub fn get_verified_businesses(env: Env) -> Vec
{ + BusinessVerificationStorage::get_verified_businesses(&env) + } + + /// Get all pending businesses + pub fn reject_investor( + env: Env, + investor: Address, + reason: String, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + do_reject_investor(&env, &admin, &investor, reason) + } + + /// Get investor verification record if available + pub fn get_investor_verification(env: Env, investor: Address) -> Option { + do_get_investor_verification(&env, &investor) + } + + /// Set investment limit for a verified investor (admin only) + pub fn set_investment_limit( + env: Env, + investor: Address, + new_limit: i128, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let admin = + BusinessVerificationStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + verification::set_investment_limit(&env, &admin, &investor, new_limit) + } + + /// Verify business (admin only) + pub fn verify_business( + env: Env, + admin: Address, + business: Address, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + verify_business(&env, &admin, &business) + } + + /// Reject business (admin only) + pub fn reject_business( + env: Env, + admin: Address, + business: Address, + reason: String, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + reject_business(&env, &admin, &business, reason) + } + + /// Get business verification status + pub fn get_business_verification_status( + env: Env, + business: Address, + ) -> Option { + verification::get_business_verification_status(&env, &business) + } + + /// Set admin address (initialization function) + pub fn set_admin(env: Env, admin: Address) -> Result<(), QuickLendXError> { + if let Some(current_admin) = BusinessVerificationStorage::get_admin(&env) { + current_admin.require_auth(); + } else { + admin.require_auth(); + } + BusinessVerificationStorage::set_admin(&env, &admin); + Ok(()) + } + + /// Get admin address + pub fn get_admin(env: Env) -> Option
{ + BusinessVerificationStorage::get_admin(&env) + } + + /// Initialize protocol limits (admin only). Sets min amount, max due date days, grace period. + pub fn initialize_protocol_limits( + env: Env, + admin: Address, + min_invoice_amount: i128, + max_due_date_days: u64, + grace_period_seconds: u64, + ) -> Result<(), QuickLendXError> { + let _ = protocol_limits::ProtocolLimitsContract::initialize(env.clone(), admin.clone()); + protocol_limits::ProtocolLimitsContract::set_protocol_limits( + env, + admin, + min_invoice_amount, + 10, // min_bid_amount + 100, // min_bid_bps + max_due_date_days, + grace_period_seconds, + 100, // max_invoices_per_business (default) + ) + } + + /// Update protocol limits (admin only). + pub fn set_protocol_limits( + env: Env, + admin: Address, + min_invoice_amount: i128, + max_due_date_days: u64, + grace_period_seconds: u64, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + protocol_limits::ProtocolLimitsContract::set_protocol_limits( + env, + admin, + min_invoice_amount, + 10, // min_bid_amount + 100, // min_bid_bps + max_due_date_days, + grace_period_seconds, + 100, // max_invoices_per_business (default) + ) + } + + /// Update protocol limits (admin only). + pub fn update_protocol_limits( + env: Env, + admin: Address, + min_invoice_amount: i128, + max_due_date_days: u64, + grace_period_seconds: u64, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + protocol_limits::ProtocolLimitsContract::set_protocol_limits( + env, + admin, + min_invoice_amount, + 10, // min_bid_amount + 100, // min_bid_bps + max_due_date_days, + grace_period_seconds, + 100, // max_invoices_per_business (default) + ) + } + + /// Update protocol limits with max invoices per business (admin only). + pub fn update_limits_max_invoices( + env: Env, + admin: Address, + min_invoice_amount: i128, + max_due_date_days: u64, + grace_period_seconds: u64, + max_invoices_per_business: u32, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + protocol_limits::ProtocolLimitsContract::set_protocol_limits( + env, + admin, + min_invoice_amount, + 10, // min_bid_amount + 100, // min_bid_bps + max_due_date_days, + grace_period_seconds, + max_invoices_per_business, + ) + } + + /// Get all pending businesses + pub fn get_pending_businesses(env: Env) -> Vec
{ + BusinessVerificationStorage::get_pending_businesses(&env) + } + + /// Get all rejected businesses + pub fn get_rejected_businesses(env: Env) -> Vec
{ + BusinessVerificationStorage::get_rejected_businesses(&env) + } + + // ======================================== + // Enhanced Investor Verification Functions + // ======================================== + + /// Get all verified investors + pub fn get_verified_investors(env: Env) -> Vec
{ + InvestorVerificationStorage::get_verified_investors(&env) + } + + /// Get all pending investors + pub fn get_pending_investors(env: Env) -> Vec
{ + InvestorVerificationStorage::get_pending_investors(&env) + } + + /// Get all rejected investors + pub fn get_rejected_investors(env: Env) -> Vec
{ + InvestorVerificationStorage::get_rejected_investors(&env) + } + + /// Get investors by tier + pub fn get_investors_by_tier(env: Env, tier: InvestorTier) -> Vec
{ + InvestorVerificationStorage::get_investors_by_tier(&env, tier) + } + + /// Get investors by risk level + pub fn get_investors_by_risk_level(env: Env, risk_level: InvestorRiskLevel) -> Vec
{ + InvestorVerificationStorage::get_investors_by_risk_level(&env, risk_level) + } + + /// Calculate investor risk score + pub fn calculate_investor_risk_score( + env: Env, + investor: Address, + kyc_data: String, + ) -> Result { + calculate_investor_risk_score(&env, &investor, &kyc_data) + } + + /// Determine investor tier + pub fn determine_investor_tier( + env: Env, + investor: Address, + risk_score: u32, + ) -> Result { + determine_investor_tier(&env, &investor, risk_score) + } + + /// Calculate investment limit for investor + pub fn calculate_investment_limit( + _env: Env, + tier: InvestorTier, + risk_level: InvestorRiskLevel, + base_limit: i128, + ) -> i128 { + calculate_investment_limit(&tier, &risk_level, base_limit) + } + + /// Validate investor investment + pub fn validate_investor_investment( + env: Env, + investor: Address, + investment_amount: i128, + ) -> Result<(), QuickLendXError> { + validate_investor_investment(&env, &investor, investment_amount) + } + + /// Check if investor is verified + pub fn is_investor_verified(env: Env, investor: Address) -> bool { + InvestorVerificationStorage::is_investor_verified(&env, &investor) + } + + /// Get escrow details for an invoice + pub fn get_escrow_details( + env: Env, + invoice_id: BytesN<32>, + ) -> Result { + EscrowStorage::get_escrow_by_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::StorageKeyNotFound) + } + + /// Get escrow status for an invoice + pub fn get_escrow_status( + env: Env, + invoice_id: BytesN<32>, + ) -> Result { + let escrow = EscrowStorage::get_escrow_by_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::StorageKeyNotFound)?; + Ok(escrow.status) + } + + /// Release escrow funds to business upon invoice verification + pub fn release_escrow_funds(env: Env, invoice_id: BytesN<32>) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + reentrancy::with_payment_guard(&env, || { + let invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + // Strictly enforce that escrow can only be released for Funded invoices. + // This prevents premature release even if an escrow object exists (e.g. from tests). + if invoice.status != InvoiceStatus::Funded { + return Err(QuickLendXError::InvalidStatus); + } + + let escrow = EscrowStorage::get_escrow_by_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::StorageKeyNotFound)?; + + release_escrow(&env, &invoice_id)?; + + emit_escrow_released( + &env, + &escrow.escrow_id, + &invoice_id, + &escrow.business, + escrow.amount, + ); + + Ok(()) + }) + } + + /// Refund escrow funds to investor if verification fails or as an explicit manual refund. + /// + /// Can be triggered by Admin or Business owner. Invoice must be Funded. + /// Protected by payment reentrancy guard. + pub fn refund_escrow_funds( + env: Env, + invoice_id: BytesN<32>, + caller: Address, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + reentrancy::with_payment_guard(&env, || do_refund_escrow_funds(&env, &invoice_id, &caller)) + } + + /// Check for overdue invoices and send notifications (admin or automated process) + /// + /// @notice Scans a bounded funded-invoice window for overdue/default handling. + /// @dev This entry point uses the default rotating batch limit to keep per-call work bounded. + /// Repeated invocations eventually cover the full funded set as the stored cursor advances. + /// @param env The contract environment. + /// @return Number of overdue funded invoices found within the scanned window. + pub fn check_overdue_invoices(env: Env) -> Result { + let grace_period = defaults::resolve_grace_period(&env, None)?; + Self::check_overdue_invoices_grace(env, grace_period) + } + + /// Check for overdue invoices with a custom grace period (in seconds) + /// + /// @notice Scans a bounded funded-invoice window using a caller-supplied grace period. + /// @dev The scan size is capped by protocol constants to keep execution deterministic. + /// @param env The contract environment. + /// @param grace_period Grace period in seconds applied to each funded invoice in the window. + /// @return Number of overdue funded invoices found within the scanned window. + pub fn check_overdue_invoices_grace( + env: Env, + grace_period: u64, + ) -> Result { + Ok(defaults::scan_funded_invoice_expirations(&env, grace_period, None)?.overdue_count) + } + + /// @notice Returns the current funded-invoice overdue scan cursor. + /// @param env The contract environment. + /// @return Zero-based index of the next funded invoice to inspect. + pub fn get_overdue_scan_cursor(env: Env) -> u32 { + defaults::get_overdue_scan_cursor(&env) + } + + /// @notice Returns the default funded-invoice overdue scan batch size. + /// @return Default number of funded invoices processed by `check_overdue_invoices*`. + pub fn get_overdue_scan_batch_limit(_env: Env) -> u32 { + defaults::default_overdue_scan_batch_limit() + } + + /// @notice Returns the maximum funded-invoice overdue scan batch size. + /// @return Hard upper bound accepted by `scan_overdue_invoices`. + pub fn get_overdue_scan_batch_limit_max(_env: Env) -> u32 { + defaults::max_overdue_scan_batch_limit() + } + + /// Check whether a specific invoice has expired and trigger default handling when necessary + pub fn check_invoice_expiration( + env: Env, + invoice_id: BytesN<32>, + grace_period: Option, + ) -> Result { + pause::PauseControl::require_not_paused(&env)?; + let invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + let grace = defaults::resolve_grace_period(&env, grace_period)?; + invoice.check_and_handle_expiration(&env, grace) + } + + // Category and Tag Management Functions + + /// Get invoices by category + pub fn get_invoices_by_category( + env: Env, + category: invoice::InvoiceCategory, + ) -> Vec> { + InvoiceStorage::get_invoices_by_category(&env, &category) + } + + /// Get invoices by category and status + pub fn get_invoices_by_cat_status( + env: Env, + category: invoice::InvoiceCategory, + status: InvoiceStatus, + ) -> Vec> { + InvoiceStorage::get_invoices_by_category_and_status(&env, &category, &status) + } + + /// Get invoices by tag + pub fn get_invoices_by_tag(env: Env, tag: String) -> Vec> { + InvoiceStorage::get_invoices_by_tag(&env, &tag) + } + + /// Get invoices by multiple tags (AND logic) + pub fn get_invoices_by_tags(env: Env, tags: Vec) -> Vec> { + InvoiceStorage::get_invoices_by_tags(&env, &tags) + } + + /// Get invoice count by category + pub fn get_invoice_count_by_category(env: Env, category: invoice::InvoiceCategory) -> u32 { + InvoiceStorage::get_invoice_count_by_category(&env, &category) + } + + /// Get invoice count by tag + pub fn get_invoice_count_by_tag(env: Env, tag: String) -> u32 { + InvoiceStorage::get_invoice_count_by_tag(&env, &tag) + } + + /// Get all available categories + pub fn get_all_categories(env: Env) -> Vec { + InvoiceStorage::get_all_categories(&env) + } + + /// Update invoice category (business owner only) + pub fn update_invoice_category( + env: Env, + invoice_id: BytesN<32>, + new_category: invoice::InvoiceCategory, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + // Only the business owner can update the category + invoice.business.require_auth(); + + let old_category = invoice.category.clone(); + invoice.update_category(new_category.clone()); + + // Validate the new category + verification::validate_invoice_category(&new_category)?; + + // Update the invoice + InvoiceStorage::update_invoice(&env, &invoice); + + // Emit event + events::emit_invoice_category_updated( + &env, + &invoice_id, + &invoice.business, + &old_category, + &new_category, + ); + + // Update indexes + InvoiceStorage::remove_category_index(&env, &old_category, &invoice_id); + InvoiceStorage::add_category_index(&env, &new_category, &invoice_id); + + Ok(()) + } + + /// Add tag to invoice (business owner only) + pub fn add_invoice_tag( + env: Env, + invoice_id: BytesN<32>, + tag: String, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + // Authorization: Ensure the stored business owner authorizes the change + invoice.business.require_auth(); + + // Tag Normalization: Synchronize with protocol requirements + let normalized_tag = normalize_tag(&env, &tag)?; + invoice.add_tag(&env, normalized_tag.clone())?; + + // Update the invoice + InvoiceStorage::update_invoice(&env, &invoice); + + // Emit event with normalized data + events::emit_invoice_tag_added(&env, &invoice_id, &invoice.business, &normalized_tag); + + // Update index with normalized form + InvoiceStorage::add_tag_index(&env, &normalized_tag, &invoice_id); + + Ok(()) + } + + /// Remove tag from invoice (business owner only) + pub fn remove_invoice_tag( + env: Env, + invoice_id: BytesN<32>, + tag: String, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + // Authorization: Ensure the stored business owner authorizes the removal + invoice.business.require_auth(); + + // Normalize tag for removal lookup + let normalized_tag = normalize_tag(&env, &tag)?; + invoice.remove_tag(normalized_tag.clone())?; + + // Update the invoice + InvoiceStorage::update_invoice(&env, &invoice); + + // Emit event with normalized data + events::emit_invoice_tag_removed(&env, &invoice_id, &invoice.business, &normalized_tag); + + // Update index using normalized form + InvoiceStorage::remove_tag_index(&env, &normalized_tag, &invoice_id); + + Ok(()) + } + + /// Get all tags for an invoice + pub fn get_invoice_tags( + env: Env, + invoice_id: BytesN<32>, + ) -> Result, QuickLendXError> { + let invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + Ok(invoice.get_tags()) + } + + /// Check if invoice has a specific tag + pub fn invoice_has_tag( + env: Env, + invoice_id: BytesN<32>, + tag: String, + ) -> Result { + let invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + Ok(invoice.has_tag(tag)) + } + + // ======================================== + // Fee and Revenue Management Functions + // ======================================== + + /// Initialize fee management system + pub fn initialize_fee_system(env: Env, admin: Address) -> Result<(), QuickLendXError> { + fees::FeeManager::initialize(&env, &admin) + } + + /// Configure treasury address for platform fee routing (admin only) + pub fn configure_treasury(env: Env, treasury_address: Address) -> Result<(), QuickLendXError> { + let admin = + BusinessVerificationStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + + let _treasury_config = + fees::FeeManager::configure_treasury(&env, &admin, treasury_address.clone())?; + + // Emit event + events::emit_treasury_configured(&env, &treasury_address, &admin); + + Ok(()) + } + + /// Update platform fee basis points (admin only) + pub fn update_platform_fee_bps(env: Env, new_fee_bps: u32) -> Result<(), QuickLendXError> { + let admin = + BusinessVerificationStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + + let old_config = fees::FeeManager::get_platform_fee_config(&env)?; + let old_fee_bps = old_config.fee_bps; + + let _new_config = fees::FeeManager::update_platform_fee(&env, &admin, new_fee_bps)?; + + // Emit event + events::emit_platform_fee_config_updated(&env, old_fee_bps, new_fee_bps, &admin); + + Ok(()) + } + + /// Get current platform fee configuration + pub fn get_platform_fee_config(env: Env) -> Result { + fees::FeeManager::get_platform_fee_config(&env) + } + + /// Get treasury address if configured + pub fn get_treasury_address(env: Env) -> Option
{ + fees::FeeManager::get_treasury_address(&env) + } + + /// Update fee structure for a specific fee type + pub fn update_fee_structure( + env: Env, + admin: Address, + fee_type: fees::FeeType, + base_fee_bps: u32, + min_fee: i128, + max_fee: i128, + is_active: bool, + ) -> Result { + fees::FeeManager::update_fee_structure( + &env, + &admin, + fee_type, + base_fee_bps, + min_fee, + max_fee, + is_active, + ) + } + + /// Get fee structure for a fee type + pub fn get_fee_structure( + env: Env, + fee_type: fees::FeeType, + ) -> Result { + fees::FeeManager::get_fee_structure(&env, &fee_type) + } + + /// Calculate total fees for a transaction + pub fn calculate_transaction_fees( + env: Env, + user: Address, + transaction_amount: i128, + is_early_payment: bool, + is_late_payment: bool, + ) -> Result { + fees::FeeManager::calculate_total_fees( + &env, + &user, + transaction_amount, + is_early_payment, + is_late_payment, + ) + } + + /// Get user volume data and tier + pub fn get_user_volume_data(env: Env, user: Address) -> fees::UserVolumeData { + fees::FeeManager::get_user_volume(&env, &user) + } + + /// Update user volume (called internally after transactions) + pub fn update_user_transaction_volume( + env: Env, + user: Address, + transaction_amount: i128, + ) -> Result { + fees::FeeManager::update_user_volume(&env, &user, transaction_amount) + } + + /// Configure revenue distribution + pub fn configure_revenue_distribution( + env: Env, + admin: Address, + treasury_address: Address, + treasury_share_bps: u32, + developer_share_bps: u32, + platform_share_bps: u32, + auto_distribution: bool, + min_distribution_amount: i128, + ) -> Result<(), QuickLendXError> { + // Verify admin + let stored_admin = + BusinessVerificationStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + if admin != stored_admin { + return Err(QuickLendXError::NotAdmin); + } + + let config = fees::RevenueConfig { + treasury_address, + treasury_share_bps, + developer_share_bps, + platform_share_bps, + auto_distribution, + min_distribution_amount, + }; + fees::FeeManager::configure_revenue_distribution(&env, &admin, config) + } + + /// Get current revenue split configuration + pub fn get_revenue_split_config(env: Env) -> Result { + fees::FeeManager::get_revenue_split_config(&env) + } + + /// Distribute revenue for a period. + /// + /// Fails with [`QuickLendXError::OperationNotAllowed`] if there is no pending balance for the + /// period (including after a successful distribution until new fees are collected). + pub fn distribute_revenue( + env: Env, + admin: Address, + period: u64, + ) -> Result<(i128, i128, i128), QuickLendXError> { + fees::FeeManager::distribute_revenue(&env, &admin, period) + } + + /// Get fee analytics for a period + pub fn get_fee_analytics(env: Env, period: u64) -> Result { + fees::FeeManager::get_analytics(&env, period) + } + + /// Collect fees (internal function called after fee calculation) + pub fn collect_transaction_fees( + env: Env, + user: Address, + fees_by_type: Map, + total_amount: i128, + ) -> Result<(), QuickLendXError> { + fees::FeeManager::collect_fees(&env, &user, fees_by_type, total_amount) + } + + /// Validate fee parameters + pub fn validate_fee_parameters( + _env: Env, + base_fee_bps: u32, + min_fee: i128, + max_fee: i128, + ) -> Result<(), QuickLendXError> { + fees::FeeManager::validate_fee_params(base_fee_bps, min_fee, max_fee) + } + + // ======================================== + // Query Functions for Frontend Integration + // ======================================== + + /// Get invoices by business with optional status filter and pagination + /// @notice Get business invoices with pagination and optional status filtering + /// @param business The business address to query invoices for + /// @param status_filter Optional status filter (None returns all statuses) + /// @param offset Starting index for pagination (0-based) + /// @param limit Maximum number of results to return (capped at MAX_QUERY_LIMIT) + /// @return Vector of invoice IDs matching the criteria + /// @dev Enforces MAX_QUERY_LIMIT hard cap for security and performance + pub fn get_business_invoices_paged( + env: Env, + business: Address, + status_filter: Option, + offset: u32, + limit: u32, + ) -> Vec> { + // Validate query parameters for security + if validate_query_params(offset, limit).is_err() { + // Return empty result on validation failure + return Vec::new(&env); + } + + let capped_limit = cap_query_limit(limit); + let all_invoices = InvoiceStorage::get_business_invoices(&env, &business); + let mut filtered = Vec::new(&env); + + for invoice_id in all_invoices.iter() { + if let Some(invoice) = InvoiceStorage::get_invoice(&env, &invoice_id) { + if let Some(status) = &status_filter { + if invoice.status == *status { + filtered.push_back(invoice_id); + } + } else { + filtered.push_back(invoice_id); + } + } + } + + // Apply pagination (overflow-safe) + let mut result = Vec::new(&env); + let len_u32 = filtered.len() as u32; + let start = offset.min(len_u32); + let end = start.saturating_add(capped_limit).min(len_u32); + let mut idx = start; + while idx < end { + if let Some(invoice_id) = filtered.get(idx) { + result.push_back(invoice_id); + } + idx += 1; + } + result + } + + /// Get investments by investor with optional status filter and pagination + /// Retrieves paginated investments for a specific investor with enhanced boundary checking. + /// + /// This function provides overflow-safe pagination with comprehensive boundary validation + /// to prevent arithmetic overflow and ensure consistent behavior across all edge cases. + /// + /// # Arguments + /// * `env` - Soroban environment + /// * `investor` - Address of the investor to query + /// * `status_filter` - Optional filter by investment status + /// * `offset` - Starting position (0-based, will be capped to available data) + /// * `limit` - Maximum records to return (capped to MAX_QUERY_LIMIT) + /// + /// # Returns + /// * Vector of investment IDs matching the criteria + /// + /// # Security Notes + /// - Uses saturating arithmetic throughout to prevent overflow attacks + /// - Validates all array bounds before access + /// - Caps query limit to prevent DoS via large requests + /// - Handles edge cases like offset >= total_count gracefully + /// + /// # Examples + /// ``` + /// // Get first 10 active investments + /// let investments = contract.get_investor_investments_paged( + /// env, investor, Some(InvestmentStatus::Active), 0, 10 + /// ); + /// + /// // Get next page with offset + /// let next_page = contract.get_investor_investments_paged( + /// env, investor, Some(InvestmentStatus::Active), 10, 10 + /// ); + /// ``` + pub fn get_investor_investments_paged( + env: Env, + investor: Address, + status_filter: Option, + offset: u32, + limit: u32, + ) -> Vec> { + investment_queries::InvestmentQueries::get_investor_investments_paginated( + &env, + &investor, + status_filter, + offset, + limit, + ) + } + + /// Get available invoices with pagination and optional filters + /// @notice Get available invoices with pagination and optional filters + /// @param min_amount Optional minimum invoice amount filter + /// @param max_amount Optional maximum invoice amount filter + /// @param category_filter Optional category filter + /// @param offset Starting index for pagination (0-based) + /// @param limit Maximum number of results to return (capped at MAX_QUERY_LIMIT) + /// @return Vector of verified invoice IDs matching the criteria + /// @dev Enforces MAX_QUERY_LIMIT hard cap for security and performance + pub fn get_available_invoices_paged( + env: Env, + min_amount: Option, + max_amount: Option, + category_filter: Option, + offset: u32, + limit: u32, + ) -> Vec> { + // Validate query parameters for security + if validate_query_params(offset, limit).is_err() { + return Vec::new(&env); + } + + let capped_limit = cap_query_limit(limit); + let verified_invoices = + InvoiceStorage::get_invoices_by_status(&env, &InvoiceStatus::Verified); + let mut filtered = Vec::new(&env); + + for invoice_id in verified_invoices.iter() { + if let Some(invoice) = InvoiceStorage::get_invoice(&env, &invoice_id) { + // Filter by amount range + if let Some(min) = min_amount { + if invoice.amount < min { + continue; + } + } + if let Some(max) = max_amount { + if invoice.amount > max { + continue; + } + } + // Filter by category + if let Some(category) = &category_filter { + if invoice.category != *category { + continue; + } + } + filtered.push_back(invoice_id); + } + } + + // Apply pagination (overflow-safe) + let mut result = Vec::new(&env); + let len_u32 = filtered.len() as u32; + let start = offset.min(len_u32); + let end = start.saturating_add(capped_limit).min(len_u32); + let mut idx = start; + while idx < end { + if let Some(invoice_id) = filtered.get(idx) { + result.push_back(invoice_id); + } + idx += 1; + } + result + } + + /// Get bid history for an invoice with pagination + /// @notice Get bid history for an invoice with pagination and optional status filtering + /// @param invoice_id The invoice ID to query bids for + /// @param status_filter Optional status filter (None returns all statuses) + /// @param offset Starting index for pagination (0-based) + /// @param limit Maximum number of results to return (capped at MAX_QUERY_LIMIT) + /// @return Vector of bids matching the criteria + /// @dev Enforces MAX_QUERY_LIMIT hard cap for security and performance + pub fn get_bid_history_paged( + env: Env, + invoice_id: BytesN<32>, + status_filter: Option, + offset: u32, + limit: u32, + ) -> Vec { + // Validate query parameters for security + if validate_query_params(offset, limit).is_err() { + return Vec::new(&env); + } + + let capped_limit = cap_query_limit(limit); + let all_bids = BidStorage::get_bid_records_for_invoice(&env, &invoice_id); + let mut filtered = Vec::new(&env); + + for bid in all_bids.iter() { + let include = match status_filter { + Some(s) => bid.status == map_public_bid_status(s), + None => true, + }; + if include { + filtered.push_back(bid.clone()); + } + } + + // Apply pagination (overflow-safe) + let mut result = Vec::new(&env); + let len_u32 = filtered.len() as u32; + let start = offset.min(len_u32); + let end = start.saturating_add(capped_limit).min(len_u32); + let mut idx = start; + while idx < end { + if let Some(bid) = filtered.get(idx) { + result.push_back(bid); + } + idx += 1; + } + result + } + + /// Get bid history for an investor with pagination + /// @notice Get bid history for an investor with pagination and optional status filtering + /// @param investor The investor address to query bids for + /// @param status_filter Optional status filter (None returns all statuses) + /// @param offset Starting index for pagination (0-based) + /// @param limit Maximum number of results to return (capped at MAX_QUERY_LIMIT) + /// @return Vector of bids matching the criteria + /// @dev Enforces MAX_QUERY_LIMIT hard cap for security and performance + pub fn get_investor_bids_paged( + env: Env, + investor: Address, + status_filter: Option, + offset: u32, + limit: u32, + ) -> Vec { + // Validate query parameters for security + if validate_query_params(offset, limit).is_err() { + return Vec::new(&env); + } + + let capped_limit = cap_query_limit(limit); + let all_bid_ids = BidStorage::get_bids_by_investor_all(&env, &investor); + let mut filtered = Vec::new(&env); + + for bid_id in all_bid_ids.iter() { + if let Some(bid) = BidStorage::get_bid(&env, &bid_id) { + let include = match status_filter { + Some(s) => bid.status == map_public_bid_status(s), + None => true, + }; + if include { + filtered.push_back(bid.clone()); + } + } + } + + // Apply pagination (overflow-safe) + let mut result = Vec::new(&env); + let len_u32 = filtered.len() as u32; + let start = offset.min(len_u32); + let end = start.saturating_add(capped_limit).min(len_u32); + let mut idx = start; + while idx < end { + if let Some(bid) = filtered.get(idx) { + result.push_back(bid); + } + idx += 1; + } + result + } + + /// Get investments by investor (simple version without pagination for backward compatibility) + pub fn get_investments_by_investor(env: Env, investor: Address) -> Vec> { + InvestmentStorage::get_investments_by_investor(&env, &investor) + } + + /// Get bid history for an invoice (simple version without pagination) + pub fn get_bid_history(env: Env, invoice_id: BytesN<32>) -> Vec { + BidStorage::get_bid_records_for_invoice(&env, &invoice_id) + } + + // ========================================================================= + // Backup + // ========================================================================= + + /// 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)?; + AdminStorage::require_admin(&env, &admin)?; + let backup_id = backup::BackupStorage::generate_backup_id(&env); + let invoices = backup::BackupStorage::get_all_invoices(&env); + let b = backup::Backup { + backup_id: backup_id.clone(), + timestamp: env.ledger().timestamp(), + description: String::from_str(&env, "Manual Backup"), + invoice_count: invoices.len() as u32, + status: backup::BackupStatus::Active, + }; + backup::BackupStorage::store_backup(&env, &b, Some(&invoices))?; + backup::BackupStorage::store_backup_data(&env, &backup_id, &invoices); + backup::BackupStorage::add_to_backup_list(&env, &backup_id); + let _ = backup::BackupStorage::cleanup_old_backups(&env); + Ok(backup_id) + } + + /// Restore invoice data from a backup (admin only). + pub fn restore_backup( + env: Env, + admin: Address, + backup_id: BytesN<32>, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + AdminStorage::require_admin(&env, &admin)?; + backup::BackupStorage::validate_backup(&env, &backup_id)?; + let invoices = backup::BackupStorage::get_backup_data(&env, &backup_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + InvoiceStorage::clear_all(&env); + for inv in invoices.iter() { + InvoiceStorage::store_invoice(&env, &inv); + } + Ok(()) + } + + /// Archive a backup (admin only). + pub fn archive_backup( + env: Env, + admin: Address, + backup_id: BytesN<32>, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + AdminStorage::require_admin(&env, &admin)?; + let mut b = backup::BackupStorage::get_backup(&env, &backup_id) + .ok_or(QuickLendXError::StorageKeyNotFound)?; + b.status = backup::BackupStatus::Archived; + backup::BackupStorage::update_backup(&env, &b)?; + backup::BackupStorage::remove_from_backup_list(&env, &backup_id); + Ok(()) + } + + /// Validate a backup's integrity. + pub fn validate_backup(env: Env, backup_id: BytesN<32>) -> bool { + backup::BackupStorage::validate_backup(&env, &backup_id).is_ok() + } + + /// Get backup details by ID. + pub fn get_backup_details(env: Env, backup_id: BytesN<32>) -> Option { + backup::BackupStorage::get_backup(&env, &backup_id) + } + + /// Get list of all active backup IDs. + pub fn get_backups(env: Env) -> Vec> { + backup::BackupStorage::get_all_backups(&env) + } + + /// Manually trigger cleanup of old backups (admin only). + pub fn cleanup_backups(env: Env, admin: Address) -> Result { + pause::PauseControl::require_not_paused(&env)?; + AdminStorage::require_admin(&env, &admin)?; + backup::BackupStorage::cleanup_old_backups(&env) + } + + /// Configure backup retention policy (admin only). + pub fn set_backup_retention_policy( + env: Env, + admin: Address, + max_backups: u32, + max_age_seconds: u64, + auto_cleanup_enabled: bool, + ) -> Result<(), QuickLendXError> { + pause::PauseControl::require_not_paused(&env)?; + AdminStorage::require_admin(&env, &admin)?; + let policy = backup::BackupRetentionPolicy { + max_backups, + max_age_seconds, + auto_cleanup_enabled, + }; + backup::BackupStorage::set_retention_policy(&env, &policy); + Ok(()) + } + + /// Get current backup retention policy. + pub fn get_backup_retention_policy(env: Env) -> backup::BackupRetentionPolicy { + backup::BackupStorage::get_retention_policy(&env) + } + + // ============================================================================ + // Vesting Functions + // ============================================================================ + + pub fn create_vesting_schedule( + env: Env, + admin: Address, + token: Address, + beneficiary: Address, + total_amount: i128, + start_time: u64, + cliff_seconds: u64, + end_time: u64, + ) -> Result { + vesting::Vesting::create_schedule( + &env, + &admin, + token, + beneficiary, + total_amount, + start_time, + cliff_seconds, + end_time, + ) + } + + pub fn get_vesting_schedule(env: Env, id: u64) -> Option { + vesting::Vesting::get_schedule(&env, id) + } + + pub fn release_vested_tokens( + env: Env, + beneficiary: Address, + id: u64, + ) -> Result { + vesting::Vesting::release(&env, &beneficiary, id) + } + + pub fn get_vesting_releasable(env: Env, id: u64) -> Option { + let schedule = vesting::Vesting::get_schedule(&env, id)?; + vesting::Vesting::releasable_amount(&env, &schedule).ok() + } + + // ============================================================================ + // Analytics Functions + // ============================================================================ + + /// Get user behavior metrics for a specific user. + pub fn get_user_behavior_metrics(env: Env, user: Address) -> analytics::UserBehaviorMetrics { + analytics::AnalyticsCalculator::calculate_user_behavior_metrics(&env, &user).unwrap() + } + + /// Financial metrics for a time bucket (fees, volume, etc.). + pub fn get_financial_metrics( + env: Env, + period: analytics::TimePeriod, + ) -> Result { + analytics::AnalyticsCalculator::calculate_financial_metrics(&env, period) + } + + // ========================================================================= + // Analytics (contract-exported) + // ========================================================================= + + pub fn get_platform_metrics(env: Env) -> analytics::PlatformMetrics { + analytics::AnalyticsStorage::get_platform_metrics(&env).unwrap_or_else(|| { + analytics::AnalyticsCalculator::calculate_platform_metrics(&env).unwrap_or( + analytics::PlatformMetrics { + total_invoices: 0, + total_investments: 0, + total_volume: 0, + total_fees_collected: 0, + active_investors: 0, + verified_businesses: 0, + average_invoice_amount: 0, + average_investment_amount: 0, + platform_fee_rate: 0, + default_rate: 0, + success_rate: 0, + timestamp: env.ledger().timestamp(), + }, + ) + }) + } + + pub fn get_performance_metrics(env: Env) -> analytics::PerformanceMetrics { + analytics::AnalyticsStorage::get_performance_metrics(&env).unwrap_or_else(|| { + analytics::AnalyticsCalculator::calculate_performance_metrics(&env).unwrap_or( + analytics::PerformanceMetrics { + platform_uptime: env.ledger().timestamp(), + average_settlement_time: 0, + average_verification_time: 0, + dispute_resolution_time: 0, + system_response_time: 0, + transaction_success_rate: 0, + error_rate: 0, + user_satisfaction_score: 0, + platform_efficiency: 0, + }, + ) + }) + } + + /// Build and persist a business report for the given period bucket. + pub fn generate_business_report( + env: Env, + business: Address, + period: analytics::TimePeriod, + ) -> Result { + let report = + analytics::AnalyticsCalculator::generate_business_report(&env, &business, period)?; + analytics::AnalyticsStorage::store_business_report(&env, &report); + Ok(report) + } + + /// Retrieve a stored business report by ID + pub fn get_business_report( + env: Env, + report_id: BytesN<32>, + ) -> Option { + analytics::AnalyticsStorage::get_business_report(&env, &report_id) + } + + /// 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(()) + } + + // ========================================================================= + // Dispute + // ========================================================================= + + /// @notice Open a dispute on an invoice. + /// + /// @dev Only the invoice's business owner or its investor may call this. + /// Enforces one-dispute-per-invoice and validates all string inputs. + /// The dispute starts in the `Disputed` state; only the admin can + /// advance it. + /// + /// @param invoice_id 32-byte invoice identifier. + /// @param creator Caller address (must be business or investor). + /// @param reason Dispute reason (1–1000 chars). + /// @param evidence Supporting evidence (1–2000 chars). + /// + /// @error InvoiceNotFound Invoice does not exist. + /// @error DisputeAlreadyExists A dispute is already open on this invoice. + /// @error DisputeNotAuthorized Caller is neither business nor investor. + /// @error InvalidDisputeReason Reason is empty or exceeds 1000 chars. + /// @error InvalidDisputeEvidence Evidence is empty or exceeds 2000 chars. + pub fn create_dispute( + env: Env, + invoice_id: BytesN<32>, + creator: Address, + reason: String, + evidence: String, + ) -> Result<(), QuickLendXError> { + // --- 1. Authenticate the caller --- + creator.require_auth(); + + // --- 2. Load invoice --- + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + // --- 3. One-dispute-per-invoice guard --- + if invoice.dispute_status != invoice::DisputeStatus::None { + return Err(QuickLendXError::DisputeAlreadyExists); + } + + // --- 4. Role check: only business owner or investor may dispute --- + let is_business = creator == invoice.business; + let is_investor = invoice + .investor + .as_ref() + .map_or(false, |inv| creator == *inv); + if !is_business && !is_investor { + return Err(QuickLendXError::DisputeNotAuthorized); + } + + // --- 5. Input validation --- + if reason.len() == 0 || reason.len() > protocol_limits::MAX_DISPUTE_REASON_LENGTH { + return Err(QuickLendXError::InvalidDisputeReason); + } + if evidence.len() == 0 || evidence.len() > protocol_limits::MAX_DISPUTE_EVIDENCE_LENGTH { + return Err(QuickLendXError::InvalidDisputeEvidence); + } + + // --- 6. Record dispute (write-once fields) --- + invoice.dispute_status = invoice::DisputeStatus::Disputed; + invoice.dispute = invoice::Dispute { + created_by: creator, + created_at: env.ledger().timestamp(), + reason, + evidence, + resolution: String::from_str(&env, ""), + resolved_by: Address::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), + resolved_at: 0, + }; + InvoiceStorage::update_invoice(&env, &invoice); + Ok(()) + } + + /// @notice Return the dispute status of an invoice. + /// + /// @param invoice_id 32-byte invoice identifier. + /// @return The current `DisputeStatus` (including `None` when no dispute exists). + /// @error InvoiceNotFound Invoice does not exist. + pub fn get_invoice_dispute_status( + env: Env, + invoice_id: BytesN<32>, + ) -> Result { + let invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + Ok(invoice.dispute_status) + } + + /// @notice Return the full dispute record for an invoice, if one exists. + /// + /// @param invoice_id 32-byte invoice identifier. + /// @return `Some(Dispute)` when a dispute exists, `None` otherwise. + /// @error InvoiceNotFound Invoice does not exist. + pub fn get_dispute_details( + env: Env, + invoice_id: BytesN<32>, + ) -> Result, QuickLendXError> { + let invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + if invoice.dispute_status == invoice::DisputeStatus::None { + return Ok(None); + } + Ok(Some(invoice.dispute)) + } + + /// @notice Advance a dispute from `Disputed` to `UnderReview` (admin only). + /// + /// @dev Enforces the forward-only state machine: only `Disputed` → `UnderReview` + /// is permitted. Any other current state returns `InvalidStatus`. + /// + /// @param invoice_id 32-byte invoice identifier. + /// @param admin Admin address (must match stored admin). + /// + /// @error NotAdmin Caller is not the stored admin. + /// @error InvoiceNotFound Invoice does not exist. + /// @error DisputeNotFound No dispute exists on this invoice. + /// @error InvalidStatus Dispute is not in `Disputed` state. + pub fn put_dispute_under_review( + env: Env, + invoice_id: BytesN<32>, + admin: Address, + ) -> Result<(), QuickLendXError> { + // --- 1. Authenticate and verify admin role --- + admin.require_auth(); + AdminStorage::require_admin(&env, &admin)?; + + // --- 2. Load invoice --- + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + // --- 3. Dispute must exist --- + if invoice.dispute_status == invoice::DisputeStatus::None { + return Err(QuickLendXError::DisputeNotFound); + } + + // --- 4. State machine: only Disputed → UnderReview --- + if invoice.dispute_status != invoice::DisputeStatus::Disputed { + return Err(QuickLendXError::InvalidStatus); + } + + invoice.dispute_status = invoice::DisputeStatus::UnderReview; + InvoiceStorage::update_invoice(&env, &invoice); + Ok(()) + } + + /// @notice Finalize a dispute with a resolution text (admin only). + /// + /// @dev Enforces the forward-only state machine: only `UnderReview` → `Resolved` + /// is permitted. `Resolved` is a **terminal, locked state** — no further + /// transitions are possible without an explicit policy-override path. + /// A second call on an already-resolved dispute returns + /// `DisputeNotUnderReview` because the status is no longer `UnderReview`. + /// + /// @param invoice_id 32-byte invoice identifier. + /// @param admin Admin address (must match stored admin). + /// @param resolution Resolution text (1–2000 chars). + /// + /// @error NotAdmin Caller is not the stored admin. + /// @error InvoiceNotFound Invoice does not exist. + /// @error DisputeNotFound No dispute exists on this invoice. + /// @error DisputeNotUnderReview Dispute is not in `UnderReview` state + /// (includes already-resolved disputes). + /// @error InvalidDisputeReason Resolution is empty or exceeds 2000 chars. + pub fn resolve_dispute( + env: Env, + invoice_id: BytesN<32>, + admin: Address, + resolution: String, + ) -> Result<(), QuickLendXError> { + // --- 1. Authenticate and verify admin role --- + admin.require_auth(); + AdminStorage::require_admin(&env, &admin)?; + + // --- 2. Load invoice --- + let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + // --- 3. Dispute must exist --- + if invoice.dispute_status == invoice::DisputeStatus::None { + return Err(QuickLendXError::DisputeNotFound); + } + + // --- 4. LOCKING GUARD: only UnderReview → Resolved is allowed. + // This also prevents re-resolution (Resolved → Resolved) because + // the status is no longer UnderReview. The Resolved state is + // terminal and immutable without an explicit policy override. --- + if invoice.dispute_status != invoice::DisputeStatus::UnderReview { + return Err(QuickLendXError::DisputeNotUnderReview); + } + + // --- 5. Validate resolution text --- + if resolution.len() == 0 + || resolution.len() > protocol_limits::MAX_DISPUTE_RESOLUTION_LENGTH + { + return Err(QuickLendXError::InvalidDisputeReason); + } + + // --- 6. Write-once resolution fields --- + let now = env.ledger().timestamp(); + invoice.dispute_status = invoice::DisputeStatus::Resolved; + invoice.dispute.resolution = resolution; + invoice.dispute.resolved_by = admin; + invoice.dispute.resolved_at = now; + InvoiceStorage::update_invoice(&env, &invoice); + Ok(()) + } + + /// @notice Return all invoice IDs that have an active or historical dispute. + /// + /// @dev Scans all non-terminal invoice statuses. The list grows as disputes + /// are created and is never pruned (historical disputes remain visible). + /// + /// @return Vec of invoice IDs with any dispute status other than `None`. + pub fn get_invoices_with_disputes(env: Env) -> Vec> { + let mut result = Vec::new(&env); + for status in [ + InvoiceStatus::Pending, + InvoiceStatus::Verified, + InvoiceStatus::Funded, + InvoiceStatus::Paid, + InvoiceStatus::Defaulted, + InvoiceStatus::Cancelled, + InvoiceStatus::Refunded, + ] { + for id in InvoiceStorage::get_invoices_by_status(&env, &status).iter() { + if let Some(inv) = InvoiceStorage::get_invoice(&env, &id) { + if inv.dispute_status != invoice::DisputeStatus::None { + result.push_back(id); + } + } + } + } + result + } + + /// @notice Return all invoice IDs whose dispute status matches `dispute_status`. + /// + /// @dev Passing `DisputeStatus::None` always returns an empty list because + /// invoices are only added to the index when a dispute is created. + /// + /// @param dispute_status The status to filter by. + /// @return Vec of matching invoice IDs. + pub fn get_invoices_by_dispute_status( + env: Env, + dispute_status: invoice::DisputeStatus, + ) -> Vec> { + let mut result = Vec::new(&env); + for status in [ + InvoiceStatus::Pending, + InvoiceStatus::Verified, + InvoiceStatus::Funded, + InvoiceStatus::Paid, + InvoiceStatus::Defaulted, + InvoiceStatus::Cancelled, + InvoiceStatus::Refunded, + ] { + for id in InvoiceStorage::get_invoices_by_status(&env, &status).iter() { + if let Some(inv) = InvoiceStorage::get_invoice(&env, &id) { + if inv.dispute_status == dispute_status { + result.push_back(id); + } + } + } + } + result + } + + // ========================================================================= + // Audit + // ========================================================================= + + pub fn get_invoice_audit_trail(env: Env, invoice_id: BytesN<32>) -> Vec> { + audit::AuditStorage::get_invoice_audit_trail(&env, &invoice_id) + } + + pub fn get_audit_entry(env: Env, audit_id: BytesN<32>) -> Option { + audit::AuditStorage::get_audit_entry(&env, &audit_id) + } + + pub fn query_audit_logs( + env: Env, + filter: audit::AuditQueryFilter, + limit: u32, + ) -> Vec { + audit::AuditStorage::query_audit_logs(&env, &filter, limit) + } + + pub fn get_audit_stats(env: Env) -> audit::AuditStats { + audit::AuditStorage::get_audit_stats(&env) + } + + pub fn validate_invoice_audit_integrity( + env: Env, + invoice_id: BytesN<32>, + ) -> Result { + audit::AuditStorage::validate_invoice_audit_integrity(&env, &invoice_id) + } + + // ========================================================================= + // Notifications + // ========================================================================= + + pub fn get_notification( + env: Env, + notification_id: BytesN<32>, + ) -> Option { + notifications::NotificationSystem::get_notification(&env, ¬ification_id) + } + + pub fn get_user_notifications(env: Env, user: Address) -> Vec> { + notifications::NotificationSystem::get_user_notifications(&env, &user) + } + + pub fn get_notification_preferences( + env: Env, + user: Address, + ) -> notifications::NotificationPreferences { + notifications::NotificationSystem::get_user_preferences(&env, &user) + } + + pub fn update_notification_preferences( + env: Env, + user: Address, + preferences: notifications::NotificationPreferences, + ) { + user.require_auth(); + notifications::NotificationSystem::update_user_preferences(&env, &user, preferences); + } + + pub fn update_notification_status( + env: Env, + notification_id: BytesN<32>, + status: notifications::NotificationDeliveryStatus, + ) -> Result<(), QuickLendXError> { + notifications::NotificationSystem::update_notification_status( + &env, + ¬ification_id, + status, + ) + } + + pub fn get_user_notification_stats( + env: Env, + user: Address, + ) -> notifications::NotificationStats { + notifications::NotificationSystem::get_user_notification_stats(&env, &user) + } + + /// Retrieve a stored investor report by ID + pub fn get_investor_report( + env: Env, + report_id: BytesN<32>, + ) -> Option { + analytics::AnalyticsStorage::get_investor_report(&env, &report_id) + } + + /// Get a summary of platform and performance metrics + pub fn get_analytics_summary( + env: Env, + ) -> (analytics::PlatformMetrics, analytics::PerformanceMetrics) { + let platform = analytics::AnalyticsCalculator::calculate_platform_metrics(&env).unwrap_or( + analytics::PlatformMetrics { + total_invoices: 0, + total_investments: 0, + total_volume: 0, + total_fees_collected: 0, + active_investors: 0, + verified_businesses: 0, + average_invoice_amount: 0, + average_investment_amount: 0, + platform_fee_rate: 0, + default_rate: 0, + success_rate: 0, + timestamp: env.ledger().timestamp(), + }, + ); + let performance = analytics::AnalyticsCalculator::calculate_performance_metrics(&env) + .unwrap_or(analytics::PerformanceMetrics { + platform_uptime: env.ledger().timestamp(), + average_settlement_time: 0, + average_verification_time: 0, + dispute_resolution_time: 0, + system_response_time: 0, + transaction_success_rate: 0, + error_rate: 0, + user_satisfaction_score: 0, + platform_efficiency: 0, + }); + (platform, performance) + } +} +======= /// QuickLendX Smart Contract Library /// /// This crate contains the core arithmetic modules for the QuickLendX @@ -34,3 +2972,4 @@ mod test_business_kyc; #[cfg(test)] mod test_investor_kyc; +>>>>>>> main diff --git a/quicklendx-contracts/src/test_dispute.rs b/quicklendx-contracts/src/test_dispute.rs index 292bfabf..aeeb0179 100644 --- a/quicklendx-contracts/src/test_dispute.rs +++ b/quicklendx-contracts/src/test_dispute.rs @@ -864,4 +864,459 @@ mod test_dispute { assert_eq!(dispute.resolution, resolution); assert_eq!(dispute.resolved_by, admin); } + + // ----------------------------------------------------------------------- + // Regression Tests — Dispute Locking + // ----------------------------------------------------------------------- + + /// [TC-30] REGRESSION: Resolved dispute cannot be overwritten by a second + /// `resolve_dispute` call. + /// + /// # Security Note + /// This is the core locking invariant. The `Resolved` state is terminal; + /// any attempt to call `resolve_dispute` again must return + /// `DisputeNotUnderReview` because the status is no longer `UnderReview`. + #[test] + fn test_regression_resolved_dispute_is_locked() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + + client.create_dispute( + &invoice_id, + &business, + &String::from_str(&env, "Original reason"), + &String::from_str(&env, "Original evidence"), + ); + client.put_dispute_under_review(&invoice_id, &admin); + client.resolve_dispute( + &invoice_id, + &admin, + &String::from_str(&env, "Original resolution"), + ); + + // Verify terminal state + assert_eq!( + client.get_invoice(&invoice_id).dispute_status, + DisputeStatus::Resolved + ); + + // Attempt overwrite — must fail + let overwrite = client.try_resolve_dispute( + &invoice_id, + &admin, + &String::from_str(&env, "Overwrite attempt"), + ); + assert!(overwrite.is_err(), "Resolved dispute must be locked"); + let err = overwrite.unwrap_err().expect("expected contract error"); + assert_eq!( + err, + QuickLendXError::DisputeNotUnderReview, + "Overwrite must return DisputeNotUnderReview" + ); + + // Original resolution must be unchanged + let dispute = client + .get_dispute_details(&invoice_id) + .expect("Dispute must still exist"); + assert_eq!( + dispute.resolution, + String::from_str(&env, "Original resolution"), + "Resolution must not be overwritten" + ); + } + + /// [TC-31] REGRESSION: Resolved dispute cannot be re-opened via + /// `put_dispute_under_review`. + /// + /// # Security Note + /// Prevents an admin from cycling a resolved dispute back to `UnderReview` + /// and then issuing a different resolution. + #[test] + fn test_regression_resolved_dispute_cannot_reopen() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + + client.create_dispute( + &invoice_id, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, "evidence"), + ); + client.put_dispute_under_review(&invoice_id, &admin); + client.resolve_dispute( + &invoice_id, + &admin, + &String::from_str(&env, "Final resolution"), + ); + + // Attempt to re-open — must fail + let reopen = client.try_put_dispute_under_review(&invoice_id, &admin); + assert!(reopen.is_err(), "Resolved dispute must not be re-opened"); + let err = reopen.unwrap_err().expect("expected contract error"); + assert_eq!( + err, + QuickLendXError::InvalidStatus, + "Re-opening a Resolved dispute must return InvalidStatus" + ); + } + + /// [TC-32] REGRESSION: `resolved_at` timestamp is set exactly once and + /// is never zero after resolution. + #[test] + fn test_regression_resolved_at_is_set_once() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + + client.create_dispute( + &invoice_id, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, "evidence"), + ); + + // Before resolution: resolved_at must be 0 + let before = client + .get_dispute_details(&invoice_id) + .expect("Dispute must exist"); + assert_eq!(before.resolved_at, 0, "resolved_at must be 0 before resolution"); + + client.put_dispute_under_review(&invoice_id, &admin); + client.resolve_dispute( + &invoice_id, + &admin, + &String::from_str(&env, "Resolution text"), + ); + + // After resolution: resolved_at must be non-zero + let after = client + .get_dispute_details(&invoice_id) + .expect("Dispute must exist"); + assert!(after.resolved_at > 0, "resolved_at must be set after resolution"); + assert_eq!(after.resolved_by, admin, "resolved_by must be the admin"); + } + + /// [TC-33] REGRESSION: `resolve_dispute` on a `Disputed` (not yet under + /// review) invoice must return `DisputeNotUnderReview`, not silently succeed. + /// + /// # Security Note + /// Prevents skipping the mandatory review step. + #[test] + fn test_regression_cannot_skip_review_step() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + + client.create_dispute( + &invoice_id, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, "evidence"), + ); + + // Status is Disputed — resolve must fail + let result = client.try_resolve_dispute( + &invoice_id, + &admin, + &String::from_str(&env, "Skipped review"), + ); + assert!(result.is_err()); + let err = result.unwrap_err().expect("expected contract error"); + assert_eq!(err, QuickLendXError::DisputeNotUnderReview); + + // Status must remain Disputed + assert_eq!( + client.get_invoice(&invoice_id).dispute_status, + DisputeStatus::Disputed, + "Status must not change after failed resolve" + ); + } + + /// [TC-34] REGRESSION: Non-admin cannot resolve a dispute even if they + /// know the invoice ID. + #[test] + fn test_regression_non_admin_cannot_resolve() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + let attacker = Address::generate(&env); + + client.create_dispute( + &invoice_id, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, "evidence"), + ); + client.put_dispute_under_review(&invoice_id, &admin); + + let result = client.try_resolve_dispute( + &invoice_id, + &attacker, + &String::from_str(&env, "Attacker resolution"), + ); + assert!(result.is_err(), "Non-admin must not resolve a dispute"); + } + + /// [TC-35] REGRESSION: Non-admin cannot advance a dispute to `UnderReview`. + #[test] + fn test_regression_non_admin_cannot_put_under_review() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + let attacker = Address::generate(&env); + + client.create_dispute( + &invoice_id, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, "evidence"), + ); + + let result = client.try_put_dispute_under_review(&invoice_id, &attacker); + assert!(result.is_err(), "Non-admin must not advance dispute"); + } + + /// [TC-36] REGRESSION: `put_dispute_under_review` on an invoice with no + /// dispute must return `DisputeNotFound`. + #[test] + fn test_regression_review_no_dispute_returns_not_found() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + + let result = client.try_put_dispute_under_review(&invoice_id, &admin); + assert!(result.is_err()); + let err = result.unwrap_err().expect("expected contract error"); + assert_eq!(err, QuickLendXError::DisputeNotFound); + } + + /// [TC-37] REGRESSION: `resolve_dispute` on an invoice with no dispute + /// must return `DisputeNotFound`. + #[test] + fn test_regression_resolve_no_dispute_returns_not_found() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + + let result = client.try_resolve_dispute( + &invoice_id, + &admin, + &String::from_str(&env, "resolution"), + ); + assert!(result.is_err()); + let err = result.unwrap_err().expect("expected contract error"); + assert_eq!(err, QuickLendXError::DisputeNotFound); + } + + /// [TC-38] REGRESSION: Double-resolution attempt preserves the original + /// `resolved_by` and `resolved_at` fields unchanged. + #[test] + fn test_regression_double_resolution_preserves_original_fields() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + + client.create_dispute( + &invoice_id, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, "evidence"), + ); + client.put_dispute_under_review(&invoice_id, &admin); + client.resolve_dispute( + &invoice_id, + &admin, + &String::from_str(&env, "First resolution"), + ); + + let first = client + .get_dispute_details(&invoice_id) + .expect("Dispute must exist"); + + // Second attempt must fail + let _ = client.try_resolve_dispute( + &invoice_id, + &admin, + &String::from_str(&env, "Second resolution"), + ); + + // Fields must be identical to the first resolution + let after = client + .get_dispute_details(&invoice_id) + .expect("Dispute must exist"); + assert_eq!(after.resolution, first.resolution); + assert_eq!(after.resolved_by, first.resolved_by); + assert_eq!(after.resolved_at, first.resolved_at); + } + + /// [TC-39] REGRESSION: Invalid dispute ID (non-existent invoice) must + /// return `InvoiceNotFound` for all dispute operations. + #[test] + fn test_regression_invalid_invoice_id_all_operations() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let fake_id = BytesN::from_array(&env, &[0xFFu8; 32]); + + let create_result = client.try_create_dispute( + &fake_id, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, "evidence"), + ); + assert!(create_result.is_err()); + assert_eq!( + create_result.unwrap_err().expect("expected contract error"), + QuickLendXError::InvoiceNotFound + ); + + let review_result = client.try_put_dispute_under_review(&fake_id, &admin); + assert!(review_result.is_err()); + assert_eq!( + review_result.unwrap_err().expect("expected contract error"), + QuickLendXError::InvoiceNotFound + ); + + let resolve_result = client.try_resolve_dispute( + &fake_id, + &admin, + &String::from_str(&env, "resolution"), + ); + assert!(resolve_result.is_err()); + assert_eq!( + resolve_result.unwrap_err().expect("expected contract error"), + QuickLendXError::InvoiceNotFound + ); + } + + /// [TC-40] REGRESSION: Evidence boundary — exactly 2000 chars must succeed; + /// 2001 chars must fail. + #[test] + fn test_regression_evidence_boundary_values() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + + // 2000 chars — must succeed + let id1 = create_test_invoice(&env, &client, &business, 100_000); + let max_evidence = "e".repeat(2000); + let ok = client.try_create_dispute( + &id1, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, max_evidence.as_str()), + ); + assert!(ok.is_ok(), "2000-char evidence must be accepted"); + + // 2001 chars — must fail + let id2 = create_test_invoice(&env, &client, &business, 110_000); + let over_evidence = "e".repeat(2001); + let err = client.try_create_dispute( + &id2, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, over_evidence.as_str()), + ); + assert!(err.is_err()); + assert_eq!( + err.unwrap_err().expect("expected contract error"), + QuickLendXError::InvalidDisputeEvidence + ); + } + + /// [TC-41] REGRESSION: Resolution boundary — exactly 2000 chars must + /// succeed; 2001 chars must fail. + #[test] + fn test_regression_resolution_boundary_values() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + + // 2000 chars — must succeed + let id1 = create_test_invoice(&env, &client, &business, 100_000); + client.create_dispute( + &id1, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, "evidence"), + ); + client.put_dispute_under_review(&id1, &admin); + let max_resolution = "r".repeat(2000); + let ok = client.try_resolve_dispute( + &id1, + &admin, + &String::from_str(&env, max_resolution.as_str()), + ); + assert!(ok.is_ok(), "2000-char resolution must be accepted"); + + // 2001 chars — must fail + let id2 = create_test_invoice(&env, &client, &business, 110_000); + client.create_dispute( + &id2, + &business, + &String::from_str(&env, "reason"), + &String::from_str(&env, "evidence"), + ); + client.put_dispute_under_review(&id2, &admin); + let over_resolution = "r".repeat(2001); + let err = client.try_resolve_dispute( + &id2, + &admin, + &String::from_str(&env, over_resolution.as_str()), + ); + assert!(err.is_err()); + assert_eq!( + err.unwrap_err().expect("expected contract error"), + QuickLendXError::InvalidDisputeReason + ); + } + + /// [TC-42] REGRESSION: `get_dispute_details` returns `None` for an invoice + /// that exists but has no dispute. + #[test] + fn test_regression_get_details_no_dispute_returns_none() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let invoice_id = create_test_invoice(&env, &client, &business, 100_000); + + let result = client.get_dispute_details(&invoice_id); + assert!( + result.is_none(), + "get_dispute_details must return None when no dispute exists" + ); + } + + /// [TC-43] REGRESSION: Dispute state is isolated per invoice — resolving + /// one does not affect another. + #[test] + fn test_regression_resolution_isolation_across_invoices() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + + let id1 = create_test_invoice(&env, &client, &business, 100_000); + let id2 = create_test_invoice(&env, &client, &business, 200_000); + + let reason = String::from_str(&env, "reason"); + let evidence = String::from_str(&env, "evidence"); + client.create_dispute(&id1, &business, &reason, &evidence); + client.create_dispute(&id2, &business, &reason, &evidence); + + // Fully resolve id1 + client.put_dispute_under_review(&id1, &admin); + client.resolve_dispute(&id1, &admin, &String::from_str(&env, "Resolved id1")); + + // id2 must still be Disputed + assert_eq!( + client.get_invoice(&id2).dispute_status, + DisputeStatus::Disputed, + "id2 must remain Disputed after id1 is resolved" + ); + + // id2 must still be resolvable through the normal path + client.put_dispute_under_review(&id2, &admin); + client.resolve_dispute(&id2, &admin, &String::from_str(&env, "Resolved id2")); + assert_eq!( + client.get_invoice(&id2).dispute_status, + DisputeStatus::Resolved + ); + } }