From d0667670280acde7a6b116f06d8ce5b8f4c1e4fa Mon Sep 17 00:00:00 2001 From: Marco Tormento Date: Mon, 4 May 2026 13:16:09 +0200 Subject: [PATCH 1/5] Added submerchants field everywhere is needed --- examples/create_payment.rs | 1 + src/apis/payments/api.rs | 87 +++++++++++++++- src/apis/payments/model.rs | 42 ++++++++ src/apis/payouts/api.rs | 64 +++++++++++- src/apis/payouts/model.rs | 4 +- src/authenticator.rs | 6 +- src/lib.rs | 3 +- src/middlewares/retry_idempotent.rs | 2 +- tests/common/mock_server/middlewares.rs | 2 +- tests/integration_tests/helpers.rs | 1 + tests/integration_tests/payments.rs | 117 +++++++++++++++++++-- tests/integration_tests/payouts.rs | 129 +++++++++++++++++++++++- 12 files changed, 437 insertions(+), 21 deletions(-) diff --git a/examples/create_payment.rs b/examples/create_payment.rs index 6cf94c3..7ebf6c3 100644 --- a/examples/create_payment.rs +++ b/examples/create_payment.rs @@ -86,6 +86,7 @@ async fn run() -> anyhow::Result<()> { phone: None, }, metadata: None, + sub_merchants: None, }) .await?; diff --git a/src/apis/payments/api.rs b/src/apis/payments/api.rs index 684d323..b3885e0 100644 --- a/src/apis/payments/api.rs +++ b/src/apis/payments/api.rs @@ -406,13 +406,13 @@ mod tests { apis::{ auth::Credentials, payments::{ - refunds::RefundStatus, AdditionalInputType, AuthorizationFlowNextAction, + refunds::RefundStatus, AdditionalInputType, Address, AuthorizationFlowNextAction, AuthorizationFlowResponseStatus, Beneficiary, ConsentSupported, CountryCode, CreatePaymentStatus, CreatePaymentUserRequest, Currency, FailureStage, FormSupported, PaymentMethod, PaymentMethodRequest, PaymentStatus, Provider, ProviderSelection, ProviderSelectionRequest, ProviderSelectionSupported, - RedirectSupported, SchemeSelection, SubmitProviderReturnParametersResponseResource, - User, + RedirectSupported, SchemeSelection, SubMerchants, + SubmitProviderReturnParametersResponseResource, UltimateCounterparty, User, }, }, authenticator::Authenticator, @@ -517,6 +517,7 @@ mod tests { id: "user-id".to_string(), }, metadata: None, + sub_merchants: None, }) .await .unwrap(); @@ -527,6 +528,86 @@ mod tests { assert_eq!(res.status, CreatePaymentStatus::AuthorizationRequired) } + #[tokio::test] + async fn create_with_sub_merchants() { + let (inner, mock_server) = mock_client_and_server().await; + let api = PaymentsApi::new(Arc::new(inner)); + + Mock::given(method("POST")) + .and(path("/payments")) + .and(header_exists(IDEMPOTENCY_KEY_HEADER)) + .and(body_partial_json(json!({ + "sub_merchants": { + "ultimate_counterparty": { + "type": "business_client", + "id": "client-id", + "trading_name": "Test Trading", + "commercial_name": "Test Commercial", + "mcc": "5411", + "address": { + "address_line1": "1 Test Street", + "city": "London", + "zip": "EC1A 1BB", + "country_code": "GB" + } + } + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "payment-id", + "resource_token": "resource-token", + "user": { "id": "user-id" }, + "status": "authorization_required" + }))) + .expect(1) + .mount(&mock_server) + .await; + + let res = api + .create(&CreatePaymentRequest { + amount_in_minor: 100, + currency: Currency::Gbp, + payment_method: PaymentMethodRequest::BankTransfer { + provider_selection: ProviderSelectionRequest::UserSelected { + filter: None, + scheme_selection: None, + }, + beneficiary: Beneficiary::MerchantAccount { + merchant_account_id: "merchant-account-id".to_string(), + account_holder_name: None, + reference: None, + statement_reference: None, + }, + }, + user: CreatePaymentUserRequest::ExistingUser { + id: "user-id".to_string(), + }, + metadata: None, + sub_merchants: Some(SubMerchants { + ultimate_counterparty: UltimateCounterparty::BusinessClient { + id: "client-id".to_string(), + trading_name: "Test Trading".to_string(), + commercial_name: Some("Test Commercial".to_string()), + url: None, + mcc: Some("5411".to_string()), + registration_number: None, + address: Some(Box::new(Address { + address_line1: "1 Test Street".to_string(), + address_line2: None, + city: "London".to_string(), + state: None, + zip: "EC1A 1BB".to_string(), + country_code: "GB".to_string(), + })), + }, + }), + }) + .await + .unwrap(); + + assert_eq!(res.id, "payment-id"); + } + #[tokio::test] async fn start_authorization_flow() { let (inner, mock_server) = mock_client_and_server().await; diff --git a/src/apis/payments/model.rs b/src/apis/payments/model.rs index ae76d5f..3701b28 100644 --- a/src/apis/payments/model.rs +++ b/src/apis/payments/model.rs @@ -15,6 +15,48 @@ pub struct CreatePaymentRequest { pub payment_method: PaymentMethodRequest, pub user: CreatePaymentUserRequest, pub metadata: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub_merchants: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +pub struct SubMerchants { + pub ultimate_counterparty: UltimateCounterparty, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum UltimateCounterparty { + BusinessDivision { + id: String, + name: String, + }, + BusinessClient { + id: String, + trading_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + commercial_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + registration_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + address: Option>, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +pub struct Address { + pub address_line1: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line2: Option, + pub city: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + pub zip: String, + pub country_code: String, } #[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] diff --git a/src/apis/payouts/api.rs b/src/apis/payouts/api.rs index 42e7f4f..669d67b 100644 --- a/src/apis/payouts/api.rs +++ b/src/apis/payouts/api.rs @@ -93,7 +93,7 @@ mod tests { use crate::{ apis::{ auth::Credentials, - payments::{AccountIdentifier, Currency}, + payments::{AccountIdentifier, Currency, SubMerchants, UltimateCounterparty}, payouts::{PayoutBeneficiary, PayoutStatus}, }, authenticator::Authenticator, @@ -175,6 +175,68 @@ mod tests { }, reference: "some-reference".to_string(), }, + sub_merchants: None, + }) + .await + .unwrap(); + + assert_eq!(res.id, "payout-id"); + } + + #[tokio::test] + async fn create_with_sub_merchants() { + let (inner, mock_server) = mock_client_and_server().await; + let api = PayoutsApi::new(Arc::new(inner)); + + Mock::given(method("POST")) + .and(path("/payouts")) + .and(header_exists(IDEMPOTENCY_KEY_HEADER)) + .and(body_partial_json(json!({ + "merchant_account_id": "merchant-account-id", + "amount_in_minor": 100, + "currency": "GBP", + "beneficiary": { + "type": "external_account", + "account_holder_name": "Mr. Holder", + "account_identifier": { + "type": "iban", + "iban": "some-iban" + }, + "reference": "some-reference" + }, + "sub_merchants": { + "ultimate_counterparty": { + "type": "business_division", + "id": "division-id", + "name": "Division Name" + } + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "payout-id" + }))) + .expect(1) + .mount(&mock_server) + .await; + + let res = api + .create(&CreatePayoutRequest { + merchant_account_id: "merchant-account-id".to_string(), + amount_in_minor: 100, + currency: Currency::Gbp, + beneficiary: PayoutBeneficiary::ExternalAccount { + account_holder_name: "Mr. Holder".to_string(), + account_identifier: AccountIdentifier::Iban { + iban: "some-iban".to_string(), + }, + reference: "some-reference".to_string(), + }, + sub_merchants: Some(SubMerchants { + ultimate_counterparty: UltimateCounterparty::BusinessDivision { + id: "division-id".to_string(), + name: "Division Name".to_string(), + }, + }), }) .await .unwrap(); diff --git a/src/apis/payouts/model.rs b/src/apis/payouts/model.rs index a563793..aa2b08c 100644 --- a/src/apis/payouts/model.rs +++ b/src/apis/payouts/model.rs @@ -1,5 +1,5 @@ use crate::{ - apis::payments::{AccountIdentifier, Currency}, + apis::payments::{AccountIdentifier, Currency, SubMerchants}, pollable::IsInTerminalState, Error, Pollable, TrueLayerClient, }; @@ -14,6 +14,8 @@ pub struct CreatePayoutRequest { pub amount_in_minor: u64, pub currency: Currency, pub beneficiary: PayoutBeneficiary, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub_merchants: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/src/authenticator.rs b/src/authenticator.rs index cd7eec4..a9b78d0 100644 --- a/src/authenticator.rs +++ b/src/authenticator.rs @@ -143,9 +143,9 @@ async fn process_get_access_token( /// Returns `true` if the token is close to expiration (10 minutes before actual expiration) /// and should be refreshed. If this token does not expire, this function always returns `false`. fn should_refresh_token(token: &AccessToken) -> bool { - token.expires_at.map_or(false, |expires_at| { - now() >= expires_at - Duration::minutes(10) - }) + token + .expires_at + .is_some_and(|expires_at| now() >= expires_at - Duration::minutes(10)) } // Select an implementation of `now()` depending on whether we are testing or not diff --git a/src/lib.rs b/src/lib.rs index 2e77043..126c58a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,7 +73,8 @@ //! email: Some("some.one@email.com".to_string()), //! phone: None, //! }, -//! metadata: None +//! metadata: None, +//! sub_merchants: None, //! }) //! .await?; //! diff --git a/src/middlewares/retry_idempotent.rs b/src/middlewares/retry_idempotent.rs index 403d5bc..dd4b513 100644 --- a/src/middlewares/retry_idempotent.rs +++ b/src/middlewares/retry_idempotent.rs @@ -48,7 +48,7 @@ impl Middleware for RetryIdempotentMiddleware { Method::POST | Method::PATCH => req .headers() .get(IDEMPOTENCY_KEY_HEADER) - .map_or(false, |v| !v.is_empty()), + .is_some_and(|v| !v.is_empty()), _ => false, }; diff --git a/tests/common/mock_server/middlewares.rs b/tests/common/mock_server/middlewares.rs index 4b93763..fd3faf6 100644 --- a/tests/common/mock_server/middlewares.rs +++ b/tests/common/mock_server/middlewares.rs @@ -45,7 +45,7 @@ pub(super) async fn ensure_idempotency_key(req: &mut ServiceRequest) -> Result<( .get("Idempotency-Key") .map(|v| v.to_str()) .transpose()? - .map_or(false, |v| !v.is_empty()), + .is_some_and(|v| !v.is_empty()), "Invalid or missing Idempotency Key" ); diff --git a/tests/integration_tests/helpers.rs b/tests/integration_tests/helpers.rs index 524fd58..b2ccf52 100644 --- a/tests/integration_tests/helpers.rs +++ b/tests/integration_tests/helpers.rs @@ -44,6 +44,7 @@ pub async fn create_closed_loop_payment( phone: None, }, metadata: None, + sub_merchants: None, }) .await?; Ok(res) diff --git a/tests/integration_tests/payments.rs b/tests/integration_tests/payments.rs index 6846fda..3ba1125 100644 --- a/tests/integration_tests/payments.rs +++ b/tests/integration_tests/payments.rs @@ -7,14 +7,15 @@ use std::{collections::HashMap, time::Duration}; use test_case::test_case; use truelayer_rust::{ apis::payments::{ - AccountIdentifier, AdditionalInputType, AuthorizationFlow, AuthorizationFlowActions, - AuthorizationFlowNextAction, AuthorizationFlowResponseStatus, Beneficiary, - ConsentSupported, CreatePaymentRequest, CreatePaymentStatus, CreatePaymentUserRequest, - Currency, FailureStage, FormSupported, PaymentMethodRequest, PaymentStatus, - ProviderSelectionRequest, ProviderSelectionSupported, RedirectSupported, - StartAuthorizationFlowRequest, StartAuthorizationFlowResponse, SubmitFormActionRequest, - SubmitProviderReturnParametersRequest, SubmitProviderReturnParametersResponseResource, - SubmitProviderSelectionActionRequest, + AccountIdentifier, AdditionalInputType, Address, AuthorizationFlow, + AuthorizationFlowActions, AuthorizationFlowNextAction, AuthorizationFlowResponseStatus, + Beneficiary, ConsentSupported, CreatePaymentRequest, CreatePaymentStatus, + CreatePaymentUserRequest, Currency, FailureStage, FormSupported, PaymentMethodRequest, + PaymentStatus, ProviderSelectionRequest, ProviderSelectionSupported, RedirectSupported, + StartAuthorizationFlowRequest, StartAuthorizationFlowResponse, SubMerchants, + SubmitFormActionRequest, SubmitProviderReturnParametersRequest, + SubmitProviderReturnParametersResponseResource, SubmitProviderSelectionActionRequest, + UltimateCounterparty, }, pollable::PollOptions, PollableUntilTerminalState, @@ -71,6 +72,7 @@ async fn hpp_link_returns_200() { phone: None, }, metadata: None, + sub_merchants: None, }) .await .unwrap(); @@ -213,6 +215,7 @@ impl CreatePaymentScenario { phone: None, }, metadata: Some(HashMap::from([("some".into(), "metadata".into())])), + sub_merchants: None, }; let res = ctx .client @@ -857,7 +860,7 @@ async fn cancel_payment() { assert!(matches!( payment.status, - CreatePaymentStatus::AuthorizationRequired { .. } + CreatePaymentStatus::AuthorizationRequired )); ctx.client.payments.cancel(&payment.id).await.unwrap(); @@ -875,3 +878,99 @@ async fn cancel_payment() { PaymentStatus::Failed { failure_reason, failure_stage, .. } if failure_reason == *"canceled" && failure_stage == FailureStage::AuthorizationRequired)); } + +#[tokio::test] +async fn create_payment_with_business_division_sub_merchants() { + let ctx = TestContext::start().await; + + let res = ctx + .client + .payments + .create(&CreatePaymentRequest { + amount_in_minor: 1, + currency: Currency::Gbp, + payment_method: PaymentMethodRequest::BankTransfer { + provider_selection: ProviderSelectionRequest::UserSelected { + filter: None, + scheme_selection: None, + }, + beneficiary: Beneficiary::MerchantAccount { + merchant_account_id: ctx.merchant_account_gbp_id.clone(), + account_holder_name: None, + reference: None, + statement_reference: None, + }, + }, + user: CreatePaymentUserRequest::NewUser { + name: Some("someone".to_string()), + email: Some("some.one@email.com".to_string()), + phone: None, + }, + metadata: None, + sub_merchants: Some(SubMerchants { + ultimate_counterparty: UltimateCounterparty::BusinessDivision { + id: "division-id".to_string(), + name: "Test Division".to_string(), + }, + }), + }) + .await + .unwrap(); + + assert!(!res.id.is_empty()); + assert_eq!(res.status, CreatePaymentStatus::AuthorizationRequired); +} + +#[tokio::test] +async fn create_payment_with_business_client_sub_merchants() { + let ctx = TestContext::start().await; + + let res = ctx + .client + .payments + .create(&CreatePaymentRequest { + amount_in_minor: 1, + currency: Currency::Gbp, + payment_method: PaymentMethodRequest::BankTransfer { + provider_selection: ProviderSelectionRequest::UserSelected { + filter: None, + scheme_selection: None, + }, + beneficiary: Beneficiary::MerchantAccount { + merchant_account_id: ctx.merchant_account_gbp_id.clone(), + account_holder_name: None, + reference: None, + statement_reference: None, + }, + }, + user: CreatePaymentUserRequest::NewUser { + name: Some("someone".to_string()), + email: Some("some.one@email.com".to_string()), + phone: None, + }, + metadata: None, + sub_merchants: Some(SubMerchants { + ultimate_counterparty: UltimateCounterparty::BusinessClient { + id: "client-id".to_string(), + trading_name: "Test Trading".to_string(), + commercial_name: Some("Test Commercial".to_string()), + url: Some("https://example.com".to_string()), + mcc: Some("5411".to_string()), + registration_number: None, + address: Some(Box::new(Address { + address_line1: "1 Test Street".to_string(), + address_line2: None, + city: "London".to_string(), + state: None, + zip: "EC1A 1BB".to_string(), + country_code: "GB".to_string(), + })), + }, + }), + }) + .await + .unwrap(); + + assert!(!res.id.is_empty()); + assert_eq!(res.status, CreatePaymentStatus::AuthorizationRequired); +} diff --git a/tests/integration_tests/payouts.rs b/tests/integration_tests/payouts.rs index f2052b5..e40e7f7 100644 --- a/tests/integration_tests/payouts.rs +++ b/tests/integration_tests/payouts.rs @@ -9,7 +9,7 @@ use reqwest_retry::policies::ExponentialBackoff; use truelayer_rust::{ apis::{ merchant_accounts::ListPaymentSourcesRequest, - payments::{AccountIdentifier, Currency}, + payments::{AccountIdentifier, Address, Currency, SubMerchants, UltimateCounterparty}, payouts::{CreatePayoutRequest, PayoutBeneficiary, PayoutStatus}, }, pollable::PollOptions, @@ -56,6 +56,7 @@ async fn closed_loop_payout() { payment_source_id: payment_source.id, reference: "rust-sdk-test".to_string(), }, + sub_merchants: None, }) .await .unwrap(); @@ -109,6 +110,7 @@ async fn open_loop_payout() { account_identifier: account_identifier.clone(), reference: "rust-sdk-test".to_string(), }, + sub_merchants: None, }) .await .unwrap(); @@ -135,3 +137,128 @@ async fn open_loop_payout() { } if reference == "rust-sdk-test" )); } + +#[tokio::test] +async fn open_loop_payout_with_sub_merchants() { + let ctx = TestContext::start().await; + + // Get merchant account's first identifier + let merchant_account = ctx + .client + .merchant_accounts + .get_by_id(&ctx.merchant_account_gbp_id) + .await + .unwrap() + .unwrap(); + + let account_identifier = merchant_account + .account_identifiers + .iter() + .find(|id| matches!(id, AccountIdentifier::Iban { .. })) + .unwrap_or_else(|| merchant_account.account_identifiers.first().unwrap()); + + // Create a new payout with sub_merchants + let res = ctx + .client + .payouts + .create(&CreatePayoutRequest { + merchant_account_id: ctx.merchant_account_gbp_id.clone(), + amount_in_minor: 1, + currency: Currency::Gbp, + beneficiary: PayoutBeneficiary::ExternalAccount { + account_holder_name: merchant_account.account_holder_name.clone(), + account_identifier: account_identifier.clone(), + reference: "rust-sdk-test".to_string(), + }, + sub_merchants: Some(SubMerchants { + ultimate_counterparty: UltimateCounterparty::BusinessDivision { + id: "division-id".to_string(), + name: "Test Division".to_string(), + }, + }), + }) + .await + .unwrap(); + + assert!(!res.id.is_empty()); + + // Retrieve it and verify + let payout = ctx + .client + .payouts + .get_by_id(&res.id) + .await + .unwrap() + .unwrap(); + assert_eq!(payout.id, res.id); + assert_eq!(payout.merchant_account_id, ctx.merchant_account_gbp_id); + assert_eq!(payout.amount_in_minor, 1); + assert_eq!(payout.currency, Currency::Gbp); +} + +#[tokio::test] +async fn open_loop_payout_with_business_client_sub_merchants() { + let ctx = TestContext::start().await; + + let merchant_account = ctx + .client + .merchant_accounts + .get_by_id(&ctx.merchant_account_gbp_id) + .await + .unwrap() + .unwrap(); + + let account_identifier = merchant_account + .account_identifiers + .iter() + .find(|id| matches!(id, AccountIdentifier::Iban { .. })) + .unwrap_or_else(|| merchant_account.account_identifiers.first().unwrap()); + + let res = ctx + .client + .payouts + .create(&CreatePayoutRequest { + merchant_account_id: ctx.merchant_account_gbp_id.clone(), + amount_in_minor: 1, + currency: Currency::Gbp, + beneficiary: PayoutBeneficiary::ExternalAccount { + account_holder_name: merchant_account.account_holder_name.clone(), + account_identifier: account_identifier.clone(), + reference: "rust-sdk-test".to_string(), + }, + sub_merchants: Some(SubMerchants { + ultimate_counterparty: UltimateCounterparty::BusinessClient { + id: "client-id".to_string(), + trading_name: "Test Trading".to_string(), + commercial_name: Some("Test Commercial".to_string()), + url: Some("https://example.com".to_string()), + mcc: Some("5411".to_string()), + registration_number: None, + address: Some(Box::new(Address { + address_line1: "1 Test Street".to_string(), + address_line2: None, + city: "London".to_string(), + state: None, + zip: "EC1A 1BB".to_string(), + country_code: "GB".to_string(), + })), + }, + }), + }) + .await + .unwrap(); + + assert!(!res.id.is_empty()); + + let payout = ctx + .client + .payouts + .get_by_id(&res.id) + .await + .unwrap() + .unwrap(); + assert_eq!(payout.id, res.id); + assert_eq!(payout.merchant_account_id, ctx.merchant_account_gbp_id); + assert_eq!(payout.amount_in_minor, 1); + assert_eq!(payout.currency, Currency::Gbp); +} From f2fdb17a7ec620b730d2e20ef605f327ebd58cc5 Mon Sep 17 00:00:00 2001 From: Marco Tormento Date: Mon, 4 May 2026 14:20:27 +0200 Subject: [PATCH 2/5] Fixed error --- src/error.rs | 8 +++++++- src/middlewares/error_handling.rs | 2 +- tests/integration_tests/auth.rs | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/error.rs b/src/error.rs index d1a2492..ec6d2af 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,7 +10,7 @@ pub enum Error { HttpError(#[from] reqwest::Error), /// Error returned by a TrueLayer API endpoint. #[error("{0}")] - ApiError(#[from] ApiError), + ApiError(Box), /// Error building request signature. /// /// Read more about signing here: @@ -21,6 +21,12 @@ pub enum Error { Other(anyhow::Error), } +impl From for Error { + fn from(e: ApiError) -> Self { + Error::ApiError(Box::new(e)) + } +} + impl From for Error { fn from(e: reqwest_middleware::Error) -> Self { match e { diff --git a/src/middlewares/error_handling.rs b/src/middlewares/error_handling.rs index b6655c8..f9c9085 100644 --- a/src/middlewares/error_handling.rs +++ b/src/middlewares/error_handling.rs @@ -28,7 +28,7 @@ impl Middleware for ErrorHandlingMiddleware { tracing::debug!("Failed HTTP request. Status code: {}", response.status()); let api_error = api_error_from_response(response).await?; - return Err(Error::ApiError(api_error).into()); + return Err(Error::from(api_error).into()); } Ok(response) diff --git a/tests/integration_tests/auth.rs b/tests/integration_tests/auth.rs index 184ae80..134c171 100644 --- a/tests/integration_tests/auth.rs +++ b/tests/integration_tests/auth.rs @@ -1,5 +1,5 @@ use crate::common::test_context::TestContext; -use truelayer_rust::{apis::auth::Credentials, error::ApiError, Error, TrueLayerClient}; +use truelayer_rust::{apis::auth::Credentials, Error, TrueLayerClient}; #[tokio::test] async fn get_access_token() { @@ -36,5 +36,5 @@ async fn invalid_credentials() { .get_access_token() .await .expect_err("Expected error"); - assert!(matches!(err, Error::ApiError(ApiError { title, .. }) if title == "invalid_client")); + assert!(matches!(err, Error::ApiError(ref e) if e.title == "invalid_client")); } From e8f086173813d3ee60d72472d61de67e8e7bec81 Mon Sep 17 00:00:00 2001 From: Marco Tormento Date: Mon, 4 May 2026 14:49:10 +0200 Subject: [PATCH 3/5] Added sub_merchants field to existing tests --- tests/integration_tests/payments.rs | 113 +++-------------------- tests/integration_tests/payouts.rs | 134 ++-------------------------- 2 files changed, 18 insertions(+), 229 deletions(-) diff --git a/tests/integration_tests/payments.rs b/tests/integration_tests/payments.rs index 3ba1125..d474888 100644 --- a/tests/integration_tests/payments.rs +++ b/tests/integration_tests/payments.rs @@ -7,11 +7,11 @@ use std::{collections::HashMap, time::Duration}; use test_case::test_case; use truelayer_rust::{ apis::payments::{ - AccountIdentifier, AdditionalInputType, Address, AuthorizationFlow, - AuthorizationFlowActions, AuthorizationFlowNextAction, AuthorizationFlowResponseStatus, - Beneficiary, ConsentSupported, CreatePaymentRequest, CreatePaymentStatus, - CreatePaymentUserRequest, Currency, FailureStage, FormSupported, PaymentMethodRequest, - PaymentStatus, ProviderSelectionRequest, ProviderSelectionSupported, RedirectSupported, + AccountIdentifier, AdditionalInputType, AuthorizationFlow, AuthorizationFlowActions, + AuthorizationFlowNextAction, AuthorizationFlowResponseStatus, Beneficiary, + ConsentSupported, CreatePaymentRequest, CreatePaymentStatus, CreatePaymentUserRequest, + Currency, FailureStage, FormSupported, PaymentMethodRequest, PaymentStatus, + ProviderSelectionRequest, ProviderSelectionSupported, RedirectSupported, StartAuthorizationFlowRequest, StartAuthorizationFlowResponse, SubMerchants, SubmitFormActionRequest, SubmitProviderReturnParametersRequest, SubmitProviderReturnParametersResponseResource, SubmitProviderSelectionActionRequest, @@ -215,7 +215,12 @@ impl CreatePaymentScenario { phone: None, }, metadata: Some(HashMap::from([("some".into(), "metadata".into())])), - sub_merchants: None, + sub_merchants: Some(SubMerchants { + ultimate_counterparty: UltimateCounterparty::BusinessDivision { + id: "division-id".to_string(), + name: "Test Division".to_string(), + }, + }), }; let res = ctx .client @@ -878,99 +883,3 @@ async fn cancel_payment() { PaymentStatus::Failed { failure_reason, failure_stage, .. } if failure_reason == *"canceled" && failure_stage == FailureStage::AuthorizationRequired)); } - -#[tokio::test] -async fn create_payment_with_business_division_sub_merchants() { - let ctx = TestContext::start().await; - - let res = ctx - .client - .payments - .create(&CreatePaymentRequest { - amount_in_minor: 1, - currency: Currency::Gbp, - payment_method: PaymentMethodRequest::BankTransfer { - provider_selection: ProviderSelectionRequest::UserSelected { - filter: None, - scheme_selection: None, - }, - beneficiary: Beneficiary::MerchantAccount { - merchant_account_id: ctx.merchant_account_gbp_id.clone(), - account_holder_name: None, - reference: None, - statement_reference: None, - }, - }, - user: CreatePaymentUserRequest::NewUser { - name: Some("someone".to_string()), - email: Some("some.one@email.com".to_string()), - phone: None, - }, - metadata: None, - sub_merchants: Some(SubMerchants { - ultimate_counterparty: UltimateCounterparty::BusinessDivision { - id: "division-id".to_string(), - name: "Test Division".to_string(), - }, - }), - }) - .await - .unwrap(); - - assert!(!res.id.is_empty()); - assert_eq!(res.status, CreatePaymentStatus::AuthorizationRequired); -} - -#[tokio::test] -async fn create_payment_with_business_client_sub_merchants() { - let ctx = TestContext::start().await; - - let res = ctx - .client - .payments - .create(&CreatePaymentRequest { - amount_in_minor: 1, - currency: Currency::Gbp, - payment_method: PaymentMethodRequest::BankTransfer { - provider_selection: ProviderSelectionRequest::UserSelected { - filter: None, - scheme_selection: None, - }, - beneficiary: Beneficiary::MerchantAccount { - merchant_account_id: ctx.merchant_account_gbp_id.clone(), - account_holder_name: None, - reference: None, - statement_reference: None, - }, - }, - user: CreatePaymentUserRequest::NewUser { - name: Some("someone".to_string()), - email: Some("some.one@email.com".to_string()), - phone: None, - }, - metadata: None, - sub_merchants: Some(SubMerchants { - ultimate_counterparty: UltimateCounterparty::BusinessClient { - id: "client-id".to_string(), - trading_name: "Test Trading".to_string(), - commercial_name: Some("Test Commercial".to_string()), - url: Some("https://example.com".to_string()), - mcc: Some("5411".to_string()), - registration_number: None, - address: Some(Box::new(Address { - address_line1: "1 Test Street".to_string(), - address_line2: None, - city: "London".to_string(), - state: None, - zip: "EC1A 1BB".to_string(), - country_code: "GB".to_string(), - })), - }, - }), - }) - .await - .unwrap(); - - assert!(!res.id.is_empty()); - assert_eq!(res.status, CreatePaymentStatus::AuthorizationRequired); -} diff --git a/tests/integration_tests/payouts.rs b/tests/integration_tests/payouts.rs index e40e7f7..fb2550c 100644 --- a/tests/integration_tests/payouts.rs +++ b/tests/integration_tests/payouts.rs @@ -9,7 +9,7 @@ use reqwest_retry::policies::ExponentialBackoff; use truelayer_rust::{ apis::{ merchant_accounts::ListPaymentSourcesRequest, - payments::{AccountIdentifier, Address, Currency, SubMerchants, UltimateCounterparty}, + payments::{AccountIdentifier, Currency, SubMerchants, UltimateCounterparty}, payouts::{CreatePayoutRequest, PayoutBeneficiary, PayoutStatus}, }, pollable::PollOptions, @@ -110,7 +110,12 @@ async fn open_loop_payout() { account_identifier: account_identifier.clone(), reference: "rust-sdk-test".to_string(), }, - sub_merchants: None, + sub_merchants: Some(SubMerchants { + ultimate_counterparty: UltimateCounterparty::BusinessDivision { + id: "division-id".to_string(), + name: "Test Division".to_string(), + }, + }), }) .await .unwrap(); @@ -137,128 +142,3 @@ async fn open_loop_payout() { } if reference == "rust-sdk-test" )); } - -#[tokio::test] -async fn open_loop_payout_with_sub_merchants() { - let ctx = TestContext::start().await; - - // Get merchant account's first identifier - let merchant_account = ctx - .client - .merchant_accounts - .get_by_id(&ctx.merchant_account_gbp_id) - .await - .unwrap() - .unwrap(); - - let account_identifier = merchant_account - .account_identifiers - .iter() - .find(|id| matches!(id, AccountIdentifier::Iban { .. })) - .unwrap_or_else(|| merchant_account.account_identifiers.first().unwrap()); - - // Create a new payout with sub_merchants - let res = ctx - .client - .payouts - .create(&CreatePayoutRequest { - merchant_account_id: ctx.merchant_account_gbp_id.clone(), - amount_in_minor: 1, - currency: Currency::Gbp, - beneficiary: PayoutBeneficiary::ExternalAccount { - account_holder_name: merchant_account.account_holder_name.clone(), - account_identifier: account_identifier.clone(), - reference: "rust-sdk-test".to_string(), - }, - sub_merchants: Some(SubMerchants { - ultimate_counterparty: UltimateCounterparty::BusinessDivision { - id: "division-id".to_string(), - name: "Test Division".to_string(), - }, - }), - }) - .await - .unwrap(); - - assert!(!res.id.is_empty()); - - // Retrieve it and verify - let payout = ctx - .client - .payouts - .get_by_id(&res.id) - .await - .unwrap() - .unwrap(); - assert_eq!(payout.id, res.id); - assert_eq!(payout.merchant_account_id, ctx.merchant_account_gbp_id); - assert_eq!(payout.amount_in_minor, 1); - assert_eq!(payout.currency, Currency::Gbp); -} - -#[tokio::test] -async fn open_loop_payout_with_business_client_sub_merchants() { - let ctx = TestContext::start().await; - - let merchant_account = ctx - .client - .merchant_accounts - .get_by_id(&ctx.merchant_account_gbp_id) - .await - .unwrap() - .unwrap(); - - let account_identifier = merchant_account - .account_identifiers - .iter() - .find(|id| matches!(id, AccountIdentifier::Iban { .. })) - .unwrap_or_else(|| merchant_account.account_identifiers.first().unwrap()); - - let res = ctx - .client - .payouts - .create(&CreatePayoutRequest { - merchant_account_id: ctx.merchant_account_gbp_id.clone(), - amount_in_minor: 1, - currency: Currency::Gbp, - beneficiary: PayoutBeneficiary::ExternalAccount { - account_holder_name: merchant_account.account_holder_name.clone(), - account_identifier: account_identifier.clone(), - reference: "rust-sdk-test".to_string(), - }, - sub_merchants: Some(SubMerchants { - ultimate_counterparty: UltimateCounterparty::BusinessClient { - id: "client-id".to_string(), - trading_name: "Test Trading".to_string(), - commercial_name: Some("Test Commercial".to_string()), - url: Some("https://example.com".to_string()), - mcc: Some("5411".to_string()), - registration_number: None, - address: Some(Box::new(Address { - address_line1: "1 Test Street".to_string(), - address_line2: None, - city: "London".to_string(), - state: None, - zip: "EC1A 1BB".to_string(), - country_code: "GB".to_string(), - })), - }, - }), - }) - .await - .unwrap(); - - assert!(!res.id.is_empty()); - - let payout = ctx - .client - .payouts - .get_by_id(&res.id) - .await - .unwrap() - .unwrap(); - assert_eq!(payout.id, res.id); - assert_eq!(payout.merchant_account_id, ctx.merchant_account_gbp_id); - assert_eq!(payout.amount_in_minor, 1); - assert_eq!(payout.currency, Currency::Gbp); -} From 7ac38f2c5b9e73a4306f35cc2a3aae5c1adba60e Mon Sep 17 00:00:00 2001 From: Marco Tormento Date: Mon, 4 May 2026 17:05:02 +0200 Subject: [PATCH 4/5] Removed call to spa --- tests/common/test_context/local_mock.rs | 10 ------ tests/common/test_context/sandbox.rs | 26 ---------------- tests/integration_tests/helpers.rs | 13 +++++--- tests/integration_tests/payments.rs | 41 ++++++++++--------------- 4 files changed, 24 insertions(+), 66 deletions(-) diff --git a/tests/common/test_context/local_mock.rs b/tests/common/test_context/local_mock.rs index b5bfaea..20e953f 100644 --- a/tests/common/test_context/local_mock.rs +++ b/tests/common/test_context/local_mock.rs @@ -78,14 +78,4 @@ impl TestContext { .complete_mock_bank_redirect_authorization(redirect_uri, action) .await } - - pub async fn submit_provider_return_parameters( - &self, - _query: &str, - _fragment: &str, - ) -> Result<(), anyhow::Error> { - // This is only necessary for acceptance tests to work correctly. - // This work is usually done by TrueLayer's SPA upon redirect from the provider. - Ok(()) - } } diff --git a/tests/common/test_context/sandbox.rs b/tests/common/test_context/sandbox.rs index 3a2241f..5694781 100644 --- a/tests/common/test_context/sandbox.rs +++ b/tests/common/test_context/sandbox.rs @@ -1,12 +1,9 @@ use crate::common::MockBankAction; use anyhow::Context; -use serde_json::json; use std::str::FromStr; use truelayer_rust::{apis::auth::Credentials, client::Environment, TrueLayerClient}; use url::Url; -static SANDBOX_RETURN_PARAMETERS_URI: &str = "https://pay-api.truelayer-sandbox.com"; - pub struct TestContext { pub client: TrueLayerClient, pub merchant_account_gbp_id: String, @@ -83,27 +80,4 @@ impl TestContext { Ok(Url::from_str(&provider_return_uri)?) } - - pub async fn submit_provider_return_parameters( - &self, - query: &str, - fragment: &str, - ) -> Result<(), anyhow::Error> { - reqwest::Client::new() - .post( - Url::parse(SANDBOX_RETURN_PARAMETERS_URI) - .unwrap() - .join("/spa/submit-provider-return-parameters") - .unwrap(), - ) - .json(&json!({ - "fragment": fragment, - "query": query - })) - .send() - .await? - .error_for_status()?; - - Ok(()) - } } diff --git a/tests/integration_tests/helpers.rs b/tests/integration_tests/helpers.rs index b2ccf52..dafb967 100644 --- a/tests/integration_tests/helpers.rs +++ b/tests/integration_tests/helpers.rs @@ -7,6 +7,7 @@ use truelayer_rust::{ AuthorizationFlowNextAction, Beneficiary, ConsentSupported, CreatePaymentRequest, CreatePaymentResponse, CreatePaymentUserRequest, Currency, Payment, PaymentMethodRequest, PaymentStatus, ProviderSelectionRequest, RedirectSupported, StartAuthorizationFlowRequest, + SubmitProviderReturnParametersRequest, }, pollable::PollOptions, Pollable, @@ -88,11 +89,13 @@ pub async fn create_and_authorize_closed_loop_payment( .complete_mock_bank_redirect_authorization(&redirect_uri, MockBankAction::Execute) .await?; - ctx.submit_provider_return_parameters( - provider_return_uri.query().unwrap_or(""), - provider_return_uri.fragment().unwrap_or(""), - ) - .await?; + ctx.client + .payments + .submit_provider_return_parameters(&SubmitProviderReturnParametersRequest { + query: provider_return_uri.query().unwrap_or("").to_string(), + fragment: provider_return_uri.fragment().unwrap_or("").to_string(), + }) + .await?; let payment = res .poll_until( diff --git a/tests/integration_tests/payments.rs b/tests/integration_tests/payments.rs index d474888..e3fff81 100644 --- a/tests/integration_tests/payments.rs +++ b/tests/integration_tests/payments.rs @@ -452,32 +452,23 @@ impl CreatePaymentScenario { .await .unwrap(); - // If we are testing the direct return scenario, submit the return parameters - if self.redirect_flow == RedirectFlow::DirectReturn { - let submit_res = ctx - .client - .payments - .submit_provider_return_parameters(&SubmitProviderReturnParametersRequest { - query: provider_return_uri.query().unwrap_or("").to_string(), - fragment: provider_return_uri.fragment().unwrap_or("").to_string(), - }) - .await - .unwrap(); - - assert_eq!( - submit_res.resource, - SubmitProviderReturnParametersResponseResource::Payment { - payment_id: res.id.clone() - } - ); - } else { - ctx.submit_provider_return_parameters( - provider_return_uri.query().unwrap_or(""), - provider_return_uri.fragment().unwrap_or(""), - ) + // Submit the provider return parameters + let submit_res = ctx + .client + .payments + .submit_provider_return_parameters(&SubmitProviderReturnParametersRequest { + query: provider_return_uri.query().unwrap_or("").to_string(), + fragment: provider_return_uri.fragment().unwrap_or("").to_string(), + }) .await - .unwrap() - } + .unwrap(); + + assert_eq!( + submit_res.resource, + SubmitProviderReturnParametersResponseResource::Payment { + payment_id: res.id.clone() + } + ); // Wait for the payment to reach a terminal state let payment = payment From 12570e007dbea6c06dc69d24ff3454ba98e56407 Mon Sep 17 00:00:00 2001 From: Marco Tormento Date: Tue, 5 May 2026 17:51:50 +0200 Subject: [PATCH 5/5] Pretag --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c68b2ae..08e95d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "truelayer-rust" -version = "0.2.0" +version = "0.3.0-pre0" edition = "2021" [dependencies]