diff --git a/abis/invoice.json b/abis/invoice.json index fadcef7..5928d00 100644 --- a/abis/invoice.json +++ b/abis/invoice.json @@ -13,7 +13,8 @@ "unpause", "set_grace_window", "get_grace_window", - "release_escrow" + "release_escrow", + "approve_refund" ], "events": [ "invoice_created", @@ -23,6 +24,7 @@ "invoice_refund_req", "escrow_released", "contract_paused", - "contract_unpaused" + "contract_unpaused", + "refund_approved" ] } diff --git a/contracts/invoice/src/events.rs b/contracts/invoice/src/events.rs index cae32c8..0b1904c 100644 --- a/contracts/invoice/src/events.rs +++ b/contracts/invoice/src/events.rs @@ -54,6 +54,11 @@ pub fn invoice_refund_requested(env: &Env, id: u64, invoice: &Invoice) { ); } +pub fn refund_approved(env: &Env, id: u64, invoice: &Invoice) { + env.events() + .publish((Symbol::new(env, "refund_approved"), id), invoice.clone()); +} + pub fn escrow_released(env: &Env, id: u64, invoice: &Invoice) { env.events() .publish((Symbol::new(env, "escrow_released"), id), invoice.clone()); diff --git a/contracts/invoice/src/invoice.rs b/contracts/invoice/src/invoice.rs index 415dc02..7fe96fc 100644 --- a/contracts/invoice/src/invoice.rs +++ b/contracts/invoice/src/invoice.rs @@ -43,6 +43,8 @@ pub enum InvoiceStatus { RefundRequested, /// Escrow funds have been released to the merchant after payment confirmation. Released, + /// Refund has been approved by admin; terminal status for disputed invoices. + Refunded, } // contracttype enum wrappers for optional complex types; Option
and diff --git a/contracts/invoice/src/lib.rs b/contracts/invoice/src/lib.rs index cc1bd08..c092c1a 100644 --- a/contracts/invoice/src/lib.rs +++ b/contracts/invoice/src/lib.rs @@ -435,6 +435,30 @@ impl InvoiceContract { Ok(()) } + /// Approve a refund request. Admin-only. Transitions RefundRequested → Refunded. + pub fn approve_refund(env: Env, admin: Address, id: u64) -> Result<(), InvoiceError> { + require_admin(&env, &admin)?; + require_not_paused(&env)?; + + let mut invoice: Invoice = env + .storage() + .persistent() + .get(&DataKey::Invoice(id)) + .ok_or(InvoiceError::NotFound)?; + + if invoice.status != InvoiceStatus::RefundRequested { + return Err(InvoiceError::NotRefundRequested); + } + + invoice.status = InvoiceStatus::Refunded; + env.storage() + .persistent() + .set(&DataKey::Invoice(id), &invoice); + append_history(&env, id, InvoiceStatus::RefundRequested, InvoiceStatus::Refunded); + events::refund_approved(&env, id, &invoice); + Ok(()) + } + // --- #9: paginated merchant invoice index read --- /// Return a page of invoice IDs for `merchant`. diff --git a/contracts/invoice/tests/invoice_test.rs b/contracts/invoice/tests/invoice_test.rs index f5c19dd..04b3ff1 100644 --- a/contracts/invoice/tests/invoice_test.rs +++ b/contracts/invoice/tests/invoice_test.rs @@ -614,6 +614,7 @@ fn test_abi_snapshot_matches_contract() { "set_grace_window", "get_grace_window", "release_escrow", + "approve_refund", ] .iter() .copied() @@ -628,6 +629,7 @@ fn test_abi_snapshot_matches_contract() { "escrow_released", "contract_paused", "contract_unpaused", + "refund_approved", ] .iter() .copied() @@ -966,3 +968,68 @@ fn test_request_refund_only_recorded_payer_can_call() { assert_eq!(client.get_invoice(&id).status, InvoiceStatus::RefundRequested); } +// --- approve_refund tests --- + +#[test] +fn test_approve_refund_transitions_to_refunded() { + let (env, admin, client) = setup(); + let merchant = Address::generate(&env); + let payer = Address::generate(&env); + let id = client.create_invoice( + &merchant, + &10_000_000, + &10_250_000, + &3600, + &MaybeBytes::None, + &MaybeBytes::None, + &0, + ); + client.mark_paid(&admin, &id, &payer, &MaybeBytes::None); + client.request_refund(&payer, &id); + assert_eq!(client.get_invoice(&id).status, InvoiceStatus::RefundRequested); + client.approve_refund(&admin, &id); + assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Refunded); +} + +#[test] +fn test_approve_refund_requires_admin() { + let (env, admin, client) = setup(); + let merchant = Address::generate(&env); + let payer = Address::generate(&env); + let rogue = Address::generate(&env); + let id = client.create_invoice( + &merchant, + &10_000_000, + &10_250_000, + &3600, + &MaybeBytes::None, + &MaybeBytes::None, + &0, + ); + client.mark_paid(&admin, &id, &payer, &MaybeBytes::None); + client.request_refund(&payer, &id); + let err = client.try_approve_refund(&rogue, &id).unwrap_err().unwrap(); + assert_eq!(err, InvoiceError::Unauthorized); + assert_eq!(client.get_invoice(&id).status, InvoiceStatus::RefundRequested); +} + +#[test] +fn test_approve_refund_requires_refund_requested_status() { + let (env, admin, client) = setup(); + let merchant = Address::generate(&env); + let payer = Address::generate(&env); + let id = client.create_invoice( + &merchant, + &10_000_000, + &10_250_000, + &3600, + &MaybeBytes::None, + &MaybeBytes::None, + &0, + ); + client.mark_paid(&admin, &id, &payer, &MaybeBytes::None); + // Invoice is Paid, not RefundRequested → should fail. + let err = client.try_approve_refund(&admin, &id).unwrap_err().unwrap(); + assert_eq!(err, InvoiceError::NotRefundRequested); + assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Paid); +}