diff --git a/crates/common/common_enums/src/enums.rs b/crates/common/common_enums/src/enums.rs index 2d52594d0e..4d69bee76d 100644 --- a/crates/common/common_enums/src/enums.rs +++ b/crates/common/common_enums/src/enums.rs @@ -1765,6 +1765,529 @@ pub enum CountryAlpha2 { US } +/// Full ISO country names. Ported from hyperswitch so connectors (e.g. Trustly's +/// ClearingHouse) that require the English country name can derive it from a +/// [`CountryAlpha2`] via [`Country::from_alpha2`]. +#[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Hash, + strum::Display, + strum::EnumString, + Serialize, + Deserialize, +)] +pub enum Country { + Afghanistan, + AlandIslands, + Albania, + Algeria, + AmericanSamoa, + Andorra, + Angola, + Anguilla, + Antarctica, + AntiguaAndBarbuda, + Argentina, + Armenia, + Aruba, + Australia, + Austria, + Azerbaijan, + Bahamas, + Bahrain, + Bangladesh, + Barbados, + Belarus, + Belgium, + Belize, + Benin, + Bermuda, + Bhutan, + BoliviaPlurinationalState, + BonaireSintEustatiusAndSaba, + BosniaAndHerzegovina, + Botswana, + BouvetIsland, + Brazil, + BritishIndianOceanTerritory, + BruneiDarussalam, + Bulgaria, + BurkinaFaso, + Burundi, + CaboVerde, + Cambodia, + Cameroon, + Canada, + CaymanIslands, + CentralAfricanRepublic, + Chad, + Chile, + China, + ChristmasIsland, + CocosKeelingIslands, + Colombia, + Comoros, + Congo, + CongoDemocraticRepublic, + CookIslands, + CostaRica, + CotedIvoire, + Croatia, + Cuba, + Curacao, + Cyprus, + Czechia, + Denmark, + Djibouti, + Dominica, + DominicanRepublic, + Ecuador, + Egypt, + ElSalvador, + EquatorialGuinea, + Eritrea, + Estonia, + Ethiopia, + FalklandIslandsMalvinas, + FaroeIslands, + Fiji, + Finland, + France, + FrenchGuiana, + FrenchPolynesia, + FrenchSouthernTerritories, + Gabon, + Gambia, + Georgia, + Germany, + Ghana, + Gibraltar, + Greece, + Greenland, + Grenada, + Guadeloupe, + Guam, + Guatemala, + Guernsey, + Guinea, + GuineaBissau, + Guyana, + Haiti, + HeardIslandAndMcDonaldIslands, + HolySee, + Honduras, + HongKong, + Hungary, + Iceland, + India, + Indonesia, + IranIslamicRepublic, + Iraq, + Ireland, + IsleOfMan, + Israel, + Italy, + Jamaica, + Japan, + Jersey, + Jordan, + Kazakhstan, + Kenya, + Kiribati, + KoreaDemocraticPeoplesRepublic, + KoreaRepublic, + Kuwait, + Kyrgyzstan, + LaoPeoplesDemocraticRepublic, + Latvia, + Lebanon, + Lesotho, + Liberia, + Libya, + Liechtenstein, + Lithuania, + Luxembourg, + Macao, + MacedoniaTheFormerYugoslavRepublic, + Madagascar, + Malawi, + Malaysia, + Maldives, + Mali, + Malta, + MarshallIslands, + Martinique, + Mauritania, + Mauritius, + Mayotte, + Mexico, + MicronesiaFederatedStates, + MoldovaRepublic, + Monaco, + Mongolia, + Montenegro, + Montserrat, + Morocco, + Mozambique, + Myanmar, + Namibia, + Nauru, + Nepal, + Netherlands, + NewCaledonia, + NewZealand, + Nicaragua, + Niger, + Nigeria, + Niue, + NorfolkIsland, + NorthernMarianaIslands, + Norway, + Oman, + Pakistan, + Palau, + PalestineState, + Panama, + PapuaNewGuinea, + Paraguay, + Peru, + Philippines, + Pitcairn, + Poland, + Portugal, + PuertoRico, + Qatar, + Reunion, + Romania, + RussianFederation, + Rwanda, + SaintBarthelemy, + SaintHelenaAscensionAndTristandaCunha, + SaintKittsAndNevis, + SaintLucia, + SaintMartinFrenchpart, + SaintPierreAndMiquelon, + SaintVincentAndTheGrenadines, + Samoa, + SanMarino, + SaoTomeAndPrincipe, + SaudiArabia, + Senegal, + Serbia, + Seychelles, + SierraLeone, + Singapore, + SintMaartenDutchpart, + Slovakia, + Slovenia, + SolomonIslands, + Somalia, + SouthAfrica, + SouthGeorgiaAndTheSouthSandwichIslands, + SouthSudan, + Spain, + SriLanka, + Sudan, + Suriname, + SvalbardAndJanMayen, + Swaziland, + Sweden, + Switzerland, + SyrianArabRepublic, + TaiwanProvinceOfChina, + Tajikistan, + TanzaniaUnitedRepublic, + Thailand, + TimorLeste, + Togo, + Tokelau, + Tonga, + TrinidadAndTobago, + Tunisia, + Turkey, + Turkmenistan, + TurksAndCaicosIslands, + Tuvalu, + Uganda, + Ukraine, + UnitedArabEmirates, + UnitedKingdomOfGreatBritainAndNorthernIreland, + UnitedStatesOfAmerica, + UnitedStatesMinorOutlyingIslands, + Uruguay, + Uzbekistan, + Vanuatu, + VenezuelaBolivarianRepublic, + Vietnam, + VirginIslandsBritish, + VirginIslandsUS, + WallisAndFutuna, + WesternSahara, + Yemen, + Zambia, + Zimbabwe, +} + +impl Country { + pub const fn from_alpha2(code: CountryAlpha2) -> Self { + match code { + CountryAlpha2::AF => Self::Afghanistan, + CountryAlpha2::AX => Self::AlandIslands, + CountryAlpha2::AL => Self::Albania, + CountryAlpha2::DZ => Self::Algeria, + CountryAlpha2::AS => Self::AmericanSamoa, + CountryAlpha2::AD => Self::Andorra, + CountryAlpha2::AO => Self::Angola, + CountryAlpha2::AI => Self::Anguilla, + CountryAlpha2::AQ => Self::Antarctica, + CountryAlpha2::AG => Self::AntiguaAndBarbuda, + CountryAlpha2::AR => Self::Argentina, + CountryAlpha2::AM => Self::Armenia, + CountryAlpha2::AW => Self::Aruba, + CountryAlpha2::AU => Self::Australia, + CountryAlpha2::AT => Self::Austria, + CountryAlpha2::AZ => Self::Azerbaijan, + CountryAlpha2::BS => Self::Bahamas, + CountryAlpha2::BH => Self::Bahrain, + CountryAlpha2::BD => Self::Bangladesh, + CountryAlpha2::BB => Self::Barbados, + CountryAlpha2::BY => Self::Belarus, + CountryAlpha2::BE => Self::Belgium, + CountryAlpha2::BZ => Self::Belize, + CountryAlpha2::BJ => Self::Benin, + CountryAlpha2::BM => Self::Bermuda, + CountryAlpha2::BT => Self::Bhutan, + CountryAlpha2::BO => Self::BoliviaPlurinationalState, + CountryAlpha2::BQ => Self::BonaireSintEustatiusAndSaba, + CountryAlpha2::BA => Self::BosniaAndHerzegovina, + CountryAlpha2::BW => Self::Botswana, + CountryAlpha2::BV => Self::BouvetIsland, + CountryAlpha2::BR => Self::Brazil, + CountryAlpha2::IO => Self::BritishIndianOceanTerritory, + CountryAlpha2::BN => Self::BruneiDarussalam, + CountryAlpha2::BG => Self::Bulgaria, + CountryAlpha2::BF => Self::BurkinaFaso, + CountryAlpha2::BI => Self::Burundi, + CountryAlpha2::CV => Self::CaboVerde, + CountryAlpha2::KH => Self::Cambodia, + CountryAlpha2::CM => Self::Cameroon, + CountryAlpha2::CA => Self::Canada, + CountryAlpha2::KY => Self::CaymanIslands, + CountryAlpha2::CF => Self::CentralAfricanRepublic, + CountryAlpha2::TD => Self::Chad, + CountryAlpha2::CL => Self::Chile, + CountryAlpha2::CN => Self::China, + CountryAlpha2::CX => Self::ChristmasIsland, + CountryAlpha2::CC => Self::CocosKeelingIslands, + CountryAlpha2::CO => Self::Colombia, + CountryAlpha2::KM => Self::Comoros, + CountryAlpha2::CG => Self::Congo, + CountryAlpha2::CD => Self::CongoDemocraticRepublic, + CountryAlpha2::CK => Self::CookIslands, + CountryAlpha2::CR => Self::CostaRica, + CountryAlpha2::CI => Self::CotedIvoire, + CountryAlpha2::HR => Self::Croatia, + CountryAlpha2::CU => Self::Cuba, + CountryAlpha2::CW => Self::Curacao, + CountryAlpha2::CY => Self::Cyprus, + CountryAlpha2::CZ => Self::Czechia, + CountryAlpha2::DK => Self::Denmark, + CountryAlpha2::DJ => Self::Djibouti, + CountryAlpha2::DM => Self::Dominica, + CountryAlpha2::DO => Self::DominicanRepublic, + CountryAlpha2::EC => Self::Ecuador, + CountryAlpha2::EG => Self::Egypt, + CountryAlpha2::SV => Self::ElSalvador, + CountryAlpha2::GQ => Self::EquatorialGuinea, + CountryAlpha2::ER => Self::Eritrea, + CountryAlpha2::EE => Self::Estonia, + CountryAlpha2::ET => Self::Ethiopia, + CountryAlpha2::FK => Self::FalklandIslandsMalvinas, + CountryAlpha2::FO => Self::FaroeIslands, + CountryAlpha2::FJ => Self::Fiji, + CountryAlpha2::FI => Self::Finland, + CountryAlpha2::FR => Self::France, + CountryAlpha2::GF => Self::FrenchGuiana, + CountryAlpha2::PF => Self::FrenchPolynesia, + CountryAlpha2::TF => Self::FrenchSouthernTerritories, + CountryAlpha2::GA => Self::Gabon, + CountryAlpha2::GM => Self::Gambia, + CountryAlpha2::GE => Self::Georgia, + CountryAlpha2::DE => Self::Germany, + CountryAlpha2::GH => Self::Ghana, + CountryAlpha2::GI => Self::Gibraltar, + CountryAlpha2::GR => Self::Greece, + CountryAlpha2::GL => Self::Greenland, + CountryAlpha2::GD => Self::Grenada, + CountryAlpha2::GP => Self::Guadeloupe, + CountryAlpha2::GU => Self::Guam, + CountryAlpha2::GT => Self::Guatemala, + CountryAlpha2::GG => Self::Guernsey, + CountryAlpha2::GN => Self::Guinea, + CountryAlpha2::GW => Self::GuineaBissau, + CountryAlpha2::GY => Self::Guyana, + CountryAlpha2::HT => Self::Haiti, + CountryAlpha2::HM => Self::HeardIslandAndMcDonaldIslands, + CountryAlpha2::VA => Self::HolySee, + CountryAlpha2::HN => Self::Honduras, + CountryAlpha2::HK => Self::HongKong, + CountryAlpha2::HU => Self::Hungary, + CountryAlpha2::IS => Self::Iceland, + CountryAlpha2::IN => Self::India, + CountryAlpha2::ID => Self::Indonesia, + CountryAlpha2::IR => Self::IranIslamicRepublic, + CountryAlpha2::IQ => Self::Iraq, + CountryAlpha2::IE => Self::Ireland, + CountryAlpha2::IM => Self::IsleOfMan, + CountryAlpha2::IL => Self::Israel, + CountryAlpha2::IT => Self::Italy, + CountryAlpha2::JM => Self::Jamaica, + CountryAlpha2::JP => Self::Japan, + CountryAlpha2::JE => Self::Jersey, + CountryAlpha2::JO => Self::Jordan, + CountryAlpha2::KZ => Self::Kazakhstan, + CountryAlpha2::KE => Self::Kenya, + CountryAlpha2::KI => Self::Kiribati, + CountryAlpha2::KP => Self::KoreaDemocraticPeoplesRepublic, + CountryAlpha2::KR => Self::KoreaRepublic, + CountryAlpha2::KW => Self::Kuwait, + CountryAlpha2::KG => Self::Kyrgyzstan, + CountryAlpha2::LA => Self::LaoPeoplesDemocraticRepublic, + CountryAlpha2::LV => Self::Latvia, + CountryAlpha2::LB => Self::Lebanon, + CountryAlpha2::LS => Self::Lesotho, + CountryAlpha2::LR => Self::Liberia, + CountryAlpha2::LY => Self::Libya, + CountryAlpha2::LI => Self::Liechtenstein, + CountryAlpha2::LT => Self::Lithuania, + CountryAlpha2::LU => Self::Luxembourg, + CountryAlpha2::MO => Self::Macao, + CountryAlpha2::MK => Self::MacedoniaTheFormerYugoslavRepublic, + CountryAlpha2::MG => Self::Madagascar, + CountryAlpha2::MW => Self::Malawi, + CountryAlpha2::MY => Self::Malaysia, + CountryAlpha2::MV => Self::Maldives, + CountryAlpha2::ML => Self::Mali, + CountryAlpha2::MT => Self::Malta, + CountryAlpha2::MH => Self::MarshallIslands, + CountryAlpha2::MQ => Self::Martinique, + CountryAlpha2::MR => Self::Mauritania, + CountryAlpha2::MU => Self::Mauritius, + CountryAlpha2::YT => Self::Mayotte, + CountryAlpha2::MX => Self::Mexico, + CountryAlpha2::FM => Self::MicronesiaFederatedStates, + CountryAlpha2::MD => Self::MoldovaRepublic, + CountryAlpha2::MC => Self::Monaco, + CountryAlpha2::MN => Self::Mongolia, + CountryAlpha2::ME => Self::Montenegro, + CountryAlpha2::MS => Self::Montserrat, + CountryAlpha2::MA => Self::Morocco, + CountryAlpha2::MZ => Self::Mozambique, + CountryAlpha2::MM => Self::Myanmar, + CountryAlpha2::NA => Self::Namibia, + CountryAlpha2::NR => Self::Nauru, + CountryAlpha2::NP => Self::Nepal, + CountryAlpha2::NL => Self::Netherlands, + CountryAlpha2::NC => Self::NewCaledonia, + CountryAlpha2::NZ => Self::NewZealand, + CountryAlpha2::NI => Self::Nicaragua, + CountryAlpha2::NE => Self::Niger, + CountryAlpha2::NG => Self::Nigeria, + CountryAlpha2::NU => Self::Niue, + CountryAlpha2::NF => Self::NorfolkIsland, + CountryAlpha2::MP => Self::NorthernMarianaIslands, + CountryAlpha2::NO => Self::Norway, + CountryAlpha2::OM => Self::Oman, + CountryAlpha2::PK => Self::Pakistan, + CountryAlpha2::PW => Self::Palau, + CountryAlpha2::PS => Self::PalestineState, + CountryAlpha2::PA => Self::Panama, + CountryAlpha2::PG => Self::PapuaNewGuinea, + CountryAlpha2::PY => Self::Paraguay, + CountryAlpha2::PE => Self::Peru, + CountryAlpha2::PH => Self::Philippines, + CountryAlpha2::PN => Self::Pitcairn, + CountryAlpha2::PL => Self::Poland, + CountryAlpha2::PT => Self::Portugal, + CountryAlpha2::PR => Self::PuertoRico, + CountryAlpha2::QA => Self::Qatar, + CountryAlpha2::RE => Self::Reunion, + CountryAlpha2::RO => Self::Romania, + CountryAlpha2::RU => Self::RussianFederation, + CountryAlpha2::RW => Self::Rwanda, + CountryAlpha2::BL => Self::SaintBarthelemy, + CountryAlpha2::SH => Self::SaintHelenaAscensionAndTristandaCunha, + CountryAlpha2::KN => Self::SaintKittsAndNevis, + CountryAlpha2::LC => Self::SaintLucia, + CountryAlpha2::MF => Self::SaintMartinFrenchpart, + CountryAlpha2::PM => Self::SaintPierreAndMiquelon, + CountryAlpha2::VC => Self::SaintVincentAndTheGrenadines, + CountryAlpha2::WS => Self::Samoa, + CountryAlpha2::SM => Self::SanMarino, + CountryAlpha2::ST => Self::SaoTomeAndPrincipe, + CountryAlpha2::SA => Self::SaudiArabia, + CountryAlpha2::SN => Self::Senegal, + CountryAlpha2::RS => Self::Serbia, + CountryAlpha2::SC => Self::Seychelles, + CountryAlpha2::SL => Self::SierraLeone, + CountryAlpha2::SG => Self::Singapore, + CountryAlpha2::SX => Self::SintMaartenDutchpart, + CountryAlpha2::SK => Self::Slovakia, + CountryAlpha2::SI => Self::Slovenia, + CountryAlpha2::SB => Self::SolomonIslands, + CountryAlpha2::SO => Self::Somalia, + CountryAlpha2::ZA => Self::SouthAfrica, + CountryAlpha2::GS => Self::SouthGeorgiaAndTheSouthSandwichIslands, + CountryAlpha2::SS => Self::SouthSudan, + CountryAlpha2::ES => Self::Spain, + CountryAlpha2::LK => Self::SriLanka, + CountryAlpha2::SD => Self::Sudan, + CountryAlpha2::SR => Self::Suriname, + CountryAlpha2::SJ => Self::SvalbardAndJanMayen, + CountryAlpha2::SZ => Self::Swaziland, + CountryAlpha2::SE => Self::Sweden, + CountryAlpha2::CH => Self::Switzerland, + CountryAlpha2::SY => Self::SyrianArabRepublic, + CountryAlpha2::TW => Self::TaiwanProvinceOfChina, + CountryAlpha2::TJ => Self::Tajikistan, + CountryAlpha2::TZ => Self::TanzaniaUnitedRepublic, + CountryAlpha2::TH => Self::Thailand, + CountryAlpha2::TL => Self::TimorLeste, + CountryAlpha2::TG => Self::Togo, + CountryAlpha2::TK => Self::Tokelau, + CountryAlpha2::TO => Self::Tonga, + CountryAlpha2::TT => Self::TrinidadAndTobago, + CountryAlpha2::TN => Self::Tunisia, + CountryAlpha2::TR => Self::Turkey, + CountryAlpha2::TM => Self::Turkmenistan, + CountryAlpha2::TC => Self::TurksAndCaicosIslands, + CountryAlpha2::TV => Self::Tuvalu, + CountryAlpha2::UG => Self::Uganda, + CountryAlpha2::UA => Self::Ukraine, + CountryAlpha2::AE => Self::UnitedArabEmirates, + CountryAlpha2::GB => Self::UnitedKingdomOfGreatBritainAndNorthernIreland, + CountryAlpha2::US => Self::UnitedStatesOfAmerica, + CountryAlpha2::UM => Self::UnitedStatesMinorOutlyingIslands, + CountryAlpha2::UY => Self::Uruguay, + CountryAlpha2::UZ => Self::Uzbekistan, + CountryAlpha2::VU => Self::Vanuatu, + CountryAlpha2::VE => Self::VenezuelaBolivarianRepublic, + CountryAlpha2::VN => Self::Vietnam, + CountryAlpha2::VG => Self::VirginIslandsBritish, + CountryAlpha2::VI => Self::VirginIslandsUS, + CountryAlpha2::WF => Self::WallisAndFutuna, + CountryAlpha2::EH => Self::WesternSahara, + CountryAlpha2::YE => Self::Yemen, + CountryAlpha2::ZM => Self::Zambia, + CountryAlpha2::ZW => Self::Zimbabwe, + } + } +} + #[derive(Clone, Copy, Eq, Hash, PartialEq, Debug, Serialize, Deserialize, strum::Display)] #[rustfmt::skip] pub enum CountryAlpha3 { diff --git a/crates/integrations/connector-integration/src/payout_connectors.rs b/crates/integrations/connector-integration/src/payout_connectors.rs index 67b211d150..a058fa894e 100644 --- a/crates/integrations/connector-integration/src/payout_connectors.rs +++ b/crates/integrations/connector-integration/src/payout_connectors.rs @@ -15,3 +15,6 @@ pub use self::worldpayxml::WorldpayxmlPayouts; pub mod cybersource; pub use self::cybersource::CybersourcePayouts; + +pub mod trustly; +pub use self::trustly::TrustlyPayouts; diff --git a/crates/integrations/connector-integration/src/payout_connectors/trustly.rs b/crates/integrations/connector-integration/src/payout_connectors/trustly.rs new file mode 100644 index 0000000000..db289e949d --- /dev/null +++ b/crates/integrations/connector-integration/src/payout_connectors/trustly.rs @@ -0,0 +1,376 @@ +pub mod transformers; + +use std::fmt::Debug; + +use common_enums::CurrencyUnit; +use common_utils::{errors::CustomResult, events, ext_traits::ByteSliceExt}; +use domain_types::{ + connector_flow::{ + PayoutCreateRecipient, PayoutEligibility, PayoutGet, PayoutTransfer, + ServerAuthenticationToken, + }, + connector_types::{ + ServerAuthenticationTokenRequestData, ServerAuthenticationTokenResponseData, + }, + errors::{ + ConnectorError, IntegrationError, IntegrationErrorContext, + ResponseTransformationErrorContext, + }, + merchant_authentication_flow_data::MerchantAuthenticationFlowData, + payment_method_data::PaymentMethodDataTypes, + payouts::payouts_types::{ + PayoutCreateRecipientRequest, PayoutCreateRecipientResponse, PayoutEligibilityRequest, + PayoutEligibilityResponse, PayoutFlowData, PayoutGetRequest, PayoutGetResponse, + PayoutTransferRequest, PayoutTransferResponse, + }, + router_data::{ConnectorSpecificConfig, ErrorResponse}, + router_data_v2::RouterDataV2, + router_response_types::Response, + types::Connectors, +}; +use error_stack::ResultExt; +use hyperswitch_masking::Maskable; +use interfaces::{ + api::ConnectorCommon, + connector_integration_v2::ConnectorIntegrationV2, + connector_types::{ + PayoutCreateRecipientV2, PayoutEligibilityV2, PayoutGetV2, PayoutServiceTrait, + PayoutTransferV2, ServerAuthentication, + }, +}; +use serde::Serialize; + +use super::super::connectors::macros; +// Reuse the shared Trustly error-response type for parsing connector errors. +use crate::connectors::trustly::transformers::TrustlyErrorResponse; +use crate::types::ResponseRouterData; +use transformers::{ + AccountPayoutRequest, AccountPayoutResponse, RegisterAccountRequest, RegisterAccountResponse, + TrustlyPayoutSyncRequest, TrustlyPayoutSyncResponse, +}; + +pub(crate) mod headers { + pub(crate) const CONTENT_TYPE: &str = "Content-Type"; +} + +const CONTENT_TYPE_JSON: &str = "application/json; charset=UTF-8"; + +macros::create_all_prerequisites!( + connector_name: TrustlyPayouts, + generic_type: T, + api: [ + ( + flow: PayoutCreateRecipient, + request_body: RegisterAccountRequest, + response_body: RegisterAccountResponse, + router_data: RouterDataV2, + ), + ( + flow: PayoutTransfer, + request_body: AccountPayoutRequest, + response_body: AccountPayoutResponse, + router_data: RouterDataV2, + ), + ( + flow: PayoutGet, + request_body: TrustlyPayoutSyncRequest, + response_body: TrustlyPayoutSyncResponse, + router_data: RouterDataV2, + ) + ], + amount_converters: [], + member_functions: { + // Trustly authenticates each request in the JSON-RPC body (username / password + // / signature), so only the content-type header is required. + fn payout_headers(&self) -> Vec<(String, Maskable)> { + vec![( + headers::CONTENT_TYPE.to_string(), + CONTENT_TYPE_JSON.to_string().into(), + )] + } + } +); + +// ===== CONNECTOR COMMON ===== + +impl ConnectorCommon + for TrustlyPayouts +{ + fn id(&self) -> &'static str { + "trustly" + } + + fn get_currency_unit(&self) -> CurrencyUnit { + CurrencyUnit::Base + } + + fn common_get_content_type(&self) -> &'static str { + CONTENT_TYPE_JSON + } + + fn base_url<'a>(&self, connectors: &'a Connectors) -> &'a str { + connectors.trustly.base_url.as_ref() + } + + fn get_auth_header( + &self, + _auth_type: &ConnectorSpecificConfig, + ) -> CustomResult)>, IntegrationError> { + Ok(Vec::new()) + } + + fn build_error_response( + &self, + res: Response, + event_builder: Option<&mut events::Event>, + _connector_config: &ConnectorSpecificConfig, + ) -> CustomResult { + let response: TrustlyErrorResponse = res + .response + .parse_struct("TrustlyErrorResponse") + .change_context(ConnectorError::ResponseDeserializationFailed { + context: ResponseTransformationErrorContext { + http_status_code: Some(res.status_code), + additional_context: Some( + "Trustly payout - failed to deserialize error response".to_string(), + ), + }, + })?; + + event_builder.map(|i| i.set_connector_response(&response)); + + Ok(ErrorResponse { + code: response.error.code.to_string(), + message: response.error.message.clone(), + reason: Some(response.error.message), + status_code: res.status_code, + attempt_status: None, + connector_transaction_id: Some(response.error.error.uuid), + network_advice_code: None, + network_decline_code: None, + network_error_message: None, + }) + } +} + +// ===== SERVER AUTHENTICATION (not implemented) ===== + +impl ServerAuthentication + for TrustlyPayouts +{ +} + +impl + ConnectorIntegrationV2< + ServerAuthenticationToken, + MerchantAuthenticationFlowData, + ServerAuthenticationTokenRequestData, + ServerAuthenticationTokenResponseData, + > for TrustlyPayouts +{ + fn get_url( + &self, + _req: &RouterDataV2< + ServerAuthenticationToken, + MerchantAuthenticationFlowData, + ServerAuthenticationTokenRequestData, + ServerAuthenticationTokenResponseData, + >, + ) -> CustomResult { + Err(IntegrationError::connector_flow_not_implemented( + ConnectorCommon::id(self), + "server_authentication_token", + IntegrationErrorContext::default(), + ) + .into()) + } +} + +// ===== PAYOUT SERVICE TRAIT ===== + +impl PayoutServiceTrait + for TrustlyPayouts +{ +} + +impl PayoutCreateRecipientV2 + for TrustlyPayouts +{ +} + +impl PayoutTransferV2 + for TrustlyPayouts +{ +} + +impl PayoutGetV2 + for TrustlyPayouts +{ +} + +// ===== PAYOUT CREATE RECIPIENT (RegisterAccount) ===== + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: TrustlyPayouts, + curl_request: Json(RegisterAccountRequest), + curl_response: RegisterAccountResponse, + flow_name: PayoutCreateRecipient, + resource_common_data: PayoutFlowData, + flow_request: PayoutCreateRecipientRequest, + flow_response: PayoutCreateRecipientResponse, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_url( + &self, + req: &RouterDataV2< + PayoutCreateRecipient, + PayoutFlowData, + PayoutCreateRecipientRequest, + PayoutCreateRecipientResponse, + >, + ) -> CustomResult { + Ok(self + .base_url(&req.resource_common_data.connectors) + .to_string()) + } + + fn get_headers( + &self, + _req: &RouterDataV2< + PayoutCreateRecipient, + PayoutFlowData, + PayoutCreateRecipientRequest, + PayoutCreateRecipientResponse, + >, + ) -> CustomResult)>, IntegrationError> { + Ok(self.payout_headers()) + } + } +); + +// ===== PAYOUT TRANSFER (AccountPayout) ===== + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: TrustlyPayouts, + curl_request: Json(AccountPayoutRequest), + curl_response: AccountPayoutResponse, + flow_name: PayoutTransfer, + resource_common_data: PayoutFlowData, + flow_request: PayoutTransferRequest, + flow_response: PayoutTransferResponse, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_url( + &self, + req: &RouterDataV2< + PayoutTransfer, + PayoutFlowData, + PayoutTransferRequest, + PayoutTransferResponse, + >, + ) -> CustomResult { + Ok(self + .base_url(&req.resource_common_data.connectors) + .to_string()) + } + + fn get_headers( + &self, + _req: &RouterDataV2< + PayoutTransfer, + PayoutFlowData, + PayoutTransferRequest, + PayoutTransferResponse, + >, + ) -> CustomResult)>, IntegrationError> { + Ok(self.payout_headers()) + } + } +); + +// ===== PAYOUT GET (GetWithdrawals) ===== + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: TrustlyPayouts, + curl_request: Json(TrustlyPayoutSyncRequest), + curl_response: TrustlyPayoutSyncResponse, + flow_name: PayoutGet, + resource_common_data: PayoutFlowData, + flow_request: PayoutGetRequest, + flow_response: PayoutGetResponse, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + Ok(self + .base_url(&req.resource_common_data.connectors) + .to_string()) + } + + fn get_headers( + &self, + _req: &RouterDataV2, + ) -> CustomResult)>, IntegrationError> { + Ok(self.payout_headers()) + } + } +); + +// ===== PAYOUT STUB FLOWS (not supported by Trustly) ===== + +macros::macro_connector_payout_implementation!( + connector: TrustlyPayouts, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + payout_flows: [ + PayoutCreate, + PayoutVoid, + PayoutStage, + PayoutCreateLink, + PayoutEnrollDisburseAccount + ] +); + +// `PayoutEligibility` has no arm in `macro_connector_payout_implementation!`, +// so its stub is still written out by hand. +impl PayoutEligibilityV2 + for TrustlyPayouts +{ +} + +impl + ConnectorIntegrationV2< + PayoutEligibility, + PayoutFlowData, + PayoutEligibilityRequest, + PayoutEligibilityResponse, + > for TrustlyPayouts +{ + fn get_url( + &self, + _req: &RouterDataV2< + PayoutEligibility, + PayoutFlowData, + PayoutEligibilityRequest, + PayoutEligibilityResponse, + >, + ) -> CustomResult { + Err(IntegrationError::connector_flow_not_implemented( + ConnectorCommon::id(self), + "payout_eligibility", + IntegrationErrorContext::default(), + ) + .into()) + } +} diff --git a/crates/integrations/connector-integration/src/payout_connectors/trustly/transformers.rs b/crates/integrations/connector-integration/src/payout_connectors/trustly/transformers.rs new file mode 100644 index 0000000000..af3ff58155 --- /dev/null +++ b/crates/integrations/connector-integration/src/payout_connectors/trustly/transformers.rs @@ -0,0 +1,826 @@ +use super::TrustlyPayoutsRouterData; +use crate::types::ResponseRouterData; +use common_utils::types::{StringMajorUnit, StringMajorUnitForConnector}; +use domain_types::{ + connector_flow::{PayoutCreateRecipient, PayoutGet, PayoutTransfer}, + errors::{ + ConnectorError, IntegrationError, IntegrationErrorContext, + ResponseTransformationErrorContext, + }, + payment_method_data::PaymentMethodDataTypes, + payouts::{ + payout_method_data::{Bank, PayoutMethodData}, + payouts_types::{ + PayoutCreateRecipientRequest, PayoutCreateRecipientResponse, PayoutFlowData, + PayoutGetRequest, PayoutGetResponse, PayoutTransferRequest, PayoutTransferResponse, + }, + }, + router_data::ConnectorSpecificConfig, + router_data_v2::RouterDataV2, + utils as domain_utils, +}; +use error_stack::Report; +use hyperswitch_masking::{ExposeInterface, Secret}; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; + +// The Trustly signature and error-response types are shared with the payment +// connector implementation; reuse them here so the RSA signing logic lives in a +// single place. +use crate::connectors::trustly::transformers::{ + generate_trustly_signature, TrustlyErrorResponse, TrustlyWebhookBody, TrustlyWebhookMethod, +}; + +const TRUSTLY_VERSION: &str = "1.1"; + +// ===== AUTH TYPE ===== + +#[derive(Debug)] +pub struct TrustlyAuthType { + pub username: Secret, + pub password: Secret, + pub private_key: Secret, +} + +impl TryFrom<&ConnectorSpecificConfig> for TrustlyAuthType { + type Error = Report; + fn try_from(auth_type: &ConnectorSpecificConfig) -> Result { + match auth_type { + ConnectorSpecificConfig::Trustly { + username, + password, + private_key, + .. + } => Ok(Self { + username: username.clone(), + password: password.clone(), + private_key: private_key.clone(), + }), + _ => Err(IntegrationError::FailedToObtainAuthType { + context: Default::default(), + } + .into()), + } + } +} + +// ===== SHARED TYPES ===== + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +enum TrustlyMethod { + RegisterAccount, + AccountPayout, + GetWithdrawals, +} + +impl TrustlyMethod { + fn as_str(&self) -> &'static str { + match self { + Self::RegisterAccount => "RegisterAccount", + Self::AccountPayout => "AccountPayout", + Self::GetWithdrawals => "GetWithdrawals", + } + } +} + +fn unsupported_payout_method_error(flow: &str) -> Report { + IntegrationError::NotSupported { + message: "Payout method is not supported".to_string(), + connector: "Trustly", + context: IntegrationErrorContext { + additional_context: Some(format!( + "Trustly {flow} - only the Trustly bank transfer payout method is supported" + )), + suggested_action: Some("Use a Trustly bank transfer payout method".to_string()), + doc_url: None, + }, + } + .into() +} + +fn missing_field(field_name: &'static str, flow: &str) -> Report { + IntegrationError::MissingRequiredField { + field_name, + context: IntegrationErrorContext { + additional_context: Some(format!("Trustly {flow} - missing required field")), + suggested_action: None, + doc_url: None, + }, + } + .into() +} + +#[derive(Debug, Deserialize)] +pub struct TrustlyAccountId { + account_id: Secret, +} + +fn to_payout_connector_meta( + connector_meta: Option, +) -> Result> { + let json = connector_meta + .ok_or_else(|| missing_field("payout_connector_metadata", "Payout Transfer"))?; + serde_json::from_value(json).map_err(|_| { + IntegrationError::InvalidDataFormat { + field_name: "payout_connector_metadata", + context: IntegrationErrorContext { + additional_context: Some( + "Trustly Payout Transfer - failed to parse payout_connector_metadata" + .to_string(), + ), + suggested_action: None, + doc_url: None, + }, + } + .into() + }) +} + +// ===== PAYOUT CREATE RECIPIENT (RegisterAccount) ===== + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct RegisterAccountRequest { + method: TrustlyMethod, + params: RegisterAccountParams, + version: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct RegisterAccountParams { + data: RegisterAccountData, + signature: Secret, + #[serde(rename = "UUID")] + uuid: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct RegisterAccountData { + account_number: Secret, + bank_number: Secret, + clearing_house: String, + end_user_i_d: String, + firstname: Secret, + lastname: Secret, + username: Secret, + password: Secret, + #[serde(skip_serializing_if = "Option::is_none")] + attributes: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct RegisterAccountAttributes { + #[serde(skip_serializing_if = "Option::is_none")] + address_country: Option, + #[serde(skip_serializing_if = "Option::is_none")] + address_line1: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + address_line2: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + address_city: Option, + #[serde(skip_serializing_if = "Option::is_none")] + address_postal_code: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + mobile_phone: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, +} + +/// Derive the payee's first and last name from the customer name (split on the +/// first space) or, failing that, from the billing address. +fn get_recipient_names( + req: &PayoutCreateRecipientRequest, +) -> Result<(String, String), Report> { + if let Some(name) = req.customer.as_ref().and_then(|c| c.name.as_ref()) { + let parts: Vec<&str> = name.splitn(2, ' ').collect(); + if let [first, second] = parts.as_slice() { + return Ok((first.to_string(), second.to_string())); + } + } + + match ( + req.get_optional_billing_first_name(), + req.get_optional_billing_last_name(), + ) { + (Some(first), Some(last)) => Ok((first.expose(), last.expose())), + _ => Err(missing_field( + "customer.name / billing first_name and last_name", + "Payout Create Recipient", + )), + } +} + +impl + TryFrom< + TrustlyPayoutsRouterData< + RouterDataV2< + PayoutCreateRecipient, + PayoutFlowData, + PayoutCreateRecipientRequest, + PayoutCreateRecipientResponse, + >, + T, + >, + > for RegisterAccountRequest +{ + type Error = Report; + + fn try_from( + item: TrustlyPayoutsRouterData< + RouterDataV2< + PayoutCreateRecipient, + PayoutFlowData, + PayoutCreateRecipientRequest, + PayoutCreateRecipientResponse, + >, + T, + >, + ) -> Result { + let item = &item.router_data; + let trustly_data = match item.request.payout_method_data.as_ref() { + Some(PayoutMethodData::Bank(Bank::Trustly(data))) => data, + _ => return Err(unsupported_payout_method_error("Payout Create Recipient")), + }; + + let (account_number, bank_number) = if let Some(iban) = trustly_data.iban.clone() { + (iban, Secret::new(String::new())) + } else { + ( + trustly_data.bank_account_number.clone().ok_or_else(|| { + missing_field("bank_account_number", "Payout Create Recipient") + })?, + trustly_data + .bank_number + .clone() + .ok_or_else(|| missing_field("bank_number", "Payout Create Recipient"))?, + ) + }; + + let (first_name, last_name) = get_recipient_names(&item.request)?; + + let end_user_id = item + .request + .customer + .as_ref() + .and_then(|c| c.merchant_customer_id.clone()) + .ok_or_else(|| { + missing_field("customer.merchant_customer_id", "Payout Create Recipient") + })?; + + let billing = item + .request + .address + .as_ref() + .and_then(|a| a.billing_address.as_ref()); + let customer_email = item.request.customer.as_ref().and_then(|c| c.email.clone()); + + let attributes = if billing.is_some() || customer_email.is_some() { + Some(RegisterAccountAttributes { + address_city: item.request.get_optional_billing_city(), + address_country: item.request.get_optional_billing_country(), + address_line1: item.request.get_optional_billing_line1(), + address_line2: item.request.get_optional_billing_line2(), + address_postal_code: item.request.get_optional_billing_zip(), + email: item.request.get_optional_billing_email(), + mobile_phone: billing.and_then(|b| b.get_phone_with_country_code().ok()), + }) + } else { + None + }; + + let auth = TrustlyAuthType::try_from(&item.connector_config)?; + + let register_account_data = RegisterAccountData { + account_number, + bank_number, + clearing_house: common_enums::Country::from_alpha2(trustly_data.bank_country_code) + .to_string() + .to_uppercase(), + end_user_i_d: end_user_id, + firstname: Secret::new(first_name), + lastname: Secret::new(last_name), + username: auth.username.clone(), + password: auth.password.clone(), + attributes, + }; + + let uuid = uuid::Uuid::new_v4().to_string(); + let signature = generate_trustly_signature( + TrustlyMethod::RegisterAccount.as_str(), + uuid.as_str(), + ®ister_account_data, + &auth.private_key.clone().expose(), + ) + .map_err(Report::new)?; + + Ok(Self { + method: TrustlyMethod::RegisterAccount, + params: RegisterAccountParams { + data: register_account_data, + signature: Secret::new(signature), + uuid, + }, + version: TRUSTLY_VERSION.to_string(), + }) + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum RegisterAccountResponse { + Success(RegisterAccountResponseSuccess), + Error(Box), +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct RegisterAccountResponseSuccess { + pub result: RegisterAccountResponseResult, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct RegisterAccountResponseResult { + data: RegisterAccountResponseResultData, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct RegisterAccountResponseResultData { + accountid: Secret, + clearinghouse: String, + bank: String, +} + +impl TryFrom> + for RouterDataV2< + PayoutCreateRecipient, + PayoutFlowData, + PayoutCreateRecipientRequest, + PayoutCreateRecipientResponse, + > +{ + type Error = Report; + + fn try_from( + item: ResponseRouterData, + ) -> Result { + match item.response { + RegisterAccountResponse::Success(response) => { + let account_id = response.result.data.accountid; + let payout_connector_metadata = Some(Secret::new(serde_json::json!({ + "account_id": account_id, + }))); + Ok(Self { + response: Ok(PayoutCreateRecipientResponse { + merchant_payout_id: item.router_data.request.merchant_payout_id.clone(), + payout_status: common_enums::PayoutStatus::RequiresCreation, + connector_payout_id: None, + status_code: item.http_code, + payout_connector_metadata, + }), + ..item.router_data + }) + } + RegisterAccountResponse::Error(error_response) => Ok(Self { + response: Err(build_error_from_response(&error_response, item.http_code)), + ..item.router_data + }), + } + } +} + +// ===== PAYOUT TRANSFER (AccountPayout) ===== + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct AccountPayoutRequest { + method: TrustlyMethod, + params: AccountPayoutParams, + version: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct AccountPayoutParams { + signature: Secret, + #[serde(rename = "UUID")] + uuid: String, + data: AccountPayoutData, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct AccountPayoutData { + account_i_d: Secret, + amount: StringMajorUnit, + #[serde(skip_serializing_if = "Option::is_none")] + attributes: Option, + currency: common_enums::Currency, + end_user_i_d: String, + message_i_d: String, + notification_u_r_l: String, + password: Secret, + username: Secret, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct AccountPayoutAttributes { + shopper_statement: String, +} + +impl + TryFrom< + TrustlyPayoutsRouterData< + RouterDataV2< + PayoutTransfer, + PayoutFlowData, + PayoutTransferRequest, + PayoutTransferResponse, + >, + T, + >, + > for AccountPayoutRequest +{ + type Error = Report; + + fn try_from( + item: TrustlyPayoutsRouterData< + RouterDataV2< + PayoutTransfer, + PayoutFlowData, + PayoutTransferRequest, + PayoutTransferResponse, + >, + T, + >, + ) -> Result { + let item = &item.router_data; + // The Trustly payout only makes sense for the Trustly bank transfer method. + match item.request.payout_method_data.as_ref() { + Some(PayoutMethodData::Bank(Bank::Trustly(_))) => {} + _ => return Err(unsupported_payout_method_error("Payout Transfer")), + } + + // The account id created by the RegisterAccount (recipient) step is + // carried over in payout_connector_metadata. + let metadata = item + .request + .payout_connector_metadata + .clone() + .map(|secret| secret.expose()); + let account_id: TrustlyAccountId = to_payout_connector_meta(metadata)?; + + let amount = domain_utils::convert_amount( + &StringMajorUnitForConnector, + item.request.amount, + item.request.source_currency, + )?; + + let notification_url = item + .request + .webhook_url + .clone() + .ok_or_else(|| missing_field("webhook_url", "Payout Transfer"))?; + + let shopper_statement = item + .resource_common_data + .description + .clone() + .ok_or_else(|| missing_field("description", "Payout Transfer"))?; + + let end_user_id = item + .request + .get_customer_id()? + .get_string_repr() + .to_string(); + + let auth = TrustlyAuthType::try_from(&item.connector_config)?; + + let account_payout_data = AccountPayoutData { + account_i_d: account_id.account_id, + amount, + attributes: Some(AccountPayoutAttributes { shopper_statement }), + currency: item.request.destination_currency, + end_user_i_d: end_user_id, + message_i_d: format!("payout_{}", item.resource_common_data.payout_id), + notification_u_r_l: notification_url, + password: auth.password.clone(), + username: auth.username.clone(), + }; + + let uuid = uuid::Uuid::new_v4().to_string(); + let signature = generate_trustly_signature( + TrustlyMethod::AccountPayout.as_str(), + uuid.as_str(), + &account_payout_data, + &auth.private_key.clone().expose(), + ) + .map_err(Report::new)?; + + Ok(Self { + method: TrustlyMethod::AccountPayout, + params: AccountPayoutParams { + data: account_payout_data, + signature: Secret::new(signature), + uuid, + }, + version: TRUSTLY_VERSION.to_string(), + }) + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +enum PayoutResult { + #[serde(rename = "0")] + Failed, + #[serde(rename = "1")] + Pending, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum AccountPayoutResponse { + Success(AccountPayoutResponseSuccess), + Error(Box), +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct AccountPayoutResponseSuccess { + version: String, + result: AccountPayoutResponseResult, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct AccountPayoutResponseResult { + data: AccountPayoutResponseData, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct AccountPayoutResponseData { + orderid: String, + result: PayoutResult, +} + +impl TryFrom> + for RouterDataV2 +{ + type Error = Report; + + fn try_from( + item: ResponseRouterData, + ) -> Result { + match item.response { + AccountPayoutResponse::Success(success_response) => { + let data = success_response.result.data; + let payout_status = match data.result { + PayoutResult::Failed => common_enums::PayoutStatus::Failure, + PayoutResult::Pending => common_enums::PayoutStatus::Initiated, + }; + Ok(Self { + response: Ok(PayoutTransferResponse { + merchant_payout_id: item.router_data.request.merchant_payout_id.clone(), + payout_status, + connector_payout_id: Some(data.orderid), + status_code: item.http_code, + }), + ..item.router_data + }) + } + AccountPayoutResponse::Error(error_response) => Ok(Self { + response: Err(build_error_from_response(&error_response, item.http_code)), + ..item.router_data + }), + } + } +} + +// ===== PAYOUT GET (GetWithdrawals) ===== + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct TrustlyPayoutSyncRequest { + method: TrustlyMethod, + params: PayoutSyncRequestParams, + version: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct PayoutSyncRequestParams { + #[serde(rename = "UUID")] + uuid: String, + data: PayoutSyncRequestData, + signature: Secret, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct PayoutSyncRequestData { + order_id: Secret, + password: Secret, + username: Secret, +} + +impl + TryFrom< + TrustlyPayoutsRouterData< + RouterDataV2, + T, + >, + > for TrustlyPayoutSyncRequest +{ + type Error = Report; + + fn try_from( + item: TrustlyPayoutsRouterData< + RouterDataV2, + T, + >, + ) -> Result { + let item = &item.router_data; + let auth = TrustlyAuthType::try_from(&item.connector_config)?; + + let order_id = item + .request + .connector_payout_id + .clone() + .ok_or_else(|| missing_field("connector_payout_id", "Payout Get"))?; + + let data = PayoutSyncRequestData { + order_id: Secret::new(order_id), + password: auth.password.clone(), + username: auth.username.clone(), + }; + + let uuid = uuid::Uuid::new_v4().to_string(); + let signature = generate_trustly_signature( + TrustlyMethod::GetWithdrawals.as_str(), + uuid.as_str(), + &data, + &auth.private_key.clone().expose(), + ) + .map_err(Report::new)?; + + Ok(Self { + method: TrustlyMethod::GetWithdrawals, + params: PayoutSyncRequestParams { + uuid, + data, + signature: Secret::new(signature), + }, + version: TRUSTLY_VERSION.to_string(), + }) + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum TrustlyPayoutSyncResponse { + Success(TrustlyPayoutSyncResponseSuccess), + Error(Box), + Webhook(Box), +} + +fn get_payout_status_from_webhook( + method: TrustlyWebhookMethod, +) -> Result> { + match method { + TrustlyWebhookMethod::Credit => Ok(common_enums::PayoutStatus::Reversed), + TrustlyWebhookMethod::Cancel => Ok(common_enums::PayoutStatus::Cancelled), + TrustlyWebhookMethod::PayoutFailed => Ok(common_enums::PayoutStatus::Failure), + TrustlyWebhookMethod::PayoutConfirmation => Ok(common_enums::PayoutStatus::Success), + _ => Err(ConnectorError::UnexpectedResponseError { + context: ResponseTransformationErrorContext { + http_status_code: None, + additional_context: Some( + "Trustly GetWithdrawals - unexpected webhook method in sync response" + .to_string(), + ), + }, + } + .into()), + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct TrustlyPayoutSyncResponseSuccess { + result: TrustlyPayoutSyncResponseResult, + version: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct TrustlyPayoutSyncResponseResult { + uuid: String, + method: String, + data: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct TrustlyPayoutSyncResponseData { + reference: String, + orderid: String, + transferstate: TrustlyPayoutStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum TrustlyPayoutStatus { + Confirmed, + Executing, + Executed, + Pending, + Queued, + Preparing, + Prepared, + Bounced, + Error, + Failed, + Returned, +} + +impl From for common_enums::PayoutStatus { + fn from(item: TrustlyPayoutStatus) -> Self { + match item { + TrustlyPayoutStatus::Confirmed => Self::Success, + TrustlyPayoutStatus::Failed + | TrustlyPayoutStatus::Error + | TrustlyPayoutStatus::Bounced + | TrustlyPayoutStatus::Returned => Self::Failure, + TrustlyPayoutStatus::Executing | TrustlyPayoutStatus::Executed => Self::Pending, + TrustlyPayoutStatus::Pending + | TrustlyPayoutStatus::Queued + | TrustlyPayoutStatus::Preparing + | TrustlyPayoutStatus::Prepared => Self::Initiated, + } + } +} + +impl TryFrom> + for RouterDataV2 +{ + type Error = Report; + + fn try_from( + item: ResponseRouterData, + ) -> Result { + match item.response { + TrustlyPayoutSyncResponse::Success(response) => { + let (payout_status, connector_payout_id) = match response.result.data.first() { + Some(first) => ( + common_enums::PayoutStatus::from(first.transferstate.clone()), + Some(first.orderid.clone()), + ), + None => ( + common_enums::PayoutStatus::Pending, + item.router_data.request.connector_payout_id.clone(), + ), + }; + Ok(Self { + response: Ok(PayoutGetResponse { + merchant_payout_id: item.router_data.request.merchant_payout_id.clone(), + payout_status, + connector_payout_id, + status_code: item.http_code, + }), + ..item.router_data + }) + } + TrustlyPayoutSyncResponse::Error(error_response) => Ok(Self { + response: Err(build_error_from_response(&error_response, item.http_code)), + ..item.router_data + }), + TrustlyPayoutSyncResponse::Webhook(webhook_body) => { + let payout_status = get_payout_status_from_webhook(webhook_body.method.clone())?; + Ok(Self { + response: Ok(PayoutGetResponse { + merchant_payout_id: item.router_data.request.merchant_payout_id.clone(), + payout_status, + connector_payout_id: Some(webhook_body.params.data.orderid.clone()), + status_code: item.http_code, + }), + ..item.router_data + }) + } + } + } +} + +// ===== ERROR HANDLING ===== + +/// Build a domain `ErrorResponse` from a Trustly JSON-RPC error body embedded in +/// an otherwise `2xx`/untagged response. +fn build_error_from_response( + error_response: &TrustlyErrorResponse, + status_code: u16, +) -> domain_types::router_data::ErrorResponse { + domain_types::router_data::ErrorResponse { + code: error_response.error.code.to_string(), + message: error_response.error.message.clone(), + reason: Some(error_response.error.message.clone()), + status_code, + attempt_status: None, + connector_transaction_id: Some(error_response.error.error.uuid.clone()), + network_advice_code: None, + network_decline_code: None, + network_error_message: None, + } +} diff --git a/crates/integrations/connector-integration/src/types.rs b/crates/integrations/connector-integration/src/types.rs index 2ba1d59437..ad51f893e2 100644 --- a/crates/integrations/connector-integration/src/types.rs +++ b/crates/integrations/connector-integration/src/types.rs @@ -224,6 +224,9 @@ impl PayoutConnectorData { PayoutConnectorEnum::Cybersource => { Box::new(payout_connectors::CybersourcePayouts::new()) } + PayoutConnectorEnum::Trustly => Box::new(payout_connectors::TrustlyPayouts::< + domain_types::payment_method_data::DefaultPCIHolder, + >::new()), } } } diff --git a/crates/types-traits/domain_types/src/connector_types.rs b/crates/types-traits/domain_types/src/connector_types.rs index 70e110a743..fec4979566 100644 --- a/crates/types-traits/domain_types/src/connector_types.rs +++ b/crates/types-traits/domain_types/src/connector_types.rs @@ -221,6 +221,7 @@ pub enum PayoutConnectorEnum { Deutschebank, Worldpayxml, Cybersource, + Trustly, } impl TryFrom for PayoutConnectorEnum { @@ -233,6 +234,7 @@ impl TryFrom for PayoutConnectorEnum { ConnectorEnum::Itaubank => Ok(Self::Itaubank), ConnectorEnum::Worldpayxml => Ok(Self::Worldpayxml), ConnectorEnum::Cybersource => Ok(Self::Cybersource), + ConnectorEnum::Trustly => Ok(Self::Trustly), _ => Err(IntegrationError::InvalidDataFormat { field_name: "connector", context: IntegrationErrorContext::default(), @@ -273,6 +275,7 @@ impl ForeignTryFrom for PayoutConnectorEnum { AuthType::Deutschebank(_) => Ok(Self::Deutschebank), AuthType::Worldpayxml(_) => Ok(Self::Worldpayxml), AuthType::Cybersource(_) => Ok(Self::Cybersource), + AuthType::Trustly(_) => Ok(Self::Trustly), _ => Err(error_stack::Report::new( IntegrationError::InvalidDataFormat { field_name: "connector", diff --git a/crates/types-traits/domain_types/src/payouts/payout_method_data.rs b/crates/types-traits/domain_types/src/payouts/payout_method_data.rs index aa8ebec3bd..35adb8b17d 100644 --- a/crates/types-traits/domain_types/src/payouts/payout_method_data.rs +++ b/crates/types-traits/domain_types/src/payouts/payout_method_data.rs @@ -79,6 +79,7 @@ pub enum Bank { Pix(PixBankTransfer), PixKey(PixKeyBankTransfer), PixEmv(PixEmvBankTransfer), + Trustly(TrustlyBankTransfer), } #[derive(Default, Eq, PartialEq, Clone, Debug)] @@ -169,6 +170,25 @@ pub struct PixEmvBankTransfer { pub emv: Secret, } +#[derive(Default, Eq, PartialEq, Clone, Debug)] +// Trustly bank transfer destination. The account can be identified either by an +// IBAN or by a bank_account_number + bank_number pair. +pub struct TrustlyBankTransfer { + /// International Bank Account Number (IBAN). When present, it is used as the + /// account number and no separate bank number is required. + pub iban: Option>, + + /// Bank account number, used when an IBAN is not available. + pub bank_account_number: Option>, + + /// Bank/clearing number identifying the destination bank. + pub bank_number: Option>, + + /// Bank country code. Maps to Trustly's `ClearingHouse` (the English country + /// name in upper case). + pub bank_country_code: common_enums::CountryAlpha2, +} + #[derive(Eq, PartialEq, Clone, Debug)] pub enum Wallet { ApplePayDecrypt(ApplePayDecrypt), diff --git a/crates/types-traits/domain_types/src/payouts/payouts_types.rs b/crates/types-traits/domain_types/src/payouts/payouts_types.rs index d88aaf33c4..40441e6fb8 100644 --- a/crates/types-traits/domain_types/src/payouts/payouts_types.rs +++ b/crates/types-traits/domain_types/src/payouts/payouts_types.rs @@ -121,6 +121,7 @@ pub struct PayoutTransferRequest { pub address: Option, pub source_bank_data: Option, pub customer: Option, + pub payout_connector_metadata: Option, } impl PayoutTransferRequest { @@ -373,6 +374,60 @@ pub struct PayoutCreateRecipientRequest { pub source_currency: common_enums::Currency, pub payout_method_data: Option, pub recipient_type: common_enums::PayoutRecipientType, + pub customer: Option, + pub address: Option, +} + +impl PayoutCreateRecipientRequest { + pub fn get_optional_billing_address(&self) -> Option<&crate::payment_address::AddressDetails> { + self.address + .as_ref() + .and_then(|a| a.billing_address.as_ref()) + .and_then(|b| b.address.as_ref()) + } + + pub fn get_optional_billing_first_name(&self) -> Option> { + self.get_optional_billing_address() + .and_then(|addr| addr.first_name.clone()) + } + + pub fn get_optional_billing_last_name(&self) -> Option> { + self.get_optional_billing_address() + .and_then(|addr| addr.last_name.clone()) + } + + pub fn get_optional_billing_line1(&self) -> Option> { + self.get_optional_billing_address() + .and_then(|addr| addr.line1.clone()) + } + + pub fn get_optional_billing_line2(&self) -> Option> { + self.get_optional_billing_address() + .and_then(|addr| addr.line2.clone()) + } + + pub fn get_optional_billing_city(&self) -> Option { + self.get_optional_billing_address() + .and_then(|addr| addr.city.as_ref()) + .map(|c| c.peek().clone()) + } + + pub fn get_optional_billing_zip(&self) -> Option> { + self.get_optional_billing_address() + .and_then(|addr| addr.zip.clone()) + } + + pub fn get_optional_billing_country(&self) -> Option { + self.get_optional_billing_address() + .and_then(|addr| addr.country) + } + + pub fn get_optional_billing_email(&self) -> Option { + self.address + .as_ref() + .and_then(|a| a.billing_address.as_ref()) + .and_then(|b| b.email.clone()) + } } #[derive(Debug, Clone)] @@ -381,6 +436,7 @@ pub struct PayoutCreateRecipientResponse { pub payout_status: common_enums::PayoutStatus, pub connector_payout_id: Option, pub status_code: u16, + pub payout_connector_metadata: Option, } #[derive(Debug, Clone)] diff --git a/crates/types-traits/domain_types/src/payouts/types.rs b/crates/types-traits/domain_types/src/payouts/types.rs index 5a1b001036..b7c6fe5f5d 100644 --- a/crates/types-traits/domain_types/src/payouts/types.rs +++ b/crates/types-traits/domain_types/src/payouts/types.rs @@ -560,6 +560,39 @@ impl ForeignTryFrom } } +impl ForeignTryFrom + for payouts::payout_method_data::TrustlyBankTransfer +{ + type Error = IntegrationError; + fn foreign_try_from( + trustly: grpc_api_types::payouts::TrustlyBankTransferPayout, + ) -> Result> { + let bank_country_code = { + let cc = grpc_api_types::payments::CountryAlpha2::try_from(trustly.bank_country_code) + .change_context(IntegrationError::InvalidDataFormat { + field_name: "bank_country_code", + context: IntegrationErrorContext { + additional_context: Some("Invalid bank country code".to_owned()), + ..Default::default() + }, + })?; + common_enums::CountryAlpha2::foreign_try_from(cc)? + }; + Ok(payouts::payout_method_data::TrustlyBankTransfer { + iban: trustly + .iban + .map(|i| ::hyperswitch_masking::Secret::new(i.peek().to_string())), + bank_account_number: trustly + .bank_account_number + .map(|n| ::hyperswitch_masking::Secret::new(n.peek().to_string())), + bank_number: trustly + .bank_number + .map(|n| ::hyperswitch_masking::Secret::new(n.peek().to_string())), + bank_country_code, + }) + } +} + impl ForeignTryFrom for payouts::payout_method_data::PixBankTransfer { @@ -956,6 +989,11 @@ impl ForeignTryFrom payouts::payout_method_data::PixEmvBankTransfer::foreign_try_from(pix_emv)?, ))) } + grpc_api_types::payouts::payout_method::PayoutMethodData::Trustly(trustly) => { + Ok(Self::Bank(payouts::payout_method_data::Bank::Trustly( + payouts::payout_method_data::TrustlyBankTransfer::foreign_try_from(trustly)?, + ))) + } grpc_api_types::payouts::payout_method::PayoutMethodData::ApplePayDecrypt( apple_pay_decrypt, ) => Ok(Self::Wallet( @@ -1163,6 +1201,15 @@ impl ForeignTryFrom .transpose()?, customer, address, + payout_connector_metadata: value + .payout_connector_metadata + .map(|m| { + common_utils::pii::SecretSerdeValue::foreign_try_from(( + m, + "payout_connector_metadata", + )) + }) + .transpose()?, }) } } @@ -1433,6 +1480,43 @@ impl ForeignTryFrom Result<_, error_stack::Report> { + let email = customer + .email + .map(|email_str| { + common_utils::pii::Email::try_from(email_str.expose()).map_err(|e| { + error_stack::Report::new(IntegrationError::InvalidDataFormat { + field_name: "email", + context: IntegrationErrorContext { + additional_context: Some("Invalid email".to_owned()), + ..Default::default() + }, + }) + .attach_printable(format!("{e:?}")) + }) + }) + .transpose()?; + + Ok(payouts::payouts_types::PayoutCustomer { + name: customer.name, + email, + merchant_customer_id: customer.id, + connector_customer_id: customer.connector_customer_id, + phone_number: customer.phone_number, + phone_country_code: customer.phone_country_code, + }) + }, + ) + .transpose()?; + + let address = value + .address + .map(payouts::payouts_types::PayoutAddress::foreign_try_from) + .transpose()?; + Ok(Self { merchant_payout_id: value.merchant_payout_id.clone(), amount: common_utils::types::MinorUnit::new(amount.minor_amount), @@ -1441,6 +1525,8 @@ impl ForeignTryFrom Result<_, error_stack::Report> { + Ok(hyperswitch_masking::Secret::new( + serde_json::to_string(meta.peek()).change_context( + crate::errors::ConnectorError::ResponseDeserializationFailed { + context: Default::default(), + }, + )?, + )) + }, + ) + .transpose()?; Ok( grpc_api_types::payouts::PayoutServiceCreateRecipientResponse { merchant_payout_id: response.merchant_payout_id, @@ -2071,6 +2171,7 @@ pub fn generate_payout_create_recipient_response( connector_payout_id: response.connector_payout_id, error: None, status_code: u32::from(response.status_code), + connector_metadata, }, ) } @@ -2093,6 +2194,7 @@ pub fn generate_payout_create_recipient_response( issuer_details: None, }), status_code: u32::from(err.status_code), + connector_metadata: None, }, ), } diff --git a/crates/types-traits/domain_types/src/router_data.rs b/crates/types-traits/domain_types/src/router_data.rs index 052f8f9f7b..91ee844a7b 100644 --- a/crates/types-traits/domain_types/src/router_data.rs +++ b/crates/types-traits/domain_types/src/router_data.rs @@ -3697,6 +3697,19 @@ impl ForeignTryFrom<(&ConnectorAuthType, &connector_types::ConnectorVariant)> }), _ => Err(err().into()), }, + PayoutConnectorEnum::Trustly => match auth { + ConnectorAuthType::SignatureKey { + api_key, + key1, + api_secret, + } => Ok(Self::Trustly { + username: api_key.clone(), + password: key1.clone(), + private_key: api_secret.clone(), + base_url: None, + }), + _ => Err(err().into()), + }, }, } } diff --git a/crates/types-traits/domain_types/src/types.rs b/crates/types-traits/domain_types/src/types.rs index 18ad91030f..a35afccd7d 100644 --- a/crates/types-traits/domain_types/src/types.rs +++ b/crates/types-traits/domain_types/src/types.rs @@ -814,6 +814,7 @@ impl Connectors { PayoutConnectorEnum::Itaubank => patched.itaubank.apply(params_patch), PayoutConnectorEnum::Worldpayxml => patched.worldpayxml.apply(params_patch), PayoutConnectorEnum::Cybersource => patched.cybersource.apply(params_patch), + PayoutConnectorEnum::Trustly => patched.trustly.apply(params_patch), // Deutschebank uses `ConnectorParamsWithCaBundle`, so patch the resolved // URLs while leaving its `server_ca_bundle` untouched. PayoutConnectorEnum::Deutschebank => { diff --git a/crates/types-traits/grpc-api-types/proto/payouts.proto b/crates/types-traits/grpc-api-types/proto/payouts.proto index 5671f287a9..8f90a85293 100644 --- a/crates/types-traits/grpc-api-types/proto/payouts.proto +++ b/crates/types-traits/grpc-api-types/proto/payouts.proto @@ -148,6 +148,18 @@ message PixEmvBankTransferPayout { SecretString emv = 1; } +message TrustlyBankTransferPayout { + // The International Bank Account Number (IBAN). Provide either the IBAN or the + // bank_account_number/bank_number pair. + optional SecretString iban = 1; + // The bank account number (used when an IBAN is not available) + optional SecretString bank_account_number = 2; + // The bank/clearing number that identifies the destination bank + optional SecretString bank_number = 3; + // The ISO Alpha-2 country code of the bank (maps to Trustly's ClearingHouse) + CountryAlpha2 bank_country_code = 4; +} + message ApplePayDecrypt { // The device primary account number (DPAN) CardNumberType dpan = 1; @@ -222,6 +234,7 @@ message PayoutMethod { // BANK TRANSFER METHODS PixKeyBankTransferPayout pix_key = 12; PixEmvBankTransferPayout pix_emv = 13; + TrustlyBankTransferPayout trustly = 14; } } @@ -321,6 +334,9 @@ message PayoutServiceTransferRequest { optional SourceBankData source_bank_data = 14; // Description of the payout optional string description = 15; + // Connector metadata carried over from a preceding flow (e.g. the recipient + // account id returned by create_recipient). + optional SecretString payout_connector_metadata = 16; } // Response message for transferring a payout @@ -505,6 +521,9 @@ message PayoutServiceCreateRecipientResponse { optional ErrorInfo error = 4; // HTTP status code from the connector uint32 status_code = 5; + // Connector metadata produced while creating the recipient (e.g. the + // registered account id), to be echoed back on the subsequent transfer. + optional SecretString connector_metadata = 6; } // Request message for enrolling disburse account diff --git a/sdk/javascript/src/payments/_generated_grpc_client.ts b/sdk/javascript/src/payments/_generated_grpc_client.ts index 47065b1506..53cece3eea 100644 --- a/sdk/javascript/src/payments/_generated_grpc_client.ts +++ b/sdk/javascript/src/payments/_generated_grpc_client.ts @@ -334,18 +334,20 @@ const _SECRET_STRING_FIELDS: Record = { PixBankTransferPayout: ["bankAccountNumber", "taxId", "ispb"], PixKeyBankTransferPayout: ["pixKey"], PixEmvBankTransferPayout: ["emv"], + TrustlyBankTransferPayout: ["iban", "bankAccountNumber", "bankNumber"], ApplePayDecrypt: ["dpan", "expiryMonth", "expiryYear", "cardHolderName"], Paypal: ["email", "telephoneNumber", "paypalId"], Venmo: ["telephoneNumber"], InteracPayout: ["email"], OpenBankingUkPayout: ["accountHolderName", "iban"], PayoutServiceCreateRequest: ["connectorFeatureData", "accessToken"], - PayoutServiceTransferRequest: ["accessToken"], + PayoutServiceTransferRequest: ["accessToken", "payoutConnectorMetadata"], PayoutServiceStageRequest: ["accessToken"], PayoutServiceGetRequest: ["accessToken"], PayoutServiceVoidRequest: ["connectorFeatureData", "accessToken"], PayoutServiceCreateLinkRequest: ["connectorFeatureData", "accessToken"], PayoutServiceCreateRecipientRequest: ["accessToken"], + PayoutServiceCreateRecipientResponse: ["connectorMetadata"], PayoutServiceEnrollDisburseAccountRequest: ["accessToken"], PayoutMethodEligibilityRequest: ["connectorFeatureData", "accessToken"], SurchargeServiceCalculateRequest: ["postalCode"], @@ -524,7 +526,7 @@ const _MSG_FIELD_TYPES: Record> = { CompositeFrmPreRiskCheckResponse: { "preRiskCheckResponse": "FrmServicePreRiskCheckResponse", "accessTokenResponse": "MerchantAuthenticationServiceCreateServerAuthenticationTokenResponse" }, CompositeFrmPostRiskCheckResponse: { "postRiskCheckResponse": "FrmServicePostRiskCheckResponse", "accessTokenResponse": "MerchantAuthenticationServiceCreateServerAuthenticationTokenResponse" }, PayoutAddress: { "shippingAddress": "Address", "billingAddress": "Address" }, - PayoutMethod: { "card": "CardPayout", "ach": "AchBankTransferPayout", "bacs": "BacsBankTransferPayout", "sepa": "SepaBankTransferPayout", "pix": "PixBankTransferPayout", "applePayDecrypt": "ApplePayDecrypt", "paypal": "Paypal", "venmo": "Venmo", "interac": "InteracPayout", "openBankingUk": "OpenBankingUkPayout", "passthrough": "Passthrough", "pixKey": "PixKeyBankTransferPayout", "pixEmv": "PixEmvBankTransferPayout" }, + PayoutMethod: { "card": "CardPayout", "ach": "AchBankTransferPayout", "bacs": "BacsBankTransferPayout", "sepa": "SepaBankTransferPayout", "pix": "PixBankTransferPayout", "applePayDecrypt": "ApplePayDecrypt", "paypal": "Paypal", "venmo": "Venmo", "interac": "InteracPayout", "openBankingUk": "OpenBankingUkPayout", "passthrough": "Passthrough", "pixKey": "PixKeyBankTransferPayout", "pixEmv": "PixEmvBankTransferPayout", "trustly": "TrustlyBankTransferPayout" }, SourceBankData: { "ach": "AchBankTransferPayout", "bacs": "BacsBankTransferPayout", "sepa": "SepaBankTransferPayout", "pix": "PixBankTransferPayout", "pixKey": "PixKeyBankTransferPayout", "pixEmv": "PixEmvBankTransferPayout" }, PayoutServiceCreateRequest: { "address": "PayoutAddress", "payoutMethodData": "PayoutMethod", "amount": "Money", "customer": "Customer", "browserInfo": "BrowserInformation", "sourceBankData": "SourceBankData" }, PayoutServiceCreateResponse: { "error": "ErrorInfo" },