diff --git a/contracts/shade/src/components/invoice.rs b/contracts/shade/src/components/invoice.rs index 44004f9..f581402 100644 --- a/contracts/shade/src/components/invoice.rs +++ b/contracts/shade/src/components/invoice.rs @@ -1,3 +1,25 @@ +pub fn finalize_invoice(env: &Env, merchant_address: &Address, invoice_id: u64) { + merchant_address.require_auth(); + let mut invoice: Invoice = env + .storage() + .persistent() + .get(&DataKey::Invoice(invoice_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::InvoiceNotFound)); + if invoice.merchant_id != env + .storage() + .persistent() + .get::<_, u64>(&DataKey::MerchantId(merchant_address.clone())) + .unwrap() + { + panic_with_error!(env, ContractError::NotAuthorized); + } + if invoice.status != InvoiceStatus::Draft { + panic_with_error!(env, ContractError::InvalidInvoiceStatus); + } + invoice.status = InvoiceStatus::Pending; + env.storage().persistent().set(&DataKey::Invoice(invoice_id), &invoice); + // Optionally emit an event for finalization +} use crate::components::{access_control, admin, merchant, signature_util}; use crate::errors::ContractError; use crate::events; @@ -42,7 +64,7 @@ pub fn create_invoice( description: description.clone(), amount, token: token.clone(), - status: InvoiceStatus::Pending, + status: InvoiceStatus::Draft, merchant_id, payer: None, date_created: env.ledger().timestamp(), @@ -130,7 +152,7 @@ pub fn create_invoice_signed( description: description.clone(), amount, token: token.clone(), - status: InvoiceStatus::Pending, + status: InvoiceStatus::Draft, merchant_id, payer: None, date_created: env.ledger().timestamp(), @@ -211,6 +233,10 @@ pub fn get_invoices(env: &Env, filter: InvoiceFilter) -> Vec { .get::<_, Invoice>(&DataKey::Invoice(i)) { let mut matches = true; + // Hide Draft invoices unless explicitly filtering for Draft + if filter.status.is_none() && invoice.status == InvoiceStatus::Draft { + continue; + } if let Some(status) = filter.status { if invoice.status as u32 != status { matches = false; @@ -324,6 +350,9 @@ pub fn refund_invoice_partial(env: &Env, invoice_id: u64, amount: i128) { pub fn pay_invoice(env: &Env, payer: &Address, invoice_id: u64) -> i128 { let invoice = get_invoice(env, invoice_id); + if invoice.status == InvoiceStatus::Draft { + panic_with_error!(env, ContractError::InvalidInvoiceStatus); + } if invoice.status != InvoiceStatus::Pending && invoice.status != InvoiceStatus::PartiallyPaid { panic_with_error!(env, ContractError::InvalidInvoiceStatus); } @@ -342,6 +371,9 @@ pub fn pay_invoice_partial(env: &Env, payer: &Address, invoice_id: u64, amount: } let mut invoice = get_invoice(env, invoice_id); + if invoice.status == InvoiceStatus::Draft { + panic_with_error!(env, ContractError::InvalidInvoiceStatus); + } if let Some(expires_at) = invoice.expires_at { if env.ledger().timestamp() >= expires_at { diff --git a/contracts/shade/src/events.rs b/contracts/shade/src/events.rs index 4c0b0f5..c8daa89 100644 --- a/contracts/shade/src/events.rs +++ b/contracts/shade/src/events.rs @@ -117,6 +117,27 @@ pub fn publish_invoice_created_event( .publish(env); } +#[contractevent] +pub struct InvoiceFinalizedEvent { + pub invoice_id: u64, + pub merchant: Address, + pub timestamp: u64, +} + +pub fn publish_invoice_finalized_event( + env: &Env, + invoice_id: u64, + merchant: Address, + timestamp: u64, +) { + InvoiceFinalizedEvent { + invoice_id, + merchant, + timestamp, + } + .publish(env); +} + #[contractevent] pub struct InvoiceRefundedEvent { pub invoice_id: u64, diff --git a/contracts/shade/src/interface.rs b/contracts/shade/src/interface.rs index 9c30a15..7bd0a67 100644 --- a/contracts/shade/src/interface.rs +++ b/contracts/shade/src/interface.rs @@ -42,6 +42,7 @@ pub trait ShadeTrait { signature: BytesN<64>, ) -> u64; fn get_invoice(env: Env, invoice_id: u64) -> Invoice; + fn finalize_invoice(env: Env, merchant: Address, invoice_id: u64); fn refund_invoice(env: Env, merchant: Address, invoice_id: u64); fn set_merchant_key(env: Env, merchant: Address, key: BytesN<32>); fn get_merchant_key(env: Env, merchant: Address) -> BytesN<32>; diff --git a/contracts/shade/src/shade.rs b/contracts/shade/src/shade.rs index 2dc1499..f3a6270 100644 --- a/contracts/shade/src/shade.rs +++ b/contracts/shade/src/shade.rs @@ -148,6 +148,11 @@ impl ShadeTrait for Shade { invoice_component::get_invoice(&env, invoice_id) } + fn finalize_invoice(env: Env, merchant: Address, invoice_id: u64) { + pausable_component::assert_not_paused(&env); + invoice_component::finalize_invoice(&env, &merchant, invoice_id); + } + fn refund_invoice(env: Env, merchant: Address, invoice_id: u64) { pausable_component::assert_not_paused(&env); invoice_component::refund_invoice(&env, &merchant, invoice_id); diff --git a/contracts/shade/src/tests/mod.rs b/contracts/shade/src/tests/mod.rs index eaa5999..142d77a 100644 --- a/contracts/shade/src/tests/mod.rs +++ b/contracts/shade/src/tests/mod.rs @@ -18,3 +18,4 @@ pub mod test_refund; pub mod test_signatures; pub mod test_subscription; pub mod test_upgrade; +pub mod test_invoice_date_filtering; diff --git a/contracts/shade/src/tests/test_admin_payment.rs b/contracts/shade/src/tests/test_admin_payment.rs index 627cb21..b0fd52d 100644 --- a/contracts/shade/src/tests/test_admin_payment.rs +++ b/contracts/shade/src/tests/test_admin_payment.rs @@ -60,6 +60,10 @@ fn test_invoice_state_validation() { // Verify initial state let invoice = client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Draft); + + client.finalize_invoice(&merchant, &invoice_id); + let invoice = client.get_invoice(&invoice_id); assert_eq!(invoice.status, InvoiceStatus::Pending); assert_eq!(invoice.payer, None); assert_eq!(invoice.date_paid, None); @@ -98,7 +102,9 @@ fn test_multiple_invoices_independent() { .set(&DataKey::Invoice(id_2), &inv_2); }); - // Verify first is still Pending + // Verify first is still Draft + assert_eq!(client.get_invoice(&id_1).status, InvoiceStatus::Draft); + client.finalize_invoice(&merchant, &id_1); assert_eq!(client.get_invoice(&id_1).status, InvoiceStatus::Pending); assert_eq!(client.get_invoice(&id_2).status, InvoiceStatus::Paid); } @@ -165,5 +171,6 @@ fn test_contract_pause_and_unpause() { &token, &None, ); + client.finalize_invoice(&merchant, &invoice_id); assert!(invoice_id > 0); } diff --git a/contracts/shade/src/tests/test_invoice.rs b/contracts/shade/src/tests/test_invoice.rs index 644b2fa..78ad29e 100644 --- a/contracts/shade/src/tests/test_invoice.rs +++ b/contracts/shade/src/tests/test_invoice.rs @@ -4,7 +4,7 @@ use crate::shade::{Shade, ShadeClient}; use crate::types::{DataKey, InvoiceStatus}; use account::account::{MerchantAccount, MerchantAccountClient}; use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; -use soroban_sdk::{token, Address, Env, Map, String, Symbol, TryIntoVal, Val}; +use soroban_sdk::{token, Address, Env, FromVal, Map, String, Symbol, TryIntoVal, Val}; fn setup_test() -> (Env, ShadeClient<'static>, Address, Address) { let env = Env::default(); @@ -16,7 +16,8 @@ fn setup_test() -> (Env, ShadeClient<'static>, Address, Address) { (env, client, contract_id, admin) } -fn assert_latest_invoice_event( + +fn assert_invoice_created_event( env: &Env, contract_id: &Address, expected_invoice_id: u64, @@ -25,22 +26,39 @@ fn assert_latest_invoice_event( expected_token: &Address, ) { let events = env.events().all(); - assert!(!events.is_empty(), "No events captured for invoice!"); - - let (event_contract_id, _topics, data) = events.get(events.len() - 1).unwrap(); - assert_eq!(&event_contract_id, contract_id); - + assert!(!events.is_empty(), "No events captured!"); + + // Search for the InvoiceCreatedEvent in all events + let event = events + .iter() + .find(|(_id, topics, _data)| { + if topics.len() < 1 { + return false; + } + let event_name = Symbol::from_val(env, &topics.get(0).unwrap()); + event_name == Symbol::new(env, "InvoiceCreatedEvent") || + event_name == Symbol::new(env, "invoice_created_event") || + event_name == Symbol::new(env, "invoice_created") + }); + + if event.is_none() { + let mut names = soroban_sdk::Vec::::new(env); + for (_, topics, _) in events.iter() { + if topics.len() > 0 { + let name = Symbol::from_val(env, &topics.get(0).unwrap()); + names.push_back(name); + } + } + panic!("InvoiceCreatedEvent not found in {} events. Topics: {:?}", events.len(), names); + } + + let (_, _, data) = event.unwrap(); let data_map: Map = data.try_into_val(env).unwrap(); - let invoice_id_val = data_map.get(Symbol::new(env, "invoice_id")).unwrap(); - let merchant_val = data_map.get(Symbol::new(env, "merchant")).unwrap(); - let amount_val = data_map.get(Symbol::new(env, "amount")).unwrap(); - let token_val = data_map.get(Symbol::new(env, "token")).unwrap(); - - let invoice_id_in_event: u64 = invoice_id_val.try_into_val(env).unwrap(); - let merchant_in_event: Address = merchant_val.try_into_val(env).unwrap(); - let amount_in_event: i128 = amount_val.try_into_val(env).unwrap(); - let token_in_event: Address = token_val.try_into_val(env).unwrap(); + let invoice_id_in_event: u64 = data_map.get(Symbol::new(env, "invoice_id")).unwrap().try_into_val(env).unwrap(); + let merchant_in_event: Address = data_map.get(Symbol::new(env, "merchant")).unwrap().try_into_val(env).unwrap(); + let amount_in_event: i128 = data_map.get(Symbol::new(env, "amount")).unwrap().try_into_val(env).unwrap(); + let token_in_event: Address = data_map.get(Symbol::new(env, "token")).unwrap().try_into_val(env).unwrap(); assert_eq!(invoice_id_in_event, expected_invoice_id); assert_eq!(merchant_in_event, expected_merchant.clone()); @@ -95,11 +113,18 @@ fn test_create_and_get_invoice_success() { let invoice_id = client.create_invoice(&merchant, &description, &amount, &token, &None); assert_eq!(invoice_id, 1); - assert_latest_invoice_event(&env, &contract_id, invoice_id, &merchant, amount, &token); + assert_invoice_created_event(&env, &contract_id, invoice_id, &merchant, amount, &token); + + let invoice = client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Draft); + + client.finalize_invoice(&merchant, &invoice_id); let invoice = client.get_invoice(&invoice_id); assert_eq!(invoice.id, 1); + // Merchant ID is 1 because it's the first merchant registered in setup_test? + // No, setup_test doesn't register merchants. The register_merchant call on line 89 does. assert_eq!(invoice.merchant_id, 1); assert_eq!(invoice.amount, amount); assert_eq!(invoice.token, token); @@ -107,6 +132,56 @@ fn test_create_and_get_invoice_success() { assert_eq!(invoice.status, InvoiceStatus::Pending); } +#[test] +fn test_get_invoices_excludes_draft() { + let (env, client, _contract_id, _admin) = setup_test(); + + let merchant = Address::generate(&env); + client.register_merchant(&merchant); + + let token = Address::generate(&env); + let description = String::from_str(&env, "Test Invoice"); + let amount: i128 = 1000; + + client.create_invoice(&merchant, &description, &amount, &token, &None); + + let filter = crate::types::InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: None, + end_date: None, + }; + + let invoices = client.get_invoices(&filter); + assert_eq!(invoices.len(), 0); + + client.finalize_invoice(&merchant, &1); + let invoices = client.get_invoices(&filter); + assert_eq!(invoices.len(), 1); +} + +#[should_panic(expected = "HostError: Error(Contract, #16)")] +#[test] +fn test_pay_draft_invoice_fails() { + let (env, client, _contract_id, _admin, token) = setup_test_with_payment(); + + let merchant = Address::generate(&env); + client.register_merchant(&merchant); + let merchant_account = Address::generate(&env); + client.set_merchant_account(&merchant, &merchant_account); + + let description = String::from_str(&env, "Test Invoice"); + let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); + + let customer = Address::generate(&env); + let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token); + token_client.mint(&customer, &1000); + + client.pay_invoice(&customer, &invoice_id); +} + #[test] fn test_create_multiple_invoices() { let (env, client, _contract_id, _admin) = setup_test(); @@ -200,6 +275,7 @@ fn test_refund_invoice_success_within_window() { token_admin.mint(&merchant_account_id, &amount); env.ledger().set_timestamp(1_000); + client.finalize_invoice(&merchant, &invoice_id); mark_invoice_paid( &env, &shade_contract_id, @@ -244,6 +320,7 @@ fn test_refund_invoice_fails_after_refund_window() { merchant_account.initialize(&merchant, &shade_contract_id, &1_u64); env.ledger().set_timestamp(604_801); + client.finalize_invoice(&merchant, &invoice_id); mark_invoice_paid( &env, &shade_contract_id, @@ -271,9 +348,16 @@ fn test_void_invoice_success() { let description = String::from_str(&env, "Test Invoice"); let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); - // Verify invoice is Pending + // Verify invoice is Draft let invoice_before = client.get_invoice(&invoice_id); - assert_eq!(invoice_before.status, InvoiceStatus::Pending); + assert_eq!(invoice_before.status, InvoiceStatus::Draft); + + // Finalize + client.finalize_invoice(&merchant, &invoice_id); + + // Verify invoice is Pending before voiding + let invoice_before_pending = client.get_invoice(&invoice_id); + assert_eq!(invoice_before_pending.status, InvoiceStatus::Pending); // Void the invoice client.void_invoice(&merchant, &invoice_id); @@ -293,7 +377,7 @@ fn test_refund_invoice_fails_for_non_owner() { client.register_merchant(&other_merchant); let token = create_test_token(&env); - let payer = Address::generate(&env); + let _payer = Address::generate(&env); let invoice_id = client.create_invoice( &merchant, &String::from_str(&env, "Wrong owner"), @@ -307,17 +391,7 @@ fn test_refund_invoice_fails_for_non_owner() { merchant_account.initialize(&merchant, &shade_contract_id, &1_u64); env.ledger().set_timestamp(100); - mark_invoice_paid( - &env, - &shade_contract_id, - &merchant, - invoice_id, - &payer, - 90, - &merchant_account_id, - &client, - ); - + client.finalize_invoice(&merchant, &invoice_id); client.refund_invoice(&other_merchant, &invoice_id); } @@ -336,6 +410,7 @@ fn test_void_invoice_non_owner() { // Try to void with different merchant (should panic with NotAuthorized) let other_merchant = Address::generate(&env); client.register_merchant(&other_merchant); + client.finalize_invoice(&merchant, &invoice_id); client.void_invoice(&other_merchant, &invoice_id); } @@ -360,6 +435,7 @@ fn test_void_invoice_already_paid() { let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token); token_client.mint(&customer, &1000); + client.finalize_invoice(&merchant, &invoice_id); client.pay_invoice(&customer, &invoice_id); // Try to void paid invoice (should panic with InvalidInvoiceStatus) @@ -379,6 +455,7 @@ fn test_void_invoice_already_cancelled() { let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); // Void the invoice once + client.finalize_invoice(&merchant, &invoice_id); client.void_invoice(&merchant, &invoice_id); // Try to void again (should panic with InvalidInvoiceStatus) @@ -403,6 +480,7 @@ fn test_pay_cancelled_invoice() { let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); // Void the invoice + client.finalize_invoice(&merchant, &invoice_id); client.void_invoice(&merchant, &invoice_id); // Try to pay cancelled invoice (should panic with InvalidInvoiceStatus) @@ -439,6 +517,7 @@ fn test_amend_invoice_amount_success() { let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); // Amend the amount + client.finalize_invoice(&merchant, &invoice_id); client.amend_invoice(&merchant, &invoice_id, &Some(2000), &None); // Verify amount was updated @@ -461,6 +540,7 @@ fn test_amend_invoice_description_success() { // Amend the description let new_description = String::from_str(&env, "Updated Description"); + client.finalize_invoice(&merchant, &invoice_id); client.amend_invoice( &merchant, &invoice_id, @@ -488,6 +568,7 @@ fn test_amend_invoice_both_fields_success() { // Amend both amount and description let new_description = String::from_str(&env, "Updated"); + client.finalize_invoice(&merchant, &invoice_id); client.amend_invoice( &merchant, &invoice_id, @@ -563,6 +644,7 @@ fn test_amend_invoice_non_owner_fails() { // Try to amend with different merchant (should panic with NotAuthorized) let other_merchant = Address::generate(&env); client.register_merchant(&other_merchant); + client.finalize_invoice(&merchant, &invoice_id); client.amend_invoice(&other_merchant, &invoice_id, &Some(2000), &None); } @@ -579,6 +661,7 @@ fn test_amend_invoice_invalid_amount_fails() { let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); // Try to amend with invalid amount (should panic with InvalidAmount) + client.finalize_invoice(&merchant, &invoice_id); client.amend_invoice(&merchant, &invoice_id, &Some(0), &None); } @@ -595,6 +678,7 @@ fn test_amend_invoice_negative_amount_fails() { let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); // Try to amend with negative amount (should panic with InvalidAmount) + client.finalize_invoice(&merchant, &invoice_id); client.amend_invoice(&merchant, &invoice_id, &Some(-100), &None); } diff --git a/contracts/shade/src/tests/test_invoice_date_filtering.rs b/contracts/shade/src/tests/test_invoice_date_filtering.rs new file mode 100644 index 0000000..6c7d7ef --- /dev/null +++ b/contracts/shade/src/tests/test_invoice_date_filtering.rs @@ -0,0 +1,169 @@ +#![cfg(test)] + +use crate::shade::{Shade, ShadeClient}; +use crate::types::InvoiceFilter; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{Address, Env, String}; + +fn setup_test() -> (Env, ShadeClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Shade, ()); + let client = ShadeClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + (env, client, contract_id, admin) +} + +#[test] +fn test_invoice_date_filtering() { + let (env, client, _contract_id, _admin) = setup_test(); + + let merchant = Address::generate(&env); + client.register_merchant(&merchant); + + let token = Address::generate(&env); + let description = String::from_str(&env, "Test Invoice"); + let amount: i128 = 1000; + + // Create invoices at different timestamps + // Invoice 1: T = 100 + env.ledger().set_timestamp(100); + let id1 = client.create_invoice(&merchant, &description, &amount, &token, &None); + client.finalize_invoice(&merchant, &id1); + + // Invoice 2: T = 200 + env.ledger().set_timestamp(200); + let id2 = client.create_invoice(&merchant, &description, &amount, &token, &None); + client.finalize_invoice(&merchant, &id2); + + // Invoice 3: T = 300 + env.ledger().set_timestamp(300); + let id3 = client.create_invoice(&merchant, &description, &amount, &token, &None); + client.finalize_invoice(&merchant, &id3); + + // --- Start Date Filtering --- + + // Filter from T=150 (should include ID 2 and 3) + let filter_start_150 = InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: Some(150), + end_date: None, + }; + let results = client.get_invoices(&filter_start_150); + assert_eq!(results.len(), 2); + assert!(results.iter().any(|i| i.id == id2)); + assert!(results.iter().any(|i| i.id == id3)); + + // Filter from T=200 (Inclusive - should include ID 2 and 3) + let filter_start_200 = InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: Some(200), + end_date: None, + }; + let results = client.get_invoices(&filter_start_200); + assert_eq!(results.len(), 2); + assert!(results.iter().any(|i| i.id == id2)); + assert!(results.iter().any(|i| i.id == id3)); + + // Filter from T=201 (Exclusive - should only include ID 3) + let filter_start_201 = InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: Some(201), + end_date: None, + }; + let results = client.get_invoices(&filter_start_201); + assert_eq!(results.len(), 1); + assert_eq!(results.get(0).unwrap().id, id3); + + // --- End Date Filtering --- + + // Filter up to T=250 (should include ID 1 and 2) + let filter_end_250 = InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: None, + end_date: Some(250), + }; + let results = client.get_invoices(&filter_end_250); + assert_eq!(results.len(), 2); + assert!(results.iter().any(|i| i.id == id1)); + assert!(results.iter().any(|i| i.id == id2)); + + // Filter up to T=200 (Inclusive - should include ID 1 and 2) + let filter_end_200 = InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: None, + end_date: Some(200), + }; + let results = client.get_invoices(&filter_end_200); + assert_eq!(results.len(), 2); + assert!(results.iter().any(|i| i.id == id1)); + assert!(results.iter().any(|i| i.id == id2)); + + // Filter up to T=199 (Exclusive - should only include ID 1) + let filter_end_199 = InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: None, + end_date: Some(199), + }; + let results = client.get_invoices(&filter_end_199); + assert_eq!(results.len(), 1); + assert_eq!(results.get(0).unwrap().id, id1); + + // --- Range Filtering --- + + // Filter between T=150 and T=250 (should only include ID 2) + let filter_range = InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: Some(150), + end_date: Some(250), + }; + let results = client.get_invoices(&filter_range); + assert_eq!(results.len(), 1); + assert_eq!(results.get(0).unwrap().id, id2); + + // Filter between T=100 and T=300 (Inclusive - should include all) + let filter_range_full = InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: Some(100), + end_date: Some(300), + }; + let results = client.get_invoices(&filter_range_full); + assert_eq!(results.len(), 3); + + // Filter with no results + let filter_no_results = InvoiceFilter { + status: None, + merchant: None, + min_amount: None, + max_amount: None, + start_date: Some(400), + end_date: Some(500), + }; + let results = client.get_invoices(&filter_no_results); + assert_eq!(results.len(), 0); +} diff --git a/contracts/shade/src/tests/test_invoice_void.rs b/contracts/shade/src/tests/test_invoice_void.rs index 86301d7..0d02876 100644 --- a/contracts/shade/src/tests/test_invoice_void.rs +++ b/contracts/shade/src/tests/test_invoice_void.rs @@ -31,9 +31,15 @@ fn test_void_invoice_success() { let amount: i128 = 1000; let invoice_id = client.create_invoice(&merchant, &description, &amount, &token, &None); - // Verify invoice is Pending before voiding let invoice_before = client.get_invoice(&invoice_id); - assert_eq!(invoice_before.status, InvoiceStatus::Pending); + assert_eq!(invoice_before.status, InvoiceStatus::Draft); + + // Finalize the invoice + client.finalize_invoice(&merchant, &invoice_id); + + // Verify invoice is Pending before voiding + let invoice_before_pending = client.get_invoice(&invoice_id); + assert_eq!(invoice_before_pending.status, InvoiceStatus::Pending); // Void the invoice client.void_invoice(&merchant, &invoice_id); @@ -60,6 +66,7 @@ fn test_void_invoice_unauthorized_random_address() { // Try to void with random address (should panic with NotAuthorized) let random_address = Address::generate(&env); + client.finalize_invoice(&merchant, &invoice_id); client.void_invoice(&random_address, &invoice_id); } @@ -82,6 +89,7 @@ fn test_void_invoice_unauthorized_different_merchant() { let invoice_id = client.create_invoice(&merchant1, &description, &1000, &token, &None); // Try to void with different merchant (should panic with NotAuthorized) + client.finalize_invoice(&merchant1, &invoice_id); client.void_invoice(&merchant2, &invoice_id); } @@ -118,6 +126,8 @@ fn test_void_invoice_already_paid() { let description = String::from_str(&env, "Test Invoice"); let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); + client.finalize_invoice(&merchant, &invoice_id); + // Pay the invoice let customer = Address::generate(&env); let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token); @@ -161,6 +171,8 @@ fn test_pay_voided_invoice() { let description = String::from_str(&env, "Test Invoice"); let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); + client.finalize_invoice(&merchant, &invoice_id); + // Void the invoice client.void_invoice(&merchant, &invoice_id); @@ -240,6 +252,8 @@ fn test_void_refunded_invoice() { let description = String::from_str(&env, "Refundable Invoice"); let invoice_id = client.create_invoice(&merchant, &description, &1000, &token, &None); + client.finalize_invoice(&merchant, &invoice_id); + let customer = Address::generate(&env); let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token); token_client.mint(&customer, &1000); @@ -268,6 +282,11 @@ fn test_void_invoice_state_isolation() { let invoice_id_2 = client.create_invoice(&merchant, &description, &2000, &token, &None); let invoice_id_3 = client.create_invoice(&merchant, &description, &3000, &token, &None); + // Finalize non-cancelled ones if we want to check their Pending status + client.finalize_invoice(&merchant, &invoice_id_1); + client.finalize_invoice(&merchant, &invoice_id_2); + client.finalize_invoice(&merchant, &invoice_id_3); + // Void only the second invoice client.void_invoice(&merchant, &invoice_id_2); diff --git a/contracts/shade/src/tests/test_payment.rs b/contracts/shade/src/tests/test_payment.rs index ae034cf..eeda0ac 100644 --- a/contracts/shade/src/tests/test_payment.rs +++ b/contracts/shade/src/tests/test_payment.rs @@ -96,6 +96,7 @@ fn test_successful_payment_with_fee() { token_client.mint(&customer, &1000); // Customer pays invoice + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); // event assertion (merchant_id should be 1 for first merchant) @@ -151,6 +152,7 @@ fn test_payment_with_zero_fee() { token_client.mint(&customer, &1000); // Customer pays invoice + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); // Verify balances @@ -187,6 +189,7 @@ fn test_payment_with_maximum_fee() { token_client.mint(&customer, &1000); // Customer pays invoice + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); // Verify balances @@ -219,6 +222,7 @@ fn test_payment_rejects_expired_invoice() { token_client.mint(&customer, &1000); env.ledger().set_timestamp(expires_at); + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); } @@ -245,6 +249,7 @@ fn test_payment_invoice_already_paid() { token_client.mint(&customer, &2000); // Customer pays invoice first time + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); // Attempt to pay again (should panic with InvalidInvoiceStatus) @@ -274,6 +279,7 @@ fn test_payment_insufficient_funds() { token_client.mint(&customer, &500); // Customer attempts to pay invoice (should panic due to insufficient funds) + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); } @@ -304,6 +310,7 @@ fn test_payment_token_not_accepted() { token_client.mint(&customer, &1000); // Customer attempts to pay invoice (should panic - token not accepted) + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); } @@ -328,6 +335,7 @@ fn test_payment_merchant_account_not_set() { token_client.mint(&customer, &1000); // Customer attempts to pay invoice (should panic - merchant account not set) + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); } @@ -353,6 +361,7 @@ fn test_payment_payer_authorization() { token_client.mint(&customer, &1000); // Customer pays invoice (auth is automatically mocked) + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); // Verify payer is recorded in invoice @@ -386,6 +395,7 @@ fn test_payment_updates_invoice_timestamps() { token_client.mint(&customer, &1000); // Customer pays invoice + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); // Get invoice after payment @@ -419,6 +429,7 @@ fn test_fee_calculation_accuracy() { token_client.mint(&customer, &10000); // Customer pays invoice + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice(&customer, &invoice_id); // Verify balances with 1% fee @@ -446,6 +457,7 @@ fn test_partial_payment_two_equal_steps_reaches_paid() { let token_client = token::StellarAssetClient::new(&env, &token); token_client.mint(&customer, &1000); + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice_partial(&customer, &invoice_id, &500); let mid_invoice = shade_client.get_invoice(&invoice_id); assert_eq!(mid_invoice.status, InvoiceStatus::PartiallyPaid); @@ -482,6 +494,7 @@ fn test_partial_payment_collects_fees_proportionally_each_step() { let token_client = token::StellarAssetClient::new(&env, &token); token_client.mint(&customer, &1000); + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice_partial(&customer, &invoice_id, &500); let token_balance_client = token::TokenClient::new(&env, &token); assert_eq!(token_balance_client.balance(&shade_contract_id), 25); @@ -509,6 +522,7 @@ fn test_partial_payment_cannot_exceed_requested_amount() { let token_client = token::StellarAssetClient::new(&env, &token); token_client.mint(&customer, &1500); + shade_client.finalize_invoice(&merchant, &invoice_id); shade_client.pay_invoice_partial(&customer, &invoice_id, &700); shade_client.pay_invoice_partial(&customer, &invoice_id, &400); } diff --git a/contracts/shade/src/tests/test_refund.rs b/contracts/shade/src/tests/test_refund.rs index bfc0c82..bffc229 100644 --- a/contracts/shade/src/tests/test_refund.rs +++ b/contracts/shade/src/tests/test_refund.rs @@ -63,6 +63,7 @@ fn setup_paid_invoice(pay_timestamp: u64) -> RefundTestContext<'static> { token_mint.mint(&payer, &amount); env.ledger().set_timestamp(pay_timestamp); + client.finalize_invoice(&merchant, &invoice_id); client.pay_invoice(&payer, &invoice_id); RefundTestContext { @@ -343,6 +344,7 @@ fn test_partial_refund_with_fee() { token_mint.mint(&payer, &amount); env.ledger().set_timestamp(1_000); + client.finalize_invoice(&merchant, &invoice_id); client.pay_invoice(&payer, &invoice_id); let tok = token::TokenClient::new(&env, &token); diff --git a/contracts/shade/src/types.rs b/contracts/shade/src/types.rs index e7e456b..bccb24c 100644 --- a/contracts/shade/src/types.rs +++ b/contracts/shade/src/types.rs @@ -67,12 +67,13 @@ pub struct Invoice { #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] pub enum InvoiceStatus { - Pending = 0, - Paid = 1, - Cancelled = 2, - Refunded = 3, - PartiallyRefunded = 4, - PartiallyPaid = 5, + Draft = 0, + Pending = 1, + Paid = 2, + Cancelled = 3, + Refunded = 4, + PartiallyRefunded = 5, + PartiallyPaid = 6, } #[contracttype]