Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions fluxapay/src/integration_test.rs
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -157,12 +157,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]
Expand Down
30 changes: 27 additions & 3 deletions fluxapay/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ pub enum Error {
AmountBelowMin = 21,
AmountAboveMax = 22,
InvalidExpiry = 23,
InvalidSettlement = 24,
DuplicateIdempotencyKey = 24,
}

Expand All @@ -155,6 +156,14 @@ pub struct AmountLimits {
pub max: Option<i128>,
}

/// 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),
Expand Down Expand Up @@ -1564,7 +1573,7 @@ impl PaymentProcessor {
env: Env,
operator: Address,
payment_id: String,
treasury_address: Address,
splits: Vec<SettlementSplit>,
) -> Result<(), Error> {
operator.require_auth();

Expand All @@ -1575,11 +1584,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()
Expand Down
129 changes: 128 additions & 1 deletion fluxapay/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<'_>) {
Expand Down Expand Up @@ -1870,6 +1870,43 @@ fn test_rejected_refunds_not_counted_in_cumulative_total() {
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::<u64>,
&None::<String>,
&None::<String>,
&None::<Address>,
&None::<String>,
);
client.verify_payment(
&oracle,
payment_id,
&BytesN::<32>::random(env),
&Address::generate(env),
&amount,
);
}

#[test]
fn test_settle_payment_single_split() {
// --- Idempotency key (client_token) tests ---

#[test]
Expand All @@ -1878,6 +1915,22 @@ fn test_create_payment_idempotency_retry_returns_same_payment() {
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 merchant_id = Address::generate(&env);
client.grant_role(&admin, &role_merchant(&env), &merchant_id);

Expand Down Expand Up @@ -1924,6 +1977,48 @@ fn test_create_payment_idempotency_different_payment_id_fails() {
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 merchant_id = Address::generate(&env);
client.grant_role(&admin, &role_merchant(&env), &merchant_id);

Expand Down Expand Up @@ -1969,6 +2064,38 @@ fn test_create_payment_without_client_token_allows_duplicate_id_error() {
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)));
let merchant_id = Address::generate(&env);
client.grant_role(&admin, &role_merchant(&env), &merchant_id);

Expand Down