Skip to content

Commit 0392921

Browse files
authored
Merge pull request #751 from Chinonso-Peter/feature/admin-invoice-status-pathway
feat: #715 Admin update_invoice_status pathway and event coverag
2 parents 20f7d16 + daf942f commit 0392921

File tree

12 files changed

+894
-289
lines changed

12 files changed

+894
-289
lines changed

.codex

Whitespace-only changes.

docs/contracts/invoice-lifecycle.md

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Invoice Lifecycle Management
22

3-
This document describes the invoice lifecycle management functionality in the QuickLendX protocol, including invoice upload, verification/approval, and cancellation.
3+
This document describes the invoice lifecycle management functionality in the QuickLendX protocol, including invoice upload, verification/approval, and cancellation.
4+
5+
> Note
6+
> `update_invoice_status` is an admin-only recovery/backfill pathway. It is not a
7+
> replacement for the normal `accept_bid`, `settle_invoice`, or overdue-default
8+
> flows, and it intentionally avoids escrow and payment side effects.
49
510
## Overview
611

@@ -84,7 +89,7 @@ Allows an admin or oracle to verify an uploaded invoice, making it available for
8489

8590
---
8691

87-
### 3. `cancel_invoice`
92+
### 3. `cancel_invoice`
8893

8994
Allows a business to cancel their own invoice before it has been funded by an investor.
9095

@@ -111,10 +116,58 @@ Allows a business to cancel their own invoice before it has been funded by an in
111116
**Failure Cases**:
112117
- `InvoiceNotFound` - Invoice does not exist
113118
- `Unauthorized` - Caller is not the business owner
114-
- `InvalidStatus` - Invoice is already funded, paid, defaulted, or cancelled
115-
116-
---
117-
### 4. `refund_escrow_funds`
119+
- `InvalidStatus` - Invoice is already funded, paid, defaulted, or cancelled
120+
121+
---
122+
### 4. `update_invoice_status`
123+
124+
Allows the configured admin to move an invoice through a limited recovery path
125+
when tests, migrations, or operational repair require a manual state correction.
126+
127+
**Authorization**: Admin only (requires authentication)
128+
129+
**Parameters**:
130+
- `env: Env` - Contract environment
131+
- `invoice_id: BytesN<32>` - ID of the invoice to update
132+
- `new_status: InvoiceStatus` - Target lifecycle status
133+
134+
**Returns**: `Result<(), QuickLendXError>` - Success or error
135+
136+
**Supported transitions**:
137+
- `Pending``Verified`
138+
- `Verified``Funded`
139+
- `Funded``Paid`
140+
- `Funded``Defaulted`
141+
142+
**Unsupported transitions**:
143+
- Any transition targeting `Pending`, `Cancelled`, or `Refunded`
144+
- Any transition from terminal invoices (`Cancelled`, `Refunded`)
145+
- Any transition that skips the supported recovery path, such as `Verified``Paid`
146+
147+
**Index updates**:
148+
- Removes the invoice ID from the previous status bucket before persisting
149+
- Adds the invoice ID to the new status bucket after persisting
150+
- Keeps `get_invoices_by_status` and `get_invoice_count_by_status` aligned
151+
152+
**Events Emitted**:
153+
- `inv_ver` when moving to `Verified`
154+
- `inv_fnd` when moving to `Funded`
155+
- `inv_set` when moving to `Paid` through the admin override path
156+
- `inv_def` when moving to `Defaulted`
157+
158+
**Security Notes**:
159+
- The function requires the stored admin address and fails with `NotAdmin` if none is configured
160+
- Manual `Paid` updates emit the canonical settlement event with zeroed settlement values because no payment transfer is executed by this pathway
161+
- Manual `Funded` updates are bookkeeping-only and do not create escrow or investment records
162+
- Production flows should prefer `verify_invoice`, `accept_bid`, `settle_invoice`, and `mark_invoice_defaulted`
163+
164+
**Failure Cases**:
165+
- `NotAdmin` - No admin configured
166+
- `InvoiceNotFound` - Invoice does not exist
167+
- `InvalidStatus` - Unsupported target status or invalid transition
168+
169+
---
170+
### 5. `refund_escrow_funds`
118171

119172
Allows an admin or the business owner to refund a funded invoice, returning funds to the investor.
120173

@@ -159,10 +212,11 @@ Allows an admin or the business owner to refund a funded invoice, returning fund
159212
- Can update invoice metadata
160213
- Can update invoice category and tags
161214

162-
### Admin/Oracle
163-
- Can verify invoices
164-
- Can reject verification
165-
- Can set admin address
215+
### Admin/Oracle
216+
- Can verify invoices
217+
- Can run the constrained `update_invoice_status` recovery path
218+
- Can reject verification
219+
- Can set admin address
166220

167221
### Investor
168222
- Cannot directly interact with invoice lifecycle (can only bid on verified invoices)
@@ -237,7 +291,10 @@ cancel_invoice(env, invoice_id)?;
237291

238292
## Security Considerations
239293

