From 051b93a642c5911c4b45638f17b01abacc4bf33e Mon Sep 17 00:00:00 2001 From: Anuoluwapo25 Date: Thu, 26 Feb 2026 11:34:57 +0100 Subject: [PATCH] feat: Implement Draft Invoice Sta --- contracts/shade/src/components/invoice.rs | 42 ++++++++- contracts/shade/src/events.rs | 21 +++++ contracts/shade/src/interface.rs | 1 + contracts/shade/src/shade.rs | 5 ++ contracts/shade/src/tests/test_invoice.rs | 90 ++++++++++++++++--- .../shade/src/tests/test_invoice_void.rs | 23 ++++- contracts/shade/src/types.rs | 13 +-- 7 files changed, 172 insertions(+), 23 deletions(-) diff --git a/contracts/shade/src/components/invoice.rs b/contracts/shade/src/components/invoice.rs index 44004f9..2f1586f 100644 --- a/contracts/shade/src/components/invoice.rs +++ b/contracts/shade/src/components/invoice.rs @@ -1,3 +1,31 @@ +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); + + events::publish_invoice_finalized_event( + env, + invoice_id, + merchant_address.clone(), + env.ledger().timestamp(), + ); +} use crate::components::{access_control, admin, merchant, signature_util}; use crate::errors::ContractError; use crate::events; @@ -42,7 +70,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 +158,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 +239,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 +356,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 +377,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/test_invoice.rs b/contracts/shade/src/tests/test_invoice.rs index 644b2fa..adfef59 100644 --- a/contracts/shade/src/tests/test_invoice.rs +++ b/contracts/shade/src/tests/test_invoice.rs @@ -95,11 +95,18 @@ fn test_create_and_get_invoice_success() { let invoice_id = client.create_invoice(&merchant, &description, &amount, &token, &None); assert_eq!(invoice_id, 1); + let invoice = client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Draft); + + client.finalize_invoice(&merchant, &invoice_id); + assert_latest_invoice_event(&env, &contract_id, invoice_id, &merchant, amount, &token); 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 +114,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 +257,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 +302,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 +330,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); @@ -307,17 +373,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 +392,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); } @@ -403,6 +460,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 +497,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 +520,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 +548,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 +624,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 +641,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 +658,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_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/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]