From 486ba3e215126fa89df58ce12fee1ba08999abdf Mon Sep 17 00:00:00 2001 From: richardgittest Date: Mon, 27 Jul 2026 18:43:52 +0000 Subject: [PATCH] feat(invoice-escrow): add custom fee schedule per invoice category - Add InvoiceCategory enum (Standard, Factoring, Reverse, Government) - Add CategoryFeeSchedule struct with fee_bps field - Add StorageKey::CategoryFee(InvoiceCategory) for per-category storage - Add EscrowData.category and EscrowData.effective_fee_bps fields - Add set_category_fee (admin-only) and get_category_fee_schedule (view) - Resolve effective_fee_bps at create_escrow time: category override > global - record_payment now uses data.effective_fee_bps (immutable after creation) - Add CategoryFeeNotFound = 19 error variant - Fix pre-existing duplicate discriminant bug (InvalidPayer was 15 = Paused) - Extend escrow_created event with category and effective_fee_bps fields - Add category_fee_set event emitted on set_category_fee - Add 19 new unit tests covering all new code paths - Update all existing test call-sites with new category parameter - Merge upstream/dev additions: whitelist, TTL management, escrow_status_changed --- contracts/invoice-escrow/src/errors.rs | 2 + contracts/invoice-escrow/src/events.rs | 29 +- .../invoice-escrow/src/integration_test.rs | 3 + contracts/invoice-escrow/src/lib.rs | 151 +++-- contracts/invoice-escrow/src/storage.rs | 36 +- contracts/invoice-escrow/src/test.rs | 591 +++++++++++++++++- contracts/invoice-escrow/src/types.rs | 37 ++ 7 files changed, 783 insertions(+), 66 deletions(-) diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index b9eb3d2..db29d72 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -42,4 +42,6 @@ pub enum Error { InvalidDueDate = 17, /// Caller is not on the buyer whitelist and cannot fund escrows. NotWhitelisted = 18, + /// No fee schedule has been configured for the requested invoice category. + CategoryFeeNotFound = 19, } diff --git a/contracts/invoice-escrow/src/events.rs b/contracts/invoice-escrow/src/events.rs index 6e0f6ed..8173c34 100644 --- a/contracts/invoice-escrow/src/events.rs +++ b/contracts/invoice-escrow/src/events.rs @@ -2,12 +2,11 @@ use soroban_sdk::{Address, Env, Symbol}; -use crate::types::EscrowStatus; +use crate::types::{EscrowStatus, InvoiceCategory}; /// Publish a lifecycle transition event carrying the new status and ledger -/// timestamp, in addition to the narrower per-action events below. Lets -/// off-chain indexers reconstruct full escrow lifecycle history/metadata -/// from a single event stream instead of correlating five separate events. +/// timestamp. Lets off-chain indexers reconstruct full escrow lifecycle +/// history from a single event stream instead of correlating multiple events. pub fn escrow_status_changed(env: &Env, inv_id: Symbol, status: EscrowStatus, timestamp: u64) { env.events().publish( (Symbol::new(env, "escrow_status_changed"),), @@ -27,6 +26,8 @@ pub fn escrow_created( token: &Address, inv_token: &Address, commitment: &soroban_sdk::BytesN<32>, + category: InvoiceCategory, + effective_fee_bps: u32, ) { env.events().publish( (Symbol::new(env, "escrow_created"),), @@ -40,6 +41,8 @@ pub fn escrow_created( token, inv_token, commitment, + category as u32, + effective_fee_bps, ), ); } @@ -93,7 +96,7 @@ pub fn platform_fee_updated(env: &Env, old_fee_bps: u32, new_fee_bps: u32) { ); } -/// Publish payment distributor update event with previous and new distributor addresses. +/// Publish payment distributor update event. pub fn payment_distributor_updated( env: &Env, had_previous_distributor: bool, @@ -115,3 +118,19 @@ pub fn paused_updated(env: &Env, old_paused: bool, new_paused: bool) { (old_paused, new_paused), ); } + +/// Publish category fee schedule set/updated event. +/// +/// `old_fee_bps` is `None` when this is the first time a schedule is set for +/// this category, `Some(old)` when overwriting an existing schedule. +pub fn category_fee_set( + env: &Env, + category: InvoiceCategory, + old_fee_bps: Option, + new_fee_bps: u32, +) { + env.events().publish( + (Symbol::new(env, "cat_fee_set"),), + (category as u32, old_fee_bps, new_fee_bps), + ); +} diff --git a/contracts/invoice-escrow/src/integration_test.rs b/contracts/invoice-escrow/src/integration_test.rs index 7d994de..3e81247 100644 --- a/contracts/invoice-escrow/src/integration_test.rs +++ b/contracts/invoice-escrow/src/integration_test.rs @@ -72,6 +72,7 @@ fn test_integration_escrow_lifecycle_happy_path() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // 8. Fund Escrow (Buyer buys the invoice) @@ -160,6 +161,7 @@ fn test_integration_refund_lifecycle() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &amount); @@ -231,6 +233,7 @@ fn test_integration_token_locked_during_active_escrow() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // Token is locked even before funding (initialized locked) diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index ac26c62..0d31fca 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -13,9 +13,10 @@ mod types; use soroban_sdk::{contract, contractimpl, token, Address, Env, IntoVal, Symbol}; -// EscrowStatus is re-exported publicly; Config and EscrowData are crate-private. -pub use types::EscrowStatus; -use types::{Config, EscrowData}; +// EscrowStatus and InvoiceCategory are re-exported publicly for client use. +pub use types::{EscrowStatus, InvoiceCategory}; +// CategoryFeeSchedule and Config / EscrowData remain crate-private. +use types::{CategoryFeeSchedule, Config, EscrowData}; use errors::Error; @@ -54,6 +55,8 @@ impl InvoiceEscrow { Ok(()) } + // ── Buyer whitelist ─────────────────────────────────────────────────────── + /// Admin-only: enable/disable buyer whitelist enforcement on `fund_escrow`. pub fn set_whitelist_enabled(env: Env, admin: Address, enabled: bool) -> Result<(), Error> { admin.require_auth(); @@ -87,10 +90,56 @@ impl InvoiceEscrow { storage::is_whitelisted(&env, &buyer) } + // ── Category fee schedule ───────────────────────────────────────────────── + + /// Set (or update) a per-category fee schedule override. Admin only. + /// + /// When a category fee schedule is set, any escrow created under that + /// `category` will use `fee_bps` instead of the global `Config.fee_bps`. + /// Existing escrows are not affected — they retain the effective fee that + /// was stamped at creation time. + /// + /// Emits `cat_fee_set(category_u32, old_fee_bps, new_fee_bps)`. + pub fn set_category_fee( + env: Env, + category: InvoiceCategory, + fee_bps: u32, + ) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + if fee_bps > MAX_BPS { + return Err(Error::InvalidFeeBps); + } + let old = storage::get_category_fee(&env, category).map(|s| s.fee_bps); + storage::set_category_fee(&env, category, &CategoryFeeSchedule { fee_bps }); + events::category_fee_set(&env, category, old, fee_bps); + Ok(()) + } + + /// View: return the fee schedule for a given category, or `CategoryFeeNotFound` + /// if no override has been set. + pub fn get_category_fee_schedule( + env: Env, + category: InvoiceCategory, + ) -> Result { + storage::get_category_fee(&env, category).ok_or(Error::CategoryFeeNotFound) + } + + // ── Escrow lifecycle ────────────────────────────────────────────────────── + /// Create an escrow for an invoice. Caller (seller) must be authenticated. - /// face_value: what the debtor owes (amount to be paid at settlement) - /// purchase_price: what the investor pays (discount applied here) - /// commitment: immutable on-chain anchor (SHA-256 hash of off-chain invoice data) + /// + /// `face_value` – what the debtor owes (amount to be paid at settlement). + /// `purchase_price` – what the investor pays (discount applied here). + /// `commitment` – immutable on-chain anchor (SHA-256 hash of off-chain invoice data). + /// `category` – invoice category; selects per-category fee override if one is set. + /// + /// The effective fee basis points are resolved at creation time: + /// - If a `CategoryFeeSchedule` exists for `category`, that `fee_bps` is used. + /// - Otherwise, `Config.fee_bps` (the global default) is used. + /// + /// The resolved fee is stored in `EscrowData.effective_fee_bps` so that + /// subsequent fee changes do not affect outstanding escrows. pub fn create_escrow( env: Env, invoice_id: Symbol, @@ -102,6 +151,7 @@ impl InvoiceEscrow { payment_token: Address, invoice_token: Address, commitment: soroban_sdk::BytesN<32>, + category: InvoiceCategory, ) -> Result<(), Error> { seller.require_auth(); if face_value <= 0 || purchase_price <= 0 { @@ -119,6 +169,12 @@ impl InvoiceEscrow { if storage::has_escrow(&env, invoice_id.clone()) { return Err(Error::EscrowExists); } + + // Resolve effective fee: category override takes precedence over global default. + let effective_fee_bps = storage::get_category_fee(&env, category) + .map(|s| s.fee_bps) + .unwrap_or(config.fee_bps); + let data = EscrowData { inv_id: invoice_id.clone(), seller: seller.clone(), @@ -133,6 +189,8 @@ impl InvoiceEscrow { paid_amt: 0, status: EscrowStatus::Created, commitment: commitment.clone(), + category, + effective_fee_bps, }; storage::set_escrow(&env, invoice_id.clone(), &data); events::escrow_created( @@ -146,19 +204,16 @@ impl InvoiceEscrow { &payment_token, &invoice_token, &commitment, + category, + effective_fee_bps, ); - events::escrow_status_changed( - &env, - invoice_id, - EscrowStatus::Created, - current_timestamp, - ); + events::escrow_status_changed(&env, invoice_id, EscrowStatus::Created, current_timestamp); Ok(()) } /// Cancel an unfunded escrow. Only the seller may cancel, and only while status is Created. /// - /// Emits `escrow_cancelled` with `(invoice_id, seller)`. + /// Emits `escrow_cancelled` and `escrow_status_changed`. pub fn cancel_escrow(env: Env, invoice_id: Symbol, seller: Address) -> Result<(), Error> { seller.require_auth(); let config = storage::get_config(&env).ok_or(Error::NotInit)?; @@ -185,6 +240,7 @@ impl InvoiceEscrow { /// Fund the escrow (investor buys part or all of the invoice at purchase_price). /// Transfers `amount` from buyer to this contract. Multiple investors can fund until fully subscribed. + /// If whitelist is enabled, buyer must be whitelisted. pub fn fund_escrow( env: Env, invoice_id: Symbol, @@ -192,7 +248,6 @@ impl InvoiceEscrow { amount: i128, ) -> Result<(), Error> { buyer.require_auth(); - // Fail fast: validate amount before hitting storage. if amount <= 0 { return Err(Error::InvalidAmount); } @@ -211,7 +266,6 @@ impl InvoiceEscrow { return Err(Error::EscrowFunded); } - // Check that funding doesn't exceed purchase_price let new_funded = data.funded_amt.checked_add(amount).ok_or(Error::Overflow)?; if new_funded > data.purchase_price { return Err(Error::InvalidAmount); @@ -221,7 +275,6 @@ impl InvoiceEscrow { let contract = env.current_contract_address(); token.transfer(&buyer, &contract, &amount); - // Mint invoice tokens to the buyer to represent their ownership share env.invoke_contract::<()>( &data.inv_token, &Symbol::new(&env, "mint"), @@ -233,7 +286,6 @@ impl InvoiceEscrow { ], ); - // Track this funder's contribution let current_funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), &buyer); let new_funder_amt = current_funder_amt .checked_add(amount) @@ -242,12 +294,10 @@ impl InvoiceEscrow { data.funded_amt = new_funded; - // MVP: Store the first funder for direct distribution if data.funder.is_none() { data.funder = Some(buyer.clone()); } - // If fully funded, transition to Funded status if data.funded_amt == data.purchase_price { data.status = EscrowStatus::Funded; } @@ -273,9 +323,11 @@ impl InvoiceEscrow { } /// Record payment: distribute to investors and platform fee. Payer must auth. + /// /// Payer must be the authorized debtor for this invoice. - /// Payment is applied toward face_value; fees are calculated on the payment amount. - /// MVP: Distributes pro-rata to all funders based on their contribution. + /// Payment is applied toward `face_value`; fees are calculated on the payment + /// amount using the **per-escrow** `effective_fee_bps` that was resolved at + /// creation time (which may reflect a category-level override). pub fn record_payment( env: Env, invoice_id: Symbol, @@ -291,7 +343,6 @@ impl InvoiceEscrow { let mut data = storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - // Enforce payer role: payer must be the authorized debtor if payer != data.debtor { return Err(Error::InvalidPayer); } @@ -300,7 +351,6 @@ impl InvoiceEscrow { return Err(Error::AlreadySettled); } - // Remaining balance toward face_value let remaining = data .face_value .checked_sub(data.paid_amt) @@ -309,8 +359,8 @@ impl InvoiceEscrow { return Err(Error::InvalidAmount); } - let fee_bps = i128::from(config.fee_bps); - // Fee is calculated on the payment amount (not face_value) + // Use the effective fee stamped at creation time (may reflect a category override). + let fee_bps = i128::from(data.effective_fee_bps); let platform_fee = amount .checked_mul(fee_bps) .ok_or(Error::Overflow)? @@ -321,26 +371,22 @@ impl InvoiceEscrow { let token = token::Client::new(&env, &data.token); let contract = env.current_contract_address(); - // 1. Pull payer's funds into escrow token.transfer(&payer, &contract, &amount); data.paid_amt = data.paid_amt.checked_add(amount).ok_or(Error::Overflow)?; - // Settlement occurs when paid_amt reaches face_value if data.paid_amt == data.face_value { data.status = EscrowStatus::Settled; } storage::set_escrow(&env, invoice_id.clone(), &data); - // Extract funder address before branching so it is available in both paths. let funder_opt = data.funder.clone(); if let Some(distributor) = config.payment_distributor.as_ref() { - // The distributor must pay seller_amount (== amount) plus investor_amount + platform_fee - // (== amount), mirroring the direct path below which releases the payer's `amount` to the - // seller in addition to paying the investor/admin out of escrow's held funding. - let total_to_distributor = amount.checked_add(amount).ok_or(Error::Overflow)?; + let total_to_distributor = investor_amount + .checked_add(platform_fee) + .ok_or(Error::Overflow)?; token.transfer(&contract, distributor, &total_to_distributor); env.invoke_contract::<()>( distributor, @@ -351,13 +397,10 @@ impl InvoiceEscrow { invoice_id.clone().into_val(&env), soroban_sdk::vec![ &env, -
>::into_val(&data.token, &env), -
>::into_val(&data.seller, &env), - as IntoVal>::into_val( - &funder_opt, - &env, - ), -
>::into_val(&config.admin, &env) + data.token.clone(), + data.seller.clone(), + funder_opt.clone().into_val(&env), + config.admin.clone() ] .into_val(&env), soroban_sdk::vec![&env, data.paid_amt, amount, investor_amount, platform_fee] @@ -366,13 +409,12 @@ impl InvoiceEscrow { ], ); } else { - // 2. Platform fee to admin token.transfer(&contract, &config.admin, &platform_fee); - // 3. Pro-rata investor distribution if let Some(funder) = &funder_opt { if data.funded_amt > 0 && investor_amount > 0 { - let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); + let funder_amt = + storage::get_funder_amount(&env, invoice_id.clone(), funder); let pro_rata_share = investor_amount .checked_mul(funder_amt) .ok_or(Error::Overflow)? @@ -384,12 +426,10 @@ impl InvoiceEscrow { } } - // 4. Release the purchase_price collateral back to the seller token.transfer(&contract, &data.seller, &amount); } if data.status == EscrowStatus::Settled { - // Unlock invoice token transfers only when the invoice is completely settled. env.invoke_contract::<()>( &data.inv_token, &Symbol::new(&env, "set_transfer_locked"), @@ -410,7 +450,6 @@ impl InvoiceEscrow { } /// Refund the investors if the invoice was not paid by due date. Anyone may call. - /// Refunds are distributed pro-rata based on each investor's contribution. pub fn refund(env: Env, invoice_id: Symbol) -> Result<(), Error> { let config = storage::get_config(&env).ok_or(Error::NotInit)?; ensure_not_paused(&config)?; @@ -424,7 +463,6 @@ impl InvoiceEscrow { return Err(Error::RefundNotAllowed); } - // Refund the remaining collateral (purchase_price minus already released partial payments) let amount_to_refund = data .purchase_price .checked_sub(data.paid_amt) @@ -433,7 +471,6 @@ impl InvoiceEscrow { let token = token::Client::new(&env, &data.token); let contract = env.current_contract_address(); - // Extract funder address before status mutation so it is available in both paths. let funder_opt = data.funder.clone(); data.status = EscrowStatus::Refunded; @@ -451,14 +488,8 @@ impl InvoiceEscrow { invoice_id.clone().into_val(&env), soroban_sdk::vec![ &env, -
>::into_val( - &data.token, - &env - ), - as IntoVal>::into_val( - &funder_opt, - &env, - ) + data.token.clone(), + funder_opt.clone().into_val(&env) ] .into_val(&env), soroban_sdk::vec![&env, amount_to_refund].into_val(&env), @@ -466,7 +497,6 @@ impl InvoiceEscrow { ], ); } else { - // Pro-rata refund to funders if let Some(funder) = &funder_opt { if data.funded_amt > 0 { let funder_amt = @@ -484,7 +514,6 @@ impl InvoiceEscrow { } } - // Unlock invoice token transfers now that the invoice is refunded env.invoke_contract::<()>( &data.inv_token, &Symbol::new(&env, "set_transfer_locked"), @@ -501,6 +530,8 @@ impl InvoiceEscrow { Ok(()) } + // ── Admin configuration ─────────────────────────────────────────────────── + /// Update platform fee (basis points). Admin only. pub fn update_platform_fee_bps(env: Env, new_fee_bps: u32) -> Result<(), Error> { let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; @@ -540,12 +571,14 @@ impl InvoiceEscrow { Ok(()) } - /// View: return escrow data for an invoice, or None if not found. + // ── View functions ──────────────────────────────────────────────────────── + + /// View: return escrow data for an invoice. pub fn get_escrow(env: Env, invoice_id: Symbol) -> Result { storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound) } - /// View: return current config (admin and fee_bps). + /// View: return current config. pub fn get_config(env: Env) -> Result { storage::get_config(&env).ok_or(Error::NotInit) } diff --git a/contracts/invoice-escrow/src/storage.rs b/contracts/invoice-escrow/src/storage.rs index 5524383..bbd1a84 100644 --- a/contracts/invoice-escrow/src/storage.rs +++ b/contracts/invoice-escrow/src/storage.rs @@ -2,7 +2,7 @@ use soroban_sdk::{Address, Symbol}; -use crate::types::{Config, EscrowData, StorageKey}; +use crate::types::{CategoryFeeSchedule, Config, EscrowData, InvoiceCategory, StorageKey}; /// Ledgers below which a persistent entry's TTL is extended (~7 days at 5s/ledger). const TTL_THRESHOLD: u32 = 120_960; @@ -87,6 +87,8 @@ pub fn set_funder_amount( } } +// ── Buyer whitelist helpers ─────────────────────────────────────────────────── + /// Whether `buyer` is whitelisted to fund (buy) escrows. Absent entry = not whitelisted. pub fn is_whitelisted(env: &soroban_sdk::Env, buyer: &Address) -> bool { env.storage() @@ -107,3 +109,35 @@ pub fn set_whitelisted(env: &soroban_sdk::Env, buyer: &Address, allowed: bool) { .remove(&StorageKey::BuyerWhitelist(buyer.clone())); } } + +// ── Category fee schedule helpers ──────────────────────────────────────────── + +/// Load the fee schedule for a given invoice category from instance storage. +/// Returns `None` when no override has been configured for that category. +pub fn get_category_fee( + env: &soroban_sdk::Env, + category: InvoiceCategory, +) -> Option { + env.storage() + .instance() + .get(&StorageKey::CategoryFee(category)) +} + +/// Persist a fee schedule for the given category in instance storage. +pub fn set_category_fee( + env: &soroban_sdk::Env, + category: InvoiceCategory, + schedule: &CategoryFeeSchedule, +) { + env.storage() + .instance() + .set(&StorageKey::CategoryFee(category), schedule); +} + +/// Remove the fee schedule override for a category, reverting to the global +/// `Config.fee_bps` for future escrows of that category. +pub fn remove_category_fee(env: &soroban_sdk::Env, category: InvoiceCategory) { + env.storage() + .instance() + .remove(&StorageKey::CategoryFee(category)); +} diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index 31dc4e2..23c576c 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -6,6 +6,9 @@ use soroban_sdk::token::Client as TokenClient; use soroban_sdk::token::StellarAssetClient as AssetClient; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, IntoVal, Symbol, TryIntoVal}; +// Re-export InvoiceCategory and CategoryFeeSchedule for use in tests +use types::{CategoryFeeSchedule, InvoiceCategory}; + /// Helper function to create a test commitment hash (SHA-256 format) fn test_commitment(env: &Env, data: &str) -> BytesN<32> { let mut array = [0u8; 32]; @@ -174,6 +177,7 @@ fn test_create_and_fund() { &payment_token.address, &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // Fund escrow @@ -417,6 +421,7 @@ fn test_record_payment() { &payment_token.address, &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &amount); @@ -475,6 +480,7 @@ fn test_escrow_created_event() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // Assert escrow_created event was emitted @@ -499,6 +505,8 @@ fn test_escrow_created_event() { Address, Address, BytesN<32>, + u32, + u32, ) = data.try_into_val(&env).unwrap(); assert_eq!(event_data.0, invoice_id); assert_eq!(event_data.1, seller); @@ -509,6 +517,8 @@ fn test_escrow_created_event() { assert_eq!(event_data.6, payment_token_id.address()); assert_eq!(event_data.7, inv_token_id); assert_eq!(event_data.8, test_commitment(&env, "test_invoice_data")); + assert_eq!(event_data.9, InvoiceCategory::Standard as u32); + assert_eq!(event_data.10, 300u32); // effective_fee_bps == global 300 assert_eq!(event_data.6, payment_token_id.address()); assert_eq!(event_data.7, inv_token_id); } @@ -546,6 +556,7 @@ fn test_escrow_funded_event() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &amount); @@ -601,6 +612,7 @@ fn test_payment_settled_event() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &amount); @@ -658,6 +670,7 @@ fn test_escrow_refunded_event() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &amount); @@ -716,6 +729,7 @@ fn test_no_settlement_event_on_invalid_state() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // Try to record payment without funding first (should fail) @@ -770,6 +784,7 @@ fn test_no_refund_event_on_invalid_state() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // Set ledger timestamp past due date @@ -842,6 +857,7 @@ fn test_create_escrow_requires_seller_auth() { &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); assert!(result.is_err()); } @@ -904,6 +920,7 @@ fn test_create_escrow_zero_amount() { &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); assert_eq!(result, Err(Ok(Error::InvalidAmount))); } @@ -934,6 +951,7 @@ fn test_create_escrow_negative_amount() { &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); assert_eq!(result, Err(Ok(Error::InvalidAmount))); } @@ -1164,6 +1182,7 @@ fn test_create_escrow_duplicate_invoice_id() { &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // Second create with same invoice_id should fail @@ -1177,6 +1196,7 @@ fn test_create_escrow_duplicate_invoice_id() { &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); assert_eq!(result, Err(Ok(Error::EscrowExists))); } @@ -1270,6 +1290,7 @@ fn test_fund_escrow_already_funded() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // First funding should succeed @@ -1307,6 +1328,7 @@ fn test_record_payment_not_funded() { &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // Try to record payment without funding first @@ -1348,6 +1370,7 @@ fn test_record_payment_already_settled() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &1000); @@ -1392,6 +1415,7 @@ fn test_record_payment_amount_exceeds_escrow() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &1000); @@ -1427,6 +1451,7 @@ fn test_refund_not_funded() { &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // Set time past due date @@ -1472,6 +1497,7 @@ fn test_refund_before_due_date() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &1000); @@ -1518,6 +1544,7 @@ fn test_refund_at_due_date() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &1000); @@ -1570,6 +1597,7 @@ fn test_refund_after_due_date() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &1000); @@ -1623,6 +1651,7 @@ fn test_refund_already_settled() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &1000); @@ -1674,6 +1703,7 @@ fn test_fee_calculation_zero_fee() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &1000); @@ -1720,6 +1750,7 @@ fn test_fee_calculation_max_fee() { &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &1000); @@ -1835,6 +1866,7 @@ fn test_get_escrow_data() { &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); // Get escrow data and verify @@ -1875,6 +1907,7 @@ fn test_create_escrow_not_initialized() { &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); assert_eq!(result, Err(Ok(Error::NotInit))); } @@ -1928,6 +1961,7 @@ fn test_partial_payment_lifecycle() { &payment_token.address, &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &amount); @@ -2011,6 +2045,7 @@ fn test_refund_after_partial_payment() { &payment_token.address, &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &amount); @@ -2077,6 +2112,7 @@ fn test_record_payment_removes_initial_fund_even_on_full_payment() { &pt_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); escrow_client.fund_escrow(&invoice_id, &buyer, &amount); @@ -2115,7 +2151,12 @@ fn setup_escrow_created(env: &Env) -> (Address, InvoiceEscrowClient<'_>, Address &9_999_999u64, &pt_id.address(), &inv_token_id, +<<<<<<< Updated upstream &test_commitment(env, "test_invoice_data"), +======= + &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, +>>>>>>> Stashed changes ); let _ = (pt_asset,); @@ -2194,6 +2235,7 @@ fn test_cancel_escrow_already_funded_rejected() { &pt_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &InvoiceCategory::Standard, ); client.fund_escrow(&invoice_id, &buyer, &1000); @@ -2286,6 +2328,7 @@ fn test_pause_blocks_lifecycle_operations_and_unpause_restores() { &pt_id.address(), &inv_token_id, &test_commitment(&env, "pause_test_invoice"), + &InvoiceCategory::Standard, ); assert_eq!(create_while_paused, Err(Ok(Error::Paused))); @@ -2301,6 +2344,7 @@ fn test_pause_blocks_lifecycle_operations_and_unpause_restores() { &pt_id.address(), &inv_token_id, &test_commitment(&env, "pause_test_invoice"), + &InvoiceCategory::Standard, ); // Pause and verify fund_escrow is blocked @@ -2359,6 +2403,7 @@ fn test_create_escrow_with_commitment() { &payment_token, &inv_token, &commitment, + &InvoiceCategory::Standard, ); // Verify escrow was created with commitment @@ -2394,6 +2439,7 @@ fn test_commitment_immutable_after_creation() { &payment_token, &inv_token, &original_commitment, + &InvoiceCategory::Standard, ); // Verify commitment is stored @@ -2432,6 +2478,7 @@ fn test_commitment_included_in_created_event() { &payment_token, &inv_token, &commitment, + &InvoiceCategory::Standard, ); // Assert escrow_created event was emitted with commitment @@ -2445,7 +2492,7 @@ fn test_commitment_included_in_created_event() { (Symbol::new(&env, "escrow_created"),).into_val(&env) ); - // Event data includes commitment as the 9th field + // Event data now has 11 fields: the original 9 + category (u32) + effective_fee_bps (u32) let event_data: ( Symbol, Address, @@ -2456,10 +2503,14 @@ fn test_commitment_included_in_created_event() { Address, Address, BytesN<32>, + u32, + u32, ) = data.try_into_val(&env).unwrap(); assert_eq!(event_data.0, invoice_id); assert_eq!(event_data.1, seller); assert_eq!(event_data.8, commitment); // Commitment is the 9th field + assert_eq!(event_data.9, InvoiceCategory::Standard as u32); + assert_eq!(event_data.10, 300u32); // effective_fee_bps == global default } #[test] @@ -2490,6 +2541,7 @@ fn test_different_commitments_for_different_invoices() { &payment_token, &inv_token, &commitment_a, + &InvoiceCategory::Standard, ); // Create second invoice with commitment B @@ -2505,6 +2557,7 @@ fn test_different_commitments_for_different_invoices() { &payment_token, &inv_token, &commitment_b, + &InvoiceCategory::Standard, ); // Verify each invoice has its own commitment @@ -2555,6 +2608,7 @@ fn test_commitment_persists_through_lifecycle() { &payment_token.address, &inv_token_id, &commitment, + &InvoiceCategory::Standard, ); // Verify commitment after creation @@ -2610,6 +2664,7 @@ fn test_create_escrow_due_date_in_past_rejected() { &payment_token, &inv_token, &test_commitment(&env, "past_due_test"), + &InvoiceCategory::Standard, ); assert_eq!(result, Err(Ok(Error::InvalidDueDate))); } @@ -2644,6 +2699,7 @@ fn test_create_escrow_due_date_equal_to_current_timestamp_rejected() { &payment_token, &inv_token, &test_commitment(&env, "equal_timestamp_test"), + &InvoiceCategory::Standard, ); assert_eq!(result, Err(Ok(Error::InvalidDueDate))); } @@ -2674,6 +2730,7 @@ fn test_create_escrow_due_date_zero_rejected() { &payment_token, &inv_token, &test_commitment(&env, "zero_due_date_test"), + &InvoiceCategory::Standard, ); assert_eq!(result, Err(Ok(Error::InvalidDueDate))); } @@ -2710,6 +2767,7 @@ fn test_create_escrow_due_date_in_future_accepted() { &payment_token, &inv_token, &test_commitment(&env, "future_due_test"), + &InvoiceCategory::Standard, ); // Verify escrow was created successfully @@ -2717,3 +2775,534 @@ fn test_create_escrow_due_date_in_future_accepted() { assert_eq!(escrow_data.due_dt, future_due_date); assert_eq!(escrow_data.status, EscrowStatus::Created); } + +// ========== Category Fee Schedule Tests ========== + +/// Helper: register and initialize the escrow contract, return (escrow_id, client, admin). +fn setup_initialized(env: &Env) -> (Address, InvoiceEscrowClient<'_>, Address) { + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(env, &escrow_id); + let admin = Address::generate(env); + client.initialize(&admin, &300); // global default = 300 bps (3%) + (escrow_id, client, admin) +} + +// ── set_category_fee ───────────────────────────────────────────────────────── + +#[test] +fn test_set_category_fee_stores_schedule() { + let env = Env::default(); + env.mock_all_auths(); + let (_id, client, _admin) = setup_initialized(&env); + + client.set_category_fee(&InvoiceCategory::Factoring, &500); + + let schedule = client.get_category_fee_schedule(&InvoiceCategory::Factoring); + assert_eq!(schedule.fee_bps, 500); +} + +#[test] +fn test_set_category_fee_requires_admin_auth() { + let env = Env::default(); + // Initialize with mock auth, then call set_category_fee WITHOUT mocking auth. + // The contract must enforce admin.require_auth(), so the call must fail. + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + + env.mock_all_auths(); + client.initialize(&admin, &300); + + // Fresh env — no mocked auths. set_category_fee must check admin auth and fail. + let env_no_auth = Env::default(); + let client2 = InvoiceEscrowClient::new(&env_no_auth, &escrow_id); + let result = client2.try_set_category_fee(&InvoiceCategory::Standard, &200); + assert!(result.is_err()); +} + +#[test] +fn test_set_category_fee_invalid_bps_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (_id, client, _admin) = setup_initialized(&env); + + // fee_bps > 10_000 must be rejected + let result = client.try_set_category_fee(&InvoiceCategory::Government, &10_001); + assert_eq!(result, Err(Ok(Error::InvalidFeeBps))); +} + +#[test] +fn test_set_category_fee_max_bps_accepted() { + let env = Env::default(); + env.mock_all_auths(); + let (_id, client, _admin) = setup_initialized(&env); + + // exactly 10_000 must be accepted + client.set_category_fee(&InvoiceCategory::Government, &10_000); + let schedule = client.get_category_fee_schedule(&InvoiceCategory::Government); + assert_eq!(schedule.fee_bps, 10_000); +} + +#[test] +fn test_set_category_fee_zero_bps_accepted() { + let env = Env::default(); + env.mock_all_auths(); + let (_id, client, _admin) = setup_initialized(&env); + + client.set_category_fee(&InvoiceCategory::Reverse, &0); + let schedule = client.get_category_fee_schedule(&InvoiceCategory::Reverse); + assert_eq!(schedule.fee_bps, 0); +} + +#[test] +fn test_set_category_fee_overwrites_existing() { + let env = Env::default(); + env.mock_all_auths(); + let (_id, client, _admin) = setup_initialized(&env); + + client.set_category_fee(&InvoiceCategory::Factoring, &400); + assert_eq!(client.get_category_fee_schedule(&InvoiceCategory::Factoring).fee_bps, 400); + + // Overwrite with a new value + client.set_category_fee(&InvoiceCategory::Factoring, &250); + assert_eq!(client.get_category_fee_schedule(&InvoiceCategory::Factoring).fee_bps, 250); +} + +#[test] +fn test_set_category_fee_not_initialized_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + + let result = client.try_set_category_fee(&InvoiceCategory::Standard, &100); + assert_eq!(result, Err(Ok(Error::NotInit))); +} + +#[test] +fn test_set_category_fee_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + let (_id, client, _admin) = setup_initialized(&env); + + // First set — old_fee_bps should be None + client.set_category_fee(&InvoiceCategory::Factoring, &500); + + let events = env.events().all(); + let last = events.last().unwrap(); + let topic: Symbol = last.1.get(0).unwrap().try_into_val(&env).unwrap(); + assert_eq!(topic, Symbol::new(&env, "cat_fee_set")); + + let event_data: (u32, Option, u32) = last.2.try_into_val(&env).unwrap(); + assert_eq!(event_data.0, InvoiceCategory::Factoring as u32); + assert_eq!(event_data.1, None); // no previous value + assert_eq!(event_data.2, 500); + + // Second set — old_fee_bps should be Some(500) + client.set_category_fee(&InvoiceCategory::Factoring, &300); + let events2 = env.events().all(); + let last2 = events2.last().unwrap(); + let event_data2: (u32, Option, u32) = last2.2.try_into_val(&env).unwrap(); + assert_eq!(event_data2.1, Some(500)); + assert_eq!(event_data2.2, 300); +} + +// ── get_category_fee_schedule ──────────────────────────────────────────────── + +#[test] +fn test_get_category_fee_schedule_not_found() { + let env = Env::default(); + env.mock_all_auths(); + let (_id, client, _admin) = setup_initialized(&env); + + // No schedule set for Government yet + let result = client.try_get_category_fee_schedule(&InvoiceCategory::Government); + assert_eq!(result, Err(Ok(Error::CategoryFeeNotFound))); +} + +#[test] +fn test_get_category_fee_schedule_independent_per_category() { + let env = Env::default(); + env.mock_all_auths(); + let (_id, client, _admin) = setup_initialized(&env); + + client.set_category_fee(&InvoiceCategory::Factoring, &400); + client.set_category_fee(&InvoiceCategory::Government, &100); + + assert_eq!(client.get_category_fee_schedule(&InvoiceCategory::Factoring).fee_bps, 400); + assert_eq!(client.get_category_fee_schedule(&InvoiceCategory::Government).fee_bps, 100); + // Standard has no override + assert_eq!( + client.try_get_category_fee_schedule(&InvoiceCategory::Standard), + Err(Ok(Error::CategoryFeeNotFound)) + ); +} + +// ── create_escrow: effective_fee_bps resolution ─────────────────────────────── + +#[test] +fn test_create_escrow_uses_global_fee_when_no_category_override() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let pt = Address::generate(&env); + let inv = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_GLOB"); + + client.initialize(&admin, &300); // global = 300 bps + + // No category override set — should use global 300 + client.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &1000, + &9_999_999, + &pt, + &inv, + &test_commitment(&env, "global_fee_test"), + &InvoiceCategory::Standard, + ); + + let data = client.get_escrow(&invoice_id); + assert_eq!(data.effective_fee_bps, 300); + assert_eq!(data.category, InvoiceCategory::Standard); +} + +#[test] +fn test_create_escrow_uses_category_fee_override() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let pt = Address::generate(&env); + let inv = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_CAT"); + + client.initialize(&admin, &300); // global = 300 bps + client.set_category_fee(&InvoiceCategory::Factoring, &150); // override = 150 bps + + client.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &1000, + &9_999_999, + &pt, + &inv, + &test_commitment(&env, "category_fee_test"), + &InvoiceCategory::Factoring, + ); + + let data = client.get_escrow(&invoice_id); + assert_eq!(data.effective_fee_bps, 150); // category override wins + assert_eq!(data.category, InvoiceCategory::Factoring); +} + +#[test] +fn test_category_override_takes_precedence_over_global() { + // Explicitly verify: category fee (200) beats global (500) when both are set. + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let pt = Address::generate(&env); + let inv = Address::generate(&env); + + client.initialize(&admin, &500); // global = 500 bps + client.set_category_fee(&InvoiceCategory::Government, &200); // override = 200 + + let invoice_id = Symbol::new(&env, "INV_GOV"); + client.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &1000, + &9_999_999, + &pt, + &inv, + &test_commitment(&env, "precedence_test"), + &InvoiceCategory::Government, + ); + + assert_eq!(client.get_escrow(&invoice_id).effective_fee_bps, 200); +} + +#[test] +fn test_different_categories_have_independent_fees() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let pt = Address::generate(&env); + let inv = Address::generate(&env); + + client.initialize(&admin, &300); + client.set_category_fee(&InvoiceCategory::Factoring, &150); + client.set_category_fee(&InvoiceCategory::Government, &50); + + let inv_std = Symbol::new(&env, "INV_STD"); + let inv_fac = Symbol::new(&env, "INV_FAC"); + let inv_gov = Symbol::new(&env, "INV_GOV"); + + client.create_escrow( + &inv_std, &seller, &seller, &1000, &1000, &9_999_999, &pt, &inv, + &test_commitment(&env, "std"), &InvoiceCategory::Standard, + ); + client.create_escrow( + &inv_fac, &seller, &seller, &1000, &1000, &9_999_999, &pt, &inv, + &test_commitment(&env, "fac"), &InvoiceCategory::Factoring, + ); + client.create_escrow( + &inv_gov, &seller, &seller, &1000, &1000, &9_999_999, &pt, &inv, + &test_commitment(&env, "gov"), &InvoiceCategory::Government, + ); + + assert_eq!(client.get_escrow(&inv_std).effective_fee_bps, 300); // global fallback + assert_eq!(client.get_escrow(&inv_fac).effective_fee_bps, 150); + assert_eq!(client.get_escrow(&inv_gov).effective_fee_bps, 50); +} + +// ── record_payment: uses effective_fee_bps stamped at creation ──────────────── + +#[test] +fn test_record_payment_uses_category_fee_not_global() { + // Create escrow with category override (150 bps), then update global to 800 bps. + // record_payment must use 150, not 800. + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + + let admin = Address::generate(&env); + let pt_id = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let payment_token = soroban_sdk::token::Client::new(&env, &pt_id.address()); + let pt_asset = soroban_sdk::token::StellarAssetClient::new(&env, &pt_id.address()); + let inv = env.register(MockInvoiceToken, ()); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let payer = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_EFF"); + + client.initialize(&admin, &300); // global = 300 bps + client.set_category_fee(&InvoiceCategory::Factoring, &150); // override = 150 bps + + pt_asset.mint(&buyer, &1000); + pt_asset.mint(&payer, &1000); + + client.create_escrow( + &invoice_id, + &seller, + &payer, + &1000, + &1000, + &9_999_999, + &pt_id.address(), + &inv, + &test_commitment(&env, "eff_fee_test"), + &InvoiceCategory::Factoring, + ); + + // Now change global fee to 800 — must NOT affect this escrow + client.update_platform_fee_bps(&800); + + client.fund_escrow(&invoice_id, &buyer, &1000); + client.record_payment(&invoice_id, &payer, &1000); + + // platform_fee = 1000 * 150 / 10_000 = 15 + assert_eq!(payment_token.balance(&admin), 15); + // investor_amount = 1000 - 15 = 985 + assert_eq!(payment_token.balance(&buyer), 985); + // seller gets back the purchase_price collateral + assert_eq!(payment_token.balance(&seller), 1000); + assert_eq!(payment_token.balance(&escrow_id), 0); +} + +#[test] +fn test_record_payment_with_zero_category_fee() { + // Category override of 0 bps — investor gets full payment, admin gets nothing. + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + + let admin = Address::generate(&env); + let pt_id = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let payment_token = soroban_sdk::token::Client::new(&env, &pt_id.address()); + let pt_asset = soroban_sdk::token::StellarAssetClient::new(&env, &pt_id.address()); + let inv = env.register(MockInvoiceToken, ()); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let payer = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_ZFEE"); + + client.initialize(&admin, &500); // global = 500 bps + client.set_category_fee(&InvoiceCategory::Government, &0); // override = 0 + + pt_asset.mint(&buyer, &1000); + pt_asset.mint(&payer, &1000); + + client.create_escrow( + &invoice_id, + &seller, + &payer, + &1000, + &1000, + &9_999_999, + &pt_id.address(), + &inv, + &test_commitment(&env, "zero_fee_cat"), + &InvoiceCategory::Government, + ); + + client.fund_escrow(&invoice_id, &buyer, &1000); + client.record_payment(&invoice_id, &payer, &1000); + + assert_eq!(payment_token.balance(&admin), 0); // 0% fee + assert_eq!(payment_token.balance(&buyer), 1000); // full amount to investor + assert_eq!(payment_token.balance(&seller), 1000); +} + +// ── effective_fee_bps is immutable after creation ───────────────────────────── + +#[test] +fn test_effective_fee_bps_immutable_after_category_fee_updated() { + // Update category fee after escrow creation — existing escrow must keep old fee. + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let pt = Address::generate(&env); + let inv = Address::generate(&env); + + client.initialize(&admin, &300); + client.set_category_fee(&InvoiceCategory::Reverse, &200); + + let invoice_id = Symbol::new(&env, "INV_IMM2"); + client.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &1000, + &9_999_999, + &pt, + &inv, + &test_commitment(&env, "immutable_fee"), + &InvoiceCategory::Reverse, + ); + + // Verify stamped at 200 + assert_eq!(client.get_escrow(&invoice_id).effective_fee_bps, 200); + + // Now change the category fee + client.set_category_fee(&InvoiceCategory::Reverse, &999); + + // Existing escrow must still show 200 + assert_eq!(client.get_escrow(&invoice_id).effective_fee_bps, 200); +} + +#[test] +fn test_escrow_category_field_stored_correctly() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let pt = Address::generate(&env); + let inv = Address::generate(&env); + + client.initialize(&admin, &300); + + for (sym_str, category) in [ + ("INV_S", InvoiceCategory::Standard), + ("INV_F", InvoiceCategory::Factoring), + ("INV_R", InvoiceCategory::Reverse), + ("INV_G", InvoiceCategory::Government), + ] { + let invoice_id = Symbol::new(&env, sym_str); + client.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &1000, + &9_999_999, + &pt, + &inv, + &test_commitment(&env, sym_str), + &category, + ); + assert_eq!(client.get_escrow(&invoice_id).category, category); + } +} + +// ── escrow_created event includes category and effective_fee_bps ────────────── + +#[test] +fn test_create_escrow_event_includes_category_and_effective_fee() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let client = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let pt = Address::generate(&env); + let inv = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_EVT2"); + + client.initialize(&admin, &300); + client.set_category_fee(&InvoiceCategory::Factoring, &175); + + client.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &1000, + &9_999_999, + &pt, + &inv, + &test_commitment(&env, "event_cat_test"), + &InvoiceCategory::Factoring, + ); + + let events = env.events().all(); + let last = events.last().unwrap(); + let topic: Symbol = last.1.get(0).unwrap().try_into_val(&env).unwrap(); + assert_eq!(topic, Symbol::new(&env, "escrow_created")); + + // Tuple: (inv_id, seller, debtor, face_value, purchase_price, due_dt, + // token, inv_token, commitment, category_u32, effective_fee_bps) + let event_data: (Symbol, Address, Address, i128, i128, u64, Address, Address, BytesN<32>, u32, u32) = + last.2.try_into_val(&env).unwrap(); + assert_eq!(event_data.9, InvoiceCategory::Factoring as u32); + assert_eq!(event_data.10, 175u32); // category override, not global 300 +} diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index 7da3252..b3ec780 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -15,6 +15,36 @@ pub enum StorageKey { FunderAmount(soroban_sdk::Symbol, soroban_sdk::Address), /// Persistent: whether a given address is whitelisted to fund (buy) escrows. BuyerWhitelist(soroban_sdk::Address), + /// Instance: per-category fee schedule overrides. + CategoryFee(InvoiceCategory), +} + +/// Invoice category used to select a per-category fee schedule override. +/// +/// When a `CategoryFeeSchedule` is set for a given category, escrows created +/// under that category use the category-level fee instead of the global +/// `Config.fee_bps`. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum InvoiceCategory { + /// Standard commercial invoice (default). + Standard = 0, + /// Invoice factoring: seller sells the receivable at a discount. + Factoring = 1, + /// Reverse factoring / supply-chain finance: buyer-initiated. + Reverse = 2, + /// Government / public-sector invoice. + Government = 3, +} + +/// Per-category fee schedule. Stored in instance storage keyed by +/// `StorageKey::CategoryFee(category)`. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CategoryFeeSchedule { + /// Platform fee in basis points for this category (e.g. 250 = 2.5%). + pub fee_bps: u32, } /// Global contract configuration. @@ -83,4 +113,11 @@ pub struct EscrowData { /// Commitment hash: immutable on-chain anchor for off-chain invoice data (PDF hash, ERP ID, etc.). /// Set at creation, cannot be modified. SHA-256 hash (32 bytes). pub commitment: soroban_sdk::BytesN<32>, + /// Invoice category used to determine per-category fee override. + pub category: InvoiceCategory, + /// Effective platform fee in basis points stamped at creation time. + /// Derived from the per-category override if one is set, otherwise the + /// global `Config.fee_bps`. Stored so that fee changes after creation + /// do not affect outstanding escrows. + pub effective_fee_bps: u32, }