240-
1. **Authentication**: All state-changing operations require proper authentication
294+
1. **Authentication**: All state-changing operations require proper authentication
295+
2. **Recovery pathway isolation**: `update_invoice_status` is admin-only and does not move funds
296+
3. **Index consistency**: Status-list removals/additions happen in the same override operation
297+
4. **Canonical events**: Admin overrides emit the same lifecycle topics used by normal flows so indexers do not need a separate schema
241298
- `upload_invoice`: Business must authenticate
242299
- `verify_invoice`: Admin must authenticate
243300
- `cancel_invoice`: Business owner must authenticate

quicklendx-contracts/src/currency.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ impl CurrencyWhitelist {
1717
admin: &Address,
1818
currency: &Address,
1919
) -> Result<(), QuickLendXError> {
20-
AdminStorage::require_admin_auth(env, admin)?;
20+
AdminStorage::require_admin(env, admin)?;
2121

2222
let mut list = Self::get_whitelisted_currencies(env);
2323
if list.iter().any(|a| a == *currency) {
@@ -86,7 +86,7 @@ impl CurrencyWhitelist {
8686
admin: &Address,
8787
currencies: &Vec<Address>,
8888
) -> Result<(), QuickLendXError> {
89-
AdminStorage::require_admin_auth(env, admin)?;
89+
AdminStorage::require_admin(env, admin)?;
9090

9191
let mut deduped: Vec<Address> = Vec::new(env);
9292
for currency in currencies.iter() {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,171 @@
1+
use crate::admin::AdminStorage;
2+
use crate::errors::QuickLendXError;
3+
use crate::invoice::{Dispute, DisputeStatus, InvoiceStatus, InvoiceStorage};
4+
use crate::protocol_limits::{
5+
MAX_DISPUTE_EVIDENCE_LENGTH, MAX_DISPUTE_REASON_LENGTH, MAX_DISPUTE_RESOLUTION_LENGTH,
6+
};
7+
use soroban_sdk::{symbol_short, Address, BytesN, Env, String, Vec};
8+
9+
fn dispute_index_key() -> soroban_sdk::Symbol {
10+
symbol_short!("dispute")
11+
}
12+
13+
fn get_dispute_index(env: &Env) -> Vec<BytesN<32>> {
14+
env.storage()
15+
.instance()
16+
.get(&dispute_index_key())
17+
.unwrap_or_else(|| Vec::new(env))
18+
}
19+
20+
fn add_to_dispute_index(env: &Env, invoice_id: &BytesN<32>) {
21+
let mut ids = get_dispute_index(env);
22+
if !ids.iter().any(|id| id == *invoice_id) {
23+
ids.push_back(invoice_id.clone());
24+
env.storage().instance().set(&dispute_index_key(), &ids);
25+
}
26+
}
27+
28+
fn zero_address(env: &Env) -> Address {
29+
Address::from_str(env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")
30+
}
31+
32+
#[allow(dead_code)]
33+
pub fn create_dispute(
34+
env: &Env,
35+
invoice_id: &BytesN<32>,
36+
creator: &Address,
37+
reason: &String,
38+
evidence: &String,
39+
) -> Result<(), QuickLendXError> {
40+
creator.require_auth();
41+
42+
let mut invoice =
43+
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;
44+
45+
if invoice.dispute_status != DisputeStatus::None {
46+
return Err(QuickLendXError::DisputeAlreadyExists);
47+
}
48+
49+
match invoice.status {
50+
InvoiceStatus::Pending
51+
| InvoiceStatus::Verified
52+
| InvoiceStatus::Funded
53+
| InvoiceStatus::Paid => {}
54+
_ => return Err(QuickLendXError::InvalidStatus),
55+
}
56+
57+
let is_business = *creator == invoice.business;
58+
let is_investor = invoice
59+
.investor
60+
.as_ref()
61+
.map_or(false, |investor| *creator == *investor);
62+
if !is_business && !is_investor {
63+
return Err(QuickLendXError::DisputeNotAuthorized);
64+
}
65+
66+
if reason.len() == 0 || reason.len() > MAX_DISPUTE_REASON_LENGTH {
67+
return Err(QuickLendXError::InvalidDisputeReason);
68+
}
69+
if evidence.len() == 0 || evidence.len() > MAX_DISPUTE_EVIDENCE_LENGTH {
70+
return Err(QuickLendXError::InvalidDisputeEvidence);
71+
}
72+
73+
invoice.dispute_status = DisputeStatus::Disputed;
74+
invoice.dispute = Dispute {
75+
created_by: creator.clone(),
76+
created_at: env.ledger().timestamp(),
77+
reason: reason.clone(),
78+
evidence: evidence.clone(),
79+
resolution: String::from_str(env, ""),
80+
resolved_by: zero_address(env),
81+
resolved_at: 0,
82+
};
83+
84+
InvoiceStorage::update_invoice(env, &invoice);
85+
add_to_dispute_index(env, invoice_id);
86+
Ok(())
87+
}
88+
89+
#[allow(dead_code)]
90+
pub fn put_dispute_under_review(
91+
env: &Env,
92+
admin: &Address,
93+
invoice_id: &BytesN<32>,
94+
) -> Result<(), QuickLendXError> {
95+
AdminStorage::require_admin(env, admin)?;
96+
let mut invoice =
97+
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;
98+
99+
if invoice.dispute_status == DisputeStatus::None {
100+
return Err(QuickLendXError::DisputeNotFound);
101+
}
102+
if invoice.dispute_status != DisputeStatus::Disputed {
103+
return Err(QuickLendXError::InvalidStatus);
104+
}
105+
106+
invoice.dispute_status = DisputeStatus::UnderReview;
107+
InvoiceStorage::update_invoice(env, &invoice);
108+
Ok(())
109+
}
110+
111+
#[allow(dead_code)]
112+
pub fn resolve_dispute(
113+
env: &Env,
114+
admin: &Address,
115+
invoice_id: &BytesN<32>,
116+
resolution: &String,
117+
) -> Result<(), QuickLendXError> {
118+
AdminStorage::require_admin(env, admin)?;
119+
let mut invoice =
120+
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;
121+
122+
if invoice.dispute_status == DisputeStatus::None {
123+
return Err(QuickLendXError::DisputeNotFound);
124+
}
125+
if invoice.dispute_status != DisputeStatus::UnderReview {
126+
return Err(QuickLendXError::DisputeNotUnderReview);
127+
}
128+
if resolution.len() == 0 || resolution.len() > MAX_DISPUTE_RESOLUTION_LENGTH {
129+
return Err(QuickLendXError::InvalidDisputeReason);
130+
}
131+
132+
invoice.dispute_status = DisputeStatus::Resolved;
133+
invoice.dispute.resolution = resolution.clone();
134+
invoice.dispute.resolved_by = admin.clone();
135+
invoice.dispute.resolved_at = env.ledger().timestamp();
136+
InvoiceStorage::update_invoice(env, &invoice);
137+
Ok(())
138+
}
139+
140+
#[allow(dead_code)]
141+
pub fn get_dispute_details(env: &Env, invoice_id: &BytesN<32>) -> Option<Dispute> {
142+
let invoice = InvoiceStorage::get_invoice(env, invoice_id)?;
143+
if invoice.dispute_status == DisputeStatus::None {
144+
None
145+
} else {
146+
Some(invoice.dispute)
147+
}
148+
}
149+
150+
#[allow(dead_code)]
151+
pub fn get_invoices_with_disputes(env: &Env) -> Vec<BytesN<32>> {
152+
get_dispute_index(env)
153+
}
154+
155+
#[allow(dead_code)]
156+
pub fn get_invoices_by_dispute_status(
157+
env: &Env,
158+
status: &DisputeStatus,
159+
) -> Vec<BytesN<32>> {
160+
let mut result = Vec::new(env);
161+
for invoice_id in get_dispute_index(env).iter() {
162+
if let Some(invoice) = InvoiceStorage::get_invoice(env, &invoice_id) {
163+
if invoice.dispute_status == *status {
164+
result.push_back(invoice_id);
165+
}
166+
}
167+
}
168+
result
169+
}
1170
//! Invoice disputes are represented on [`crate::invoice::Invoice`] and handled by contract
2171
//! entry points in `lib.rs`. This module is reserved for future dispute-specific helpers.

quicklendx-contracts/src/emergency.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ impl EmergencyWithdraw {
102102
amount: i128,
103103
target: Address,
104104
) -> Result<(), QuickLendXError> {
105-
AdminStorage::require_admin_auth(env, admin)?;
105+
AdminStorage::require_admin(env, admin)?;
106106

107107
if amount <= 0 {
108108
return Err(QuickLendXError::InvalidAmount);
@@ -173,7 +173,7 @@ impl EmergencyWithdraw {
173173
/// * `EmergencyWithdrawCancelled` if withdrawal was cancelled
174174
/// * Transfer errors (e.g. `InsufficientFunds`) if contract balance is insufficient
175175
pub fn execute(env: &Env, admin: &Address) -> Result<(), QuickLendXError> {
176-
AdminStorage::require_admin_auth(env, admin)?;
176+
AdminStorage::require_admin(env, admin)?;
177177

178178
let pending: PendingEmergencyWithdrawal = env
179179
.storage()
@@ -243,7 +243,7 @@ impl EmergencyWithdraw {
243243
/// * `EmergencyWithdrawNotFound` if no pending withdrawal exists
244244
/// * `EmergencyWithdrawCancelled` if withdrawal is already cancelled
245245
pub fn cancel(env: &Env, admin: &Address) -> Result<(), QuickLendXError> {
246-
AdminStorage::require_admin_auth(env, admin)?;
246+
AdminStorage::require_admin(env, admin)?;
247247

248248
let mut pending: PendingEmergencyWithdrawal = env
249249
.storage()

0 commit comments

Comments
 (0)