diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index e71bd8a..ab61d56 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -142,6 +142,11 @@ fn delegate_key(invoice_id: u64) -> (Symbol, u64) { (symbol_short!("delegate"), invoice_id) } +/// Delegate-pay authorization key for a beneficiary. +fn delegate_pay_key(beneficiary: &Address) -> (Symbol, Address) { + (symbol_short!("delegate_pay"), beneficiary.clone()) +} + /// Analytics counters (issue #28). fn total_invoices_key() -> Symbol { symbol_short!("tot_inv") @@ -246,6 +251,13 @@ fn append_audit_entry(env: &Env, id: u64, action: Symbol, actor: &Address) { env.storage().persistent().set(&audit_log_key(id), &log); } +fn notify_invoice(env: &Env, invoice_id: u64, event: Symbol, notification_contract: &Option
) { + if let Some(contract) = notification_contract { + let args = (invoice_id, event).into_val(env); + let _ = env.invoke_contract(contract, &Symbol::new(env, "notify"), args); + } +} + pub fn get_audit_log(env: &Env, id: u64) -> Vec { env.storage() .persistent() @@ -609,6 +621,8 @@ impl SplitContract { options.tax_authority, options.insurance_premium_bps.unwrap_or(0), options.smart_route.unwrap_or(false), + options.notification_contract.clone(), + options.overflow_behavior.clone(), options.convert_to_stream, options.accepted_tokens, options.forward_to, @@ -650,6 +664,8 @@ impl SplitContract { tax_authority: Option
, insurance_premium_bps: u32, smart_route: bool, + notification_contract: Option
, + overflow_behavior: OverflowBehavior, convert_to_stream: bool, accepted_tokens: Vec
, forward_to: Option
, @@ -868,6 +884,8 @@ impl SplitContract { oracle_address, condition_met: false, smart_route, + overflow_behavior, + notification_contract, convert_to_stream, accepted_tokens, forward_to, @@ -1247,7 +1265,6 @@ impl SplitContract { invoice.amounts.iter().sum() }; let remaining = total - invoice.funded; - assert!(amount <= remaining, "payment exceeds remaining balance"); if invoice.require_kyc { let kyc_contract: Address = env @@ -1312,23 +1329,54 @@ impl SplitContract { } let token_client = token::Client::new(env, &invoice.tokens.get(0).expect("no token")); - - let premium = (amount as u128 * invoice.insurance_premium_bps as u128 / 10_000u128) as i128; - let total_charge = amount + premium; + + let credited_amount = match invoice.overflow_behavior { + OverflowBehavior::Reject => { + assert!(amount <= remaining, "payment exceeds remaining balance"); + amount + } + OverflowBehavior::Refund => { + if amount <= remaining { + amount + } else { + remaining + } + } + OverflowBehavior::Donate => { + if amount <= remaining { + amount + } else { + remaining + } + } + }; + + let premium = (credited_amount as u128 * invoice.insurance_premium_bps as u128 / 10_000u128) as i128; + let total_charge = credited_amount + premium; // Issue #88: Auto-convert if requested. - let credited_amount = if auto_convert { - // In production, this would call a DEX swap contract. - // For now, we assume a 1:1 swap and transfer the amount directly. - // Mock DEX swap: payer's source asset -> invoice token. - // The swapped amount is what gets credited. + if auto_convert { token_client.transfer(payer, &env.current_contract_address(), &total_charge); - amount // In a real implementation, this would be the swapped output amount. } else { token_client.transfer(payer, &env.current_contract_address(), &total_charge); - amount - }; - + } + + let excess = amount - credited_amount; + match invoice.overflow_behavior { + OverflowBehavior::Refund if excess > 0 => { + token_client.transfer(&env.current_contract_address(), payer, &excess); + } + OverflowBehavior::Donate if excess > 0 => { + let treasury: Address = env + .storage() + .instance() + .get(&treasury_key()) + .expect("treasury not set"); + token_client.transfer(&env.current_contract_address(), &treasury, &excess); + } + _ => {} + } + invoice.insurance_fund += premium; // Penalty for late payment (issue #42). @@ -1379,6 +1427,7 @@ impl SplitContract { append_audit_entry(env, invoice_id, symbol_short!("pay"), payer); events::payment_received(env, invoice_id, payer, credited_amount); + notify_invoice(env, invoice_id, symbol_short!("pay"), &invoice.notification_contract); // Issue: mint a receipt token to the payer via the receipt factory if configured. if let Some(factory) = env @@ -1389,7 +1438,7 @@ impl SplitContract { let mut args: Vec = Vec::new(env); args.push_back(invoice_id.into_val(env)); args.push_back(payer.clone().into_val(env)); - args.push_back(amount.into_val(env)); + args.push_back(credited_amount.into_val(env)); let receipt_addr: Address = env.invoke_contract( &factory, &Symbol::new(env, "mint_receipt"), @@ -1503,6 +1552,68 @@ impl SplitContract { append_audit_entry(&env, invoice_id, symbol_short!("pay_tok"), &payer); events::payment_received(&env, invoice_id, &payer, credited_amount); + notify_invoice(&env, invoice_id, symbol_short!("pay"), &invoice.notification_contract); + + if invoice.funded >= total { + let in_group = env.storage().persistent().has(&invoice_group_key(invoice_id)); + let guarded = + invoice.prerequisite_id.is_some() + || !invoice.tranches.is_empty() + || !invoice.release_stages.is_empty() + || in_group + || !invoice.co_signers.is_empty(); + if guarded { + save_invoice(&env, invoice_id, &invoice); + } else { + Self::_release(&env, invoice_id, &mut invoice, &payer); + } + } else { + save_invoice(&env, invoice_id, &invoice); + } + } + + /// Pay with an alternate token by swapping via the configured DEX contract. + /// The resulting invoice token amount is credited to the invoice. + pub fn bridge_pay( + env: Env, + payer: Address, + invoice_id: u64, + source_token: Address, + source_amount: i128, + ) { + require_not_paused(&env); + payer.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!(env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed"); + assert!(source_amount > 0, "payment amount must be positive"); + + let invoice_token = invoice.tokens.get(0).expect("no token"); + let src_client = token::Client::new(&env, &source_token); + src_client.transfer(&payer, &env.current_contract_address(), &source_amount); + + let dex: Address = env + .storage() + .persistent() + .get(&soroban_sdk::symbol_short!("dex_ctr")) + .expect("dex contract not set"); + let mut args: Vec = Vec::new(&env); + args.push_back(source_token.into_val(&env)); + args.push_back(invoice_token.clone().into_val(&env)); + args.push_back(source_amount.into_val(&env)); + let converted: i128 = env.invoke_contract(&dex, &Symbol::new(&env, "swap"), args); + + let total: i128 = invoice.amounts.iter().sum(); + let remaining = total - invoice.funded; + assert!(converted <= remaining, "payment exceeds remaining balance"); + + invoice.payments.push_back(Payment { payer: payer.clone(), amount: converted, tip: 0 }); + invoice.funded += converted; + + append_audit_entry(&env, invoice_id, symbol_short!("bridge_pay"), &payer); + events::payment_received(&env, invoice_id, &payer, converted); + notify_invoice(&env, invoice_id, symbol_short!("pay"), &invoice.notification_contract); if invoice.funded >= total { let in_group = env.storage().persistent().has(&invoice_group_key(invoice_id)); @@ -2002,6 +2113,7 @@ impl SplitContract { } append_audit_entry(env, invoice_id, symbol_short!("release"), actor); events::invoice_released(env, invoice_id, &invoice.recipients); + notify_invoice(env, invoice_id, symbol_short!("release"), &invoice.notification_contract); } save_invoice(env, invoice_id, invoice); @@ -2126,6 +2238,7 @@ impl SplitContract { } append_audit_entry(&env, invoice_id, symbol_short!("stg_rel"), &creator); events::invoice_released(&env, invoice_id, &invoice.recipients); + notify_invoice(&env, invoice_id, symbol_short!("release"), &invoice.notification_contract); } else { append_audit_entry(&env, invoice_id, symbol_short!("stg_rel"), &creator); } @@ -2480,6 +2593,7 @@ impl SplitContract { save_invoice(env, invoice_id, invoice); append_audit_entry(env, invoice_id, symbol_short!("release"), actor); events::invoice_released(env, invoice_id, &invoice.recipients); + notify_invoice(env, invoice_id, symbol_short!("release"), &invoice.notification_contract); // Increment total_volume and total_released counters (issue #28). let total_volume: i128 = env @@ -2631,6 +2745,7 @@ impl SplitContract { let actor = env.current_contract_address(); append_audit_entry(&env, invoice_id, symbol_short!("auto_ref"), &actor); events::invoice_refunded(&env, invoice_id); + notify_invoice(&env, invoice_id, symbol_short!("refund"), &invoice.notification_contract); let total_refunded: i128 = env .storage() .persistent() @@ -2711,6 +2826,7 @@ impl SplitContract { let actor = env.current_contract_address(); append_audit_entry(&env, invoice_id, symbol_short!("refund"), &actor); events::invoice_refunded(&env, invoice_id); + notify_invoice(&env, invoice_id, symbol_short!("refund"), &invoice.notification_contract); // Increment total_refunded counter (issue #28). let total_refunded: i128 = env @@ -3079,6 +3195,8 @@ impl SplitContract { old_invoice.tax_authority.clone(), old_invoice.insurance_premium_bps, old_invoice.smart_route, + old_invoice.notification_contract.clone(), + old_invoice.overflow_behavior.clone(), old_invoice.convert_to_stream, old_invoice.accepted_tokens.clone(), old_invoice.forward_to, @@ -3744,4 +3862,78 @@ impl SplitContract { .persistent() .get(&delegate_key(invoice_id)) } + + /// Authorise an address to pay on behalf of the beneficiary. + /// Requires beneficiary auth. + pub fn authorise_delegate(env: Env, beneficiary: Address, delegate: Address) { + require_not_paused(&env); + beneficiary.require_auth(); + + let mut delegates: Vec
= env + .storage() + .persistent() + .get(&delegate_pay_key(&beneficiary)) + .unwrap_or_else(|| Vec::new(&env)); + + if !delegates.iter().any(|d| d == delegate) { + delegates.push_back(delegate.clone()); + env.storage().persistent().set(&delegate_pay_key(&beneficiary), &delegates); + } + } + + /// Pay toward an invoice using an authorised delegate. + /// The invoice records the beneficiary as the payer. + pub fn delegate_pay( + env: Env, + delegate: Address, + beneficiary: Address, + invoice_id: u64, + amount: i128, + ) { + require_not_paused(&env); + delegate.require_auth(); + + let delegates: Vec
= env + .storage() + .persistent() + .get(&delegate_pay_key(&beneficiary)) + .unwrap_or_else(|| Vec::new(&env)); + assert!(delegates.iter().any(|d| d == delegate), "not authorised"); + + let mut invoice = load_invoice(&env, invoice_id); + assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!(env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed"); + assert!(amount > 0, "payment amount must be positive"); + + let total: i128 = invoice.amounts.iter().sum(); + let remaining = total - invoice.funded; + assert!(amount <= remaining, "payment exceeds remaining balance"); + + let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); + token_client.transfer(&delegate, &env.current_contract_address(), &amount); + + invoice.payments.push_back(Payment { payer: beneficiary.clone(), amount, tip: 0 }); + invoice.funded += amount; + + append_audit_entry(&env, invoice_id, symbol_short!("del_pay"), &delegate); + events::payment_received(&env, invoice_id, &beneficiary, amount); + notify_invoice(&env, invoice_id, symbol_short!("pay"), &invoice.notification_contract); + + let in_group = env.storage().persistent().has(&invoice_group_key(invoice_id)); + let guarded = + invoice.prerequisite_id.is_some() + || !invoice.tranches.is_empty() + || !invoice.release_stages.is_empty() + || in_group + || !invoice.co_signers.is_empty(); + if invoice.funded >= total { + if guarded { + save_invoice(&env, invoice_id, &invoice); + } else { + Self::_release(&env, invoice_id, &mut invoice, &delegate); + } + } else { + save_invoice(&env, invoice_id, &invoice); + } + } } diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 86caa1e..740d473 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -4,7 +4,7 @@ use super::*; use soroban_sdk::{ testutils::{Address as _, Ledger}, token::{Client as TokenClient, StellarAssetClient}, - Address, Env, Vec, + Address, Env, Symbol, Vec, }; use types::InvoiceOptions; @@ -58,6 +58,8 @@ fn default_options(env: &Env) -> InvoiceOptions { tax_authority: None, insurance_premium_bps: None, smart_route: None, + notification_contract: None, + overflow_behavior: types::OverflowBehavior::Reject, convert_to_stream: false, accepted_tokens: Vec::new(env), forward_to: None, @@ -855,7 +857,7 @@ fn test_pause_blocks_pay() { env.ledger().set_timestamp(1_000); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); c.pause(&admin); @@ -876,7 +878,7 @@ fn test_unpause_restores_pay() { env.ledger().set_timestamp(1_000); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); let id = c.create_invoice( @@ -908,7 +910,7 @@ fn test_allowed_payers_unlisted_address_rejected() { env.ledger().set_timestamp(1_000); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); c.pause(&admin); @@ -1587,7 +1589,7 @@ fn test_creation_fee_charged_to_treasury() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &50_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &50_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); assert_eq!(c.get_creation_fee(), 50); assert_eq!(c.get_treasury(), treasury); @@ -1618,7 +1620,7 @@ fn test_creation_fee_zero_by_default() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); @@ -1636,7 +1638,7 @@ fn test_set_creation_fee_updates_fee() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &10_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &10_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); assert_eq!(c.get_creation_fee(), 10); c.set_creation_fee(&admin, &25_i128); @@ -1652,7 +1654,7 @@ fn test_set_treasury_updates_treasury() { let treasury1 = Address::generate(&env); let treasury2 = Address::generate(&env); - c.initialize(&admin, &10_i128, &treasury1, &token_id, &0_u32, &None); + c.initialize(&admin, &10_i128, &treasury1, &token_id, &0_u32, &None, &None, &0_u32); assert_eq!(c.get_treasury(), treasury1); c.set_treasury(&admin, &treasury2); @@ -1675,7 +1677,7 @@ fn test_creation_fee_charged_per_invoice_in_batch() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &10_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &10_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); // create_batch creates 2 invoices, each should incur a 10 unit fee. let mut recipients = Vec::new(&env); @@ -2063,7 +2065,7 @@ fn test_platform_fee_bps_defaults_to_zero() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); assert_eq!(c.get_platform_fee_bps(), 0); } @@ -2085,7 +2087,7 @@ fn test_platform_fee_bps_deducted_on_release() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None); // 10% + c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &None, &0_u32); // 10% let id = make_invoice(&env, &c, &creator, &recipient, 500, &token_id, 9_999); c.pay(&payer, &id, &500_i128, &0_u64, &false); @@ -2116,7 +2118,7 @@ fn test_platform_fee_bps_multi_recipient() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &500_u32, &None); // 5% + c.initialize(&admin, &0_i128, &treasury, &token_id, &500_u32, &None, &None, &0_u32); // 5% let mut recipients = Vec::new(&env); recipients.push_back(r1.clone()); @@ -2158,7 +2160,7 @@ fn test_platform_fee_bps_with_tranches() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None); // 10% + c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &None, &0_u32); // 10% let mut tranches = Vec::new(&env); tranches.push_back(types::Tranche { timestamp: 1_500, basis_points: 5_000 }); @@ -3451,7 +3453,7 @@ fn test_governance_approval() { let gov_id = env.register(MockGovernance, ()); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &Some(gov_id)); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &Some(gov_id), &0_u32); env.ledger().set_timestamp(1_000); @@ -3473,7 +3475,7 @@ fn test_governance_rejection() { let gov_id = env.register(MockGovernance, ()); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &Some(gov_id)); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &Some(gov_id), &0_u32); env.ledger().set_timestamp(1_000); @@ -3561,7 +3563,7 @@ fn test_convert_to_stream_calls_stream_contract() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); let stream_id = env.register(MockStream, ()); c.set_stream_contract(&admin, &stream_id); @@ -3626,6 +3628,194 @@ impl MockDex { } } +#[contract] +struct MockNotification; + +#[contractimpl] +impl MockNotification { + pub fn notify(env: Env, invoice_id: u64, event: Symbol) { + let key = (symbol_short!("notif"), invoice_id, event.clone()); + env.storage().persistent().set(&key, &true); + } + + pub fn was_notified(env: Env, invoice_id: u64, event: Symbol) -> bool { + let key = (symbol_short!("notif"), invoice_id, event.clone()); + env.storage() + .persistent() + .get(&key) + .unwrap_or(false) + } +} + +#[test] +fn test_authorise_delegate_and_delegate_pay_records_beneficiary_as_payer() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let beneficiary = Address::generate(&env); + let delegate = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&delegate, &200); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + c.authorise_delegate(&beneficiary, &delegate); + c.delegate_pay(&delegate, &beneficiary, &id, &100_i128); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.funded, 100); + assert_eq!(invoice.payments.get(0).unwrap().payer, beneficiary); + assert_eq!(invoice.payments.get(0).unwrap().amount, 100); + assert_eq!(tk.balance(&recipient), 100); +} + +#[test] +#[should_panic(expected = "not authorised")] +fn test_delegate_pay_unauthorised_panics() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let beneficiary = Address::generate(&env); + let unauthorized = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&unauthorized, &200); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + c.delegate_pay(&unauthorized, &beneficiary, &id, &100_i128); +} + +#[test] +fn test_overflow_behavior_refund_accepts_excess() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &200); + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + + let mut opts = default_options(&env); + opts.overflow_behavior = types::OverflowBehavior::Refund; + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + c.pay(&payer, &id, &200_i128, &0_u64, &false); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.funded, 100); + assert_eq!(tk.balance(&payer), 100); +} + +#[test] +fn test_overflow_behavior_donate_sends_excess_to_treasury() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); + StellarAssetClient::new(&env, &token_id).mint(&payer, &200); + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + + let mut opts = default_options(&env); + opts.overflow_behavior = types::OverflowBehavior::Donate; + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + c.pay(&payer, &id, &200_i128, &0_u64, &false); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.funded, 100); + assert_eq!(tk.balance(&treasury), 100); +} + +#[test] +fn test_bridge_pay_credits_invoice_after_swap() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + let treasury = Address::generate(&env); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); + + let alt_token_admin = Address::generate(&env); + let alt_token_id = env + .register_stellar_asset_contract_v2(alt_token_admin.clone()) + .address(); + StellarAssetClient::new(&env, &alt_token_id).mint(&payer, &300); + + let dex_id = env.register(MockDex, ()); + c.set_dex_contract(&admin, &dex_id); + + env.ledger().set_timestamp(1_000); + let id = make_invoice(&env, &c, &creator, &recipient, 300, &token_id, 9_999); + + c.bridge_pay(&payer, &id, &alt_token_id, &300_i128); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.funded, 300); +} + +#[test] +fn test_notification_contract_receives_pay_release_and_refund() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + let notifier_id = env.register(MockNotification, ()); + let notifier = MockNotificationClient::new(&env, ¬ifier_id); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &200); + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + + let mut opts = default_options(&env); + opts.notification_contract = Some(notifier_id.clone()); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + c.pay(&payer, &id, &100_i128, &0_u64, &false); + + assert!(notifier.was_notified(&id, &symbol_short!("pay"))); + assert!(notifier.was_notified(&id, &symbol_short!("release"))); + + let id2 = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + env.ledger().set_timestamp(12_000); + c.refund(&id2); + assert!(notifier.was_notified(&id2, &symbol_short!("refund"))); +} + #[test] fn test_pay_with_token_accepted_token_credited() { let (env, contract_id, token_id) = setup(); @@ -3637,7 +3827,7 @@ fn test_pay_with_token_accepted_token_credited() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); // Register alternate token and DEX. let alt_token_admin = Address::generate(&env); @@ -3778,7 +3968,7 @@ fn test_whitelist_empty_allows_any_creator() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); env.ledger().set_timestamp(1_000); // No whitelist set — any creator may create. @@ -3798,7 +3988,7 @@ fn test_non_whitelisted_creator_rejected() { let not_whitelisted = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); c.whitelist_creator(&admin, &whitelisted); env.ledger().set_timestamp(1_000); @@ -3817,7 +4007,7 @@ fn test_whitelisted_creator_can_create() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); c.whitelist_creator(&admin, &creator); env.ledger().set_timestamp(1_000); @@ -3836,7 +4026,7 @@ fn test_remove_creator_from_whitelist() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &None, &0_u32); c.whitelist_creator(&admin, &creator); c.remove_creator(&admin, &creator); diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index c8aa14f..bb02438 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -29,6 +29,14 @@ pub struct ResolveRule { pub action: ResolveAction, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum OverflowBehavior { + Reject, + Refund, + Donate, +} + /// Issue #: A single (invoice_id, amount) pair for pool_pay. #[contracttype] #[derive(Clone, Debug)] @@ -174,6 +182,8 @@ pub struct InvoiceOptions { pub tax_authority: Option
, pub insurance_premium_bps: Option, pub smart_route: Option, + pub notification_contract: Option
, + pub overflow_behavior: OverflowBehavior, /// Issue #1: when true, _release() registers funds with the stream contract instead of direct transfer. pub convert_to_stream: bool, /// Issue #2: tokens accepted in pay_with_token(); base token is always accepted implicitly.