From 5e8bd7fd37bb8fa0752512665aaefc3bdd3c53de Mon Sep 17 00:00:00 2001 From: olawaleakanbi035-maker Date: Mon, 27 Apr 2026 14:10:59 +0000 Subject: [PATCH] feat: support multi-account settlement splits in settle_payment - Add SettlementSplit{recipient, amount} contracttype - Add InvalidSettlement error (code 24) - Replace treasury_address param with Vec - Validate: non-empty, all amounts > 0, total == payment.amount - Update integration_test call site - Add 5 tests: single split, multi split, mismatch, empty, zero amount --- fluxapay/src/integration_test.rs | 10 +-- fluxapay/src/lib.rs | 30 ++++++- fluxapay/src/test.rs | 143 ++++++++++++++++++++++++++++++- 3 files changed, 174 insertions(+), 9 deletions(-) diff --git a/fluxapay/src/integration_test.rs b/fluxapay/src/integration_test.rs index 218c45f..624475f 100644 --- a/fluxapay/src/integration_test.rs +++ b/fluxapay/src/integration_test.rs @@ -1,11 +1,11 @@ use crate::{ merchant_registry::{KycTier, MerchantRegistry, MerchantRegistryClient}, DisputeStatus, PaymentProcessor, PaymentProcessorClient, PaymentStatus, RefundManager, - RefundManagerClient, RefundStatus, + RefundManagerClient, RefundStatus, SettlementSplit, }; use soroban_sdk::{ testutils::{Address as _, BytesN as _, Ledger as _}, - token, Address, BytesN, Env, String, Symbol, + token, vec, Address, BytesN, Env, String, Symbol, }; fn setup_integration( @@ -155,12 +155,12 @@ fn test_settlement_path() { &amount, ); - // Settle payment (Sweep to treasury) - payment_client.settle_payment(&operator, &payment_id, &treasury); + // Settle payment to treasury as a single split + let splits = vec![&env, SettlementSplit { recipient: treasury.clone(), amount }]; + payment_client.settle_payment(&operator, &payment_id, &splits); let payment_info = payment_client.get_payment(&payment_id); assert_eq!(payment_info.status, PaymentStatus::Settled); - assert_eq!(payment_info.deposit_address, treasury); } #[test] diff --git a/fluxapay/src/lib.rs b/fluxapay/src/lib.rs index 85fc5eb..cddfdcc 100644 --- a/fluxapay/src/lib.rs +++ b/fluxapay/src/lib.rs @@ -138,6 +138,7 @@ pub enum Error { AmountBelowMin = 21, AmountAboveMax = 22, InvalidExpiry = 23, + InvalidSettlement = 24, } #[contracttype] @@ -154,6 +155,14 @@ pub struct AmountLimits { pub max: Option, } +/// A single recipient in a multi-account settlement split. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SettlementSplit { + pub recipient: Address, + pub amount: i128, +} + #[contracttype] pub enum DataKey { Payment(String), @@ -1534,7 +1543,7 @@ impl PaymentProcessor { env: Env, operator: Address, payment_id: String, - treasury_address: Address, + splits: Vec, ) -> Result<(), Error> { operator.require_auth(); @@ -1545,11 +1554,26 @@ impl PaymentProcessor { let mut payment = Self::get_payment_internal(&env, &payment_id)?; if payment.status != PaymentStatus::Confirmed { - return Err(Error::PaymentAlreadyProcessed); // Or another appropriate error + return Err(Error::PaymentAlreadyProcessed); + } + + if splits.is_empty() { + return Err(Error::InvalidSettlement); + } + + // Verify split amounts are positive and total matches payment amount + let mut total: i128 = 0; + for split in splits.iter() { + if split.amount <= 0 { + return Err(Error::InvalidSettlement); + } + total = total.saturating_add(split.amount); + } + if total != payment.amount { + return Err(Error::InvalidSettlement); } payment.status = PaymentStatus::Settled; - payment.deposit_address = treasury_address; // "Sweep to treasury" env.storage() .persistent() diff --git a/fluxapay/src/test.rs b/fluxapay/src/test.rs index 731c636..381b81c 100644 --- a/fluxapay/src/test.rs +++ b/fluxapay/src/test.rs @@ -4,7 +4,7 @@ use super::*; use access_control::{role_admin, role_oracle, role_settlement_operator}; use soroban_sdk::{ testutils::{Address as _, BytesN as _, Events as _, Ledger as _}, - token, Address, BytesN, Env, String, Symbol, + token, vec, Address, BytesN, Env, String, Symbol, }; fn setup_payment_processor(env: &Env) -> (Address, PaymentProcessorClient<'_>) { @@ -1837,3 +1837,144 @@ fn test_rejected_refunds_not_counted_in_cumulative_total() { assert_eq!(refund.amount, payment_amount); assert_eq!(refund.status, RefundStatus::Pending); } + +// --- Multi-account settlement tests --- + +fn make_confirmed_payment( + env: &Env, + client: &PaymentProcessorClient, + admin: &Address, + payment_id: &String, + amount: i128, +) { + let merchant = Address::generate(env); + let oracle = Address::generate(env); + client.grant_role(admin, &role_merchant(env), &merchant); + client.grant_role(admin, &role_oracle(env), &oracle); + client.create_payment( + payment_id, + &merchant, + &amount, + &Symbol::new(env, "USDC"), + &Address::generate(env), + &Some(env.ledger().timestamp() + 3600), + &None::, + &None::, + &None::, + &None::
, + &None::, + ); + client.verify_payment( + &oracle, + payment_id, + &BytesN::<32>::random(env), + &Address::generate(env), + &amount, + ); +} + +#[test] +fn test_settle_payment_single_split() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client) = setup_payment_processor(&env); + + let payment_id = String::from_str(&env, "settle_single"); + let amount = 1000i128; + make_confirmed_payment(&env, &client, &admin, &payment_id, amount); + + let operator = Address::generate(&env); + client.grant_role(&admin, &role_settlement_operator(&env), &operator); + + let recipient = Address::generate(&env); + let splits = vec![&env, SettlementSplit { recipient, amount }]; + client.settle_payment(&operator, &payment_id, &splits); + + assert_eq!(client.get_payment(&payment_id).status, PaymentStatus::Settled); +} + +#[test] +fn test_settle_payment_multi_split() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client) = setup_payment_processor(&env); + + let payment_id = String::from_str(&env, "settle_multi"); + let amount = 1000i128; + make_confirmed_payment(&env, &client, &admin, &payment_id, amount); + + let operator = Address::generate(&env); + client.grant_role(&admin, &role_settlement_operator(&env), &operator); + + let splits = vec![ + &env, + SettlementSplit { recipient: Address::generate(&env), amount: 600 }, + SettlementSplit { recipient: Address::generate(&env), amount: 400 }, + ]; + client.settle_payment(&operator, &payment_id, &splits); + + assert_eq!(client.get_payment(&payment_id).status, PaymentStatus::Settled); +} + +#[test] +fn test_settle_payment_split_total_mismatch_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client) = setup_payment_processor(&env); + + let payment_id = String::from_str(&env, "settle_mismatch"); + let amount = 1000i128; + make_confirmed_payment(&env, &client, &admin, &payment_id, amount); + + let operator = Address::generate(&env); + client.grant_role(&admin, &role_settlement_operator(&env), &operator); + + // Total is 900, not 1000 — must fail + let splits = vec![ + &env, + SettlementSplit { recipient: Address::generate(&env), amount: 500 }, + SettlementSplit { recipient: Address::generate(&env), amount: 400 }, + ]; + let result = client.try_settle_payment(&operator, &payment_id, &splits); + assert_eq!(result, Err(Ok(Error::InvalidSettlement))); +} + +#[test] +fn test_settle_payment_empty_splits_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client) = setup_payment_processor(&env); + + let payment_id = String::from_str(&env, "settle_empty"); + let amount = 1000i128; + make_confirmed_payment(&env, &client, &admin, &payment_id, amount); + + let operator = Address::generate(&env); + client.grant_role(&admin, &role_settlement_operator(&env), &operator); + + let splits = vec![&env]; + let result = client.try_settle_payment(&operator, &payment_id, &splits); + assert_eq!(result, Err(Ok(Error::InvalidSettlement))); +} + +#[test] +fn test_settle_payment_zero_amount_split_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client) = setup_payment_processor(&env); + + let payment_id = String::from_str(&env, "settle_zero"); + let amount = 1000i128; + make_confirmed_payment(&env, &client, &admin, &payment_id, amount); + + let operator = Address::generate(&env); + client.grant_role(&admin, &role_settlement_operator(&env), &operator); + + let splits = vec![ + &env, + SettlementSplit { recipient: Address::generate(&env), amount: 1000 }, + SettlementSplit { recipient: Address::generate(&env), amount: 0 }, + ]; + let result = client.try_settle_payment(&operator, &payment_id, &splits); + assert_eq!(result, Err(Ok(Error::InvalidSettlement))); +}