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] 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/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/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/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/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/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/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")); } diff --git a/tests/integration_tests/helpers.rs b/tests/integration_tests/helpers.rs index 524fd58..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, @@ -44,6 +45,7 @@ pub async fn create_closed_loop_payment( phone: None, }, metadata: None, + sub_merchants: None, }) .await?; Ok(res) @@ -87,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 6846fda..e3fff81 100644 --- a/tests/integration_tests/payments.rs +++ b/tests/integration_tests/payments.rs @@ -12,9 +12,10 @@ use truelayer_rust::{ ConsentSupported, CreatePaymentRequest, CreatePaymentStatus, CreatePaymentUserRequest, Currency, FailureStage, FormSupported, PaymentMethodRequest, PaymentStatus, ProviderSelectionRequest, ProviderSelectionSupported, RedirectSupported, - StartAuthorizationFlowRequest, StartAuthorizationFlowResponse, SubmitFormActionRequest, - SubmitProviderReturnParametersRequest, SubmitProviderReturnParametersResponseResource, - SubmitProviderSelectionActionRequest, + 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,12 @@ impl CreatePaymentScenario { phone: None, }, metadata: Some(HashMap::from([("some".into(), "metadata".into())])), + sub_merchants: Some(SubMerchants { + ultimate_counterparty: UltimateCounterparty::BusinessDivision { + id: "division-id".to_string(), + name: "Test Division".to_string(), + }, + }), }; let res = ctx .client @@ -444,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 @@ -857,7 +856,7 @@ async fn cancel_payment() { assert!(matches!( payment.status, - CreatePaymentStatus::AuthorizationRequired { .. } + CreatePaymentStatus::AuthorizationRequired )); ctx.client.payments.cancel(&payment.id).await.unwrap(); diff --git a/tests/integration_tests/payouts.rs b/tests/integration_tests/payouts.rs index f2052b5..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, Currency}, + payments::{AccountIdentifier, 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,12 @@ async fn open_loop_payout() { 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();