This document describes the invoice lifecycle management functionality in the QuickLendX protocol, including invoice upload, verification/approval, and cancellation.
Note
update_invoice_statusis an admin-only recovery/backfill pathway. It is not a replacement for the normalaccept_bid,settle_invoice, or overdue-default flows, and it intentionally avoids escrow and payment side effects.
The invoice lifecycle consists of the following states:
- Pending - Invoice uploaded by business, awaiting verification
- Verified - Invoice verified by admin/oracle and available for bidding
- Funded - Invoice has been funded by an investor
- Paid - Invoice has been paid and settled
- Defaulted - Invoice payment is overdue/defaulted
- Cancelled - Invoice has been cancelled by the business owner
- Refunded - Invoice funds have been returned to the investor and the invoice is closed
Allows a verified business to upload an invoice to the platform.
Authorization: Business owner only (requires authentication)
Parameters:
env: Env- Contract environmentbusiness: Address- Address of the business uploading the invoiceamount: i128- Total invoice amount (must be > 0)currency: Address- Currency token addressdue_date: u64- Due date timestamp (must be in the future)description: String- Invoice description (cannot be empty)category: InvoiceCategory- Invoice categorytags: Vec<String>- Invoice tags for discoverabilityorigination_fee_bps: Option<u32>- Optional origination fee rate to charge the investor at fund-time
Returns: Result<BytesN<32>, QuickLendXError> - Invoice ID on success
Validations:
- Business must be verified
- Amount must be greater than 0
- Due date must be in the future (after current timestamp)
- Description cannot be empty
- Category must be valid
- Tags must be valid (max 10 tags, 1-50 characters each)
Events Emitted:
inv_up(invoice_uploaded) - Contains invoice ID, business address, amount, currency, and due date
Failure Cases:
BusinessNotVerified- Business is not verifiedInvalidAmount- Amount is <= 0InvoiceDueDateInvalid- Due date is not in the futureInvalidDescription- Description is emptyInvalidTag- Invalid tag format or limit exceeded
Allows an admin or oracle to verify an uploaded invoice, making it available for investors to bid on.
Authorization: Admin only (requires authentication)
Parameters:
env: Env- Contract environmentinvoice_id: BytesN<32>- ID of the invoice to verify
Returns: Result<(), QuickLendXError> - Success or error
Validations:
- Caller must be an admin
- Invoice must exist
- Invoice status must be
Pending
State Transitions:
Pending→Verified
Events Emitted:
inv_ver(invoice_verified) - Contains invoice ID and business address
Failure Cases:
NotAdmin- Caller is not an adminInvoiceNotFound- Invoice does not existInvalidStatus- Invoice is not in Pending status
Allows a business to cancel their own invoice before it has been funded by an investor.
Authorization: Business owner only (requires authentication)
Parameters:
env: Env- Contract environmentinvoice_id: BytesN<32>- ID of the invoice to cancel
Returns: Result<(), QuickLendXError> - Success or error
Validations:
- Caller must be the business owner of the invoice
- Invoice must exist
- Invoice status must be either
PendingorVerified(cannot cancel if already funded)
State Transitions:
Pending→CancelledVerified→Cancelled
Events Emitted:
inv_canc(invoice_cancelled) - Contains invoice ID, business address, and timestamp
Failure Cases:
InvoiceNotFound- Invoice does not existUnauthorized- Caller is not the business ownerInvalidStatus- Invoice is already funded, paid, defaulted, or cancelled
Allows the configured admin to move an invoice through a limited recovery path when tests, migrations, or operational repair require a manual state correction.
Authorization: Admin only (requires authentication)
Parameters:
env: Env- Contract environmentinvoice_id: BytesN<32>- ID of the invoice to updatenew_status: InvoiceStatus- Target lifecycle status
Returns: Result<(), QuickLendXError> - Success or error
Supported transitions:
Pending→VerifiedVerified→FundedFunded→PaidFunded→Defaulted
Unsupported transitions:
- Any transition targeting
Pending,Cancelled, orRefunded - Any transition from terminal invoices (
Cancelled,Refunded) - Any transition that skips the supported recovery path, such as
Verified→Paid
Index updates:
- Removes the invoice ID from the previous status bucket before persisting
- Adds the invoice ID to the new status bucket after persisting
- Keeps
get_invoices_by_statusandget_invoice_count_by_statusaligned
Events Emitted:
inv_verwhen moving toVerifiedinv_fndwhen moving toFundedinv_setwhen moving toPaidthrough the admin override pathinv_defwhen moving toDefaulted
Security Notes:
- The function requires the stored admin address and fails with
NotAdminif none is configured - Manual
Paidupdates emit the canonical settlement event with zeroed settlement values because no payment transfer is executed by this pathway - Manual
Fundedupdates are bookkeeping-only and do not create escrow or investment records - Production flows should prefer
verify_invoice,accept_bid,settle_invoice, andmark_invoice_defaulted
Failure Cases:
NotAdmin- No admin configuredInvoiceNotFound- Invoice does not existInvalidStatus- Unsupported target status or invalid transition
Allows an admin or the business owner to refund a funded invoice, returning funds to the investor.
Authorization: Admin or Business owner (requires authentication)
Parameters:
env: Env- Contract environmentinvoice_id: BytesN<32>- ID of the invoice to refundcaller: Address- Address of the party initiating the refund
Returns: Result<(), QuickLendXError> - Success or error
Validations:
- Caller must be an admin or the business owner
- Invoice must be in
Fundedstatus
State Transitions:
Funded→Refunded
Related Updates:
- Bid status →
Cancelled - Investment status →
Refunded - Escrow status →
Refunded
Events Emitted:
esc_ref(escrow_refunded) - Transferred funds back to investor- Audit logs for status change and refund
Failure Cases:
InvoiceNotFound- Invoice does not existUnauthorized- Caller is not authorized (Admin/Business)InvalidStatus- Invoice is not in Funded status
- Can upload invoices (if verified)
- Can cancel their own invoices (before funding)
- Can refund their own invoices (after funding, before release)
- Can update invoice metadata
- Can update invoice category and tags
- Can verify invoices
- Can run the constrained
update_invoice_statusrecovery path - Can reject verification
- Can set admin address
- Cannot directly interact with invoice lifecycle (can only bid on verified invoices)
┌─────────┐
│ Pending │ ◄─── Business uploads invoice
└────┬────┘
│
│ Admin verifies
▼
┌──────────┐
│ Verified │ ◄─── Available for bidding
└────┬─────┘
│
│ Investor bids and business accepts
▼
┌─────────┐
│ Funded │ ◄─── Investor has funded the invoice
└────┬────┘
│
│ Business pays back
▼
┌──────┐
│ Paid │ ◄─── Invoice settled successfully
└──────┘
Alternative paths:
- Pending/Verified → Cancelled (business cancels)
- Funded → Defaulted (payment overdue beyond grace period)
- Funded → Refunded (admin or business refunds)
// Business uploads an invoice
let invoice_id = upload_invoice(
env,
business_address,
1000000, // amount in stroops
xlm_address,
due_date,
String::from_str(&env, "Payment for services"),
InvoiceCategory::Services,
tags
)?;// Admin verifies the invoice
verify_invoice(env, invoice_id)?;
// Invoice is now available for bidding// Business can cancel before funding
cancel_invoice(env, invoice_id)?;
// Invoice is now cancelled and unavailable for bidding-
Authentication: All state-changing operations require proper authentication
-
Recovery pathway isolation:
update_invoice_statusis admin-only and does not move funds -
Index consistency: Status-list removals/additions happen in the same override operation
-
Canonical events: Admin overrides emit the same lifecycle topics used by normal flows so indexers do not need a separate schema
upload_invoice: Business must authenticateverify_invoice: Admin must authenticatecancel_invoice: Business owner must authenticate
-
Authorization: Functions check that the caller has the appropriate role
- Only verified businesses can upload invoices
- Only admins can verify invoices
- Only the business owner can cancel their own invoices
-
State Validation: All transitions validate the current state
- Verification only works on Pending invoices
- Cancellation only works on Pending or Verified invoices
- Prevents invalid state transitions
-
Input Validation: All inputs are validated
- Amount must be positive
- Due date must be in the future
- Description cannot be empty
- Currency address must be valid
-
Audit Logging: All state changes are logged via the audit system
- Invoice creation is logged
- Status changes are logged with actor address
- Audit trail enables accountability and dispute resolution
All invoice lifecycle events can be monitored by off-chain systems:
| Event Symbol | Event Name | Data |
|---|---|---|
inv_up |
invoice_uploaded | (invoice_id, business, amount, currency, due_date) |
inv_ver |
invoice_verified | (invoice_id, business) |
inv_canc |
invoice_cancelled | (invoice_id, business, timestamp) |
| Error | Code | Description |
|---|---|---|
InvoiceNotFound |
1000 | Invoice does not exist |
InvalidAmount |
1200 | Amount is invalid (e.g., <= 0) |
InvoiceDueDateInvalid |
1005 | Due date is not in the future |
InvalidDescription |
1204 | Description is empty |
InvalidStatus |
1401 | Operation not allowed in current status |
BusinessNotVerified |
1600 | Business is not verified |
NotAdmin |
1103 | Caller is not an admin |
Unauthorized |
1100 | Caller is not authorized |
InvalidTag |
1802 | Invalid tag format |
TagLimitExceeded |
1803 | Too many tags (max 10) |
Comprehensive tests should cover:
-
Happy Path:
- Upload invoice with valid parameters
- Verify invoice by admin
- Cancel invoice by business owner
-
Authorization:
- Non-business cannot upload invoice
- Non-admin cannot verify invoice
- Non-owner cannot cancel invoice
-
Validation:
- Negative amount rejected
- Past due date rejected
- Empty description rejected
- Invalid category rejected
-
State Transitions:
- Cannot verify non-pending invoice
- Cannot cancel funded invoice
- Cannot cancel already cancelled invoice
-
Edge Cases:
- Cancel immediately after upload
- Cancel after verification but before funding
- Attempt to cancel after funding (should fail)
Minimum test coverage: 95%
When an invoice transitions through its lifecycle, the associated investment's status must be updated atomically. This section documents the investment status state machine and its integration with settlement and default events.
| Status | Description |
|---|---|
Active |
Investment is live; funds are held in escrow |
Completed |
Invoice was fully settled; investor receives principal + yield |
Defaulted |
Invoice was not paid within the grace period |
Refunded |
Escrow was refunded before settlement |
Withdrawn |
Investor withdrew before the invoice was funded |
Only Active investments can transition. All other states are terminal.
Active ──► Completed (full settlement via settle_invoice)
Active ──► Defaulted (overdue via mark_invoice_defaulted)
Active ──► Refunded (escrow refund via refund_escrow_funds)
Active ──► Withdrawn (investor withdrawal before funding)
Completed ──► (terminal)
Defaulted ──► (terminal)
Refunded ──► (terminal)
Withdrawn ──► (terminal)
Any attempt to transition from a terminal state panics with QuickLendXError::InvalidStatus, preventing double-settle, double-default, or any backward transition.
A persistent act_inv index tracks all Active investment IDs:
- Added when
store_investmentis called (new investments always startActive) - Removed atomically when
update_investmenttransitions away fromActive
This index enables O(n) orphan detection and off-chain monitoring without full storage scans.
validate_no_orphan_investments scans the active index and verifies every listed investment still has status == Active. Returns false if any entry has a terminal status — indicating a bug in the transition path.
// After any settlement or default event:
assert!(client.validate_no_orphan_investments());// src/settlement.rs — full settlement path
updated_investment.status = InvestmentStatus::Completed;
InvestmentStorage::update_investment(env, &updated_investment);
// Active index entry removed automatically// src/defaults.rs
investment.status = InvestmentStatus::Defaulted;
InvestmentStorage::update_investment(env, &investment);
// Active index entry removed automatically// src/escrow.rs
investment.status = InvestmentStatus::Refunded;
InvestmentStorage::update_investment(env, &investment);
// Active index entry removed automatically- Transition guard is mandatory —
update_investmentalways callsvalidate_transitionbefore persisting. No code path can bypass it. - Index consistency — The active index is updated inside the same
update_investmentcall as the status write; there is no window where the index and storage can diverge. - Terminal states are irreversible — Once an investment reaches
Completed,Defaulted,Refunded, orWithdrawn, no further transitions are possible. - No orphan active investments — After every terminal lifecycle event,
validate_no_orphan_investmentsmust returntrue.
| Test | Scenario |
|---|---|
test_settlement_sets_investment_completed |
Full settlement → Completed, removed from active index |
test_settlement_invoice_status_paid |
Invoice status is Paid after settlement |
test_default_sets_investment_defaulted |
Default event → Defaulted, removed from active index |
test_default_invoice_status_defaulted |
Invoice status is Defaulted after default |
test_refund_sets_investment_refunded |
Refund → Refunded, no orphan |
test_completed_to_defaulted_rejected |
Terminal → terminal rejected |
test_defaulted_to_completed_rejected |
Terminal → terminal rejected |
test_refunded_to_active_rejected |
Terminal → Active rejected |
test_withdrawn_to_completed_rejected |
Terminal → terminal rejected |
test_active_valid_transitions_accepted |
All four Active transitions accepted |
test_double_settle_rejected |
Second settle fails |
test_double_default_rejected |
Second default fails with InvoiceAlreadyDefaulted |
test_partial_payment_keeps_investment_active |
Partial payment leaves investment Active |
test_multiple_investments_independent_transitions |
Two investments transition independently |
test_active_index_grows_and_shrinks |
Index size tracks lifecycle events |
test_validate_no_orphan_empty_state |
Returns true on empty state |
test_validate_no_orphan_after_funding |
Returns true when all active entries are genuinely Active |