feat(connector): [Coinbase] [Opennode] Add support for crypto payments via PG redirection (#834)

Co-authored-by: arvindpatel24 <arvind.patel@juspay.in>
Co-authored-by: Jagan Elavarasan <jaganelavarasan@gmail.com>
This commit is contained in:
Arvind Patel
2023-04-11 13:05:57 +05:30
committed by GitHub
parent f46eaf3e3d
commit b3d1473734
43 changed files with 3053 additions and 92 deletions

View File

@ -60,6 +60,7 @@ cards = [
"bluesnap",
"braintree",
"checkout",
"coinbase",
"cybersource",
"dlocal",
"fiserv",
@ -67,6 +68,7 @@ cards = [
"mollie",
"multisafepay",
"nuvei",
"opennode",
"paypal",
"payu",
"shift4",
@ -99,6 +101,7 @@ bambora.base_url = "https://api.na.bambora.com"
bluesnap.base_url = "https://sandbox.bluesnap.com/"
braintree.base_url = "https://api.sandbox.braintreegateway.com/"
checkout.base_url = "https://api.sandbox.checkout.com/"
coinbase.base_url = "https://api.commerce.coinbase.com"
cybersource.base_url = "https://apitest.cybersource.com/"
dlocal.base_url = "https://sandbox.dlocal.com/"
fiserv.base_url = "https://cert.api.fiservapps.com/"
@ -107,6 +110,7 @@ klarna.base_url = "https://api-na.playground.klarna.com/"
mollie.base_url = "https://api.mollie.com/v2/"
multisafepay.base_url = "https://testapi.multisafepay.com/"
nuvei.base_url = "https://ppp-test.nuvei.com/"
opennode.base_url = "https://dev-api.opennode.com"
paypal.base_url = "https://www.sandbox.paypal.com/"
payu.base_url = "https://secure.snd.payu.com/"
rapyd.base_url = "https://sandboxapi.rapyd.net"

View File

@ -133,6 +133,7 @@ bambora.base_url = "https://api.na.bambora.com"
bluesnap.base_url = "https://sandbox.bluesnap.com/"
braintree.base_url = "https://api.sandbox.braintreegateway.com/"
checkout.base_url = "https://api.sandbox.checkout.com/"
coinbase.base_url = "https://api.commerce.coinbase.com"
cybersource.base_url = "https://apitest.cybersource.com/"
dlocal.base_url = "https://sandbox.dlocal.com/"
fiserv.base_url = "https://cert.api.fiservapps.com/"
@ -141,6 +142,7 @@ klarna.base_url = "https://api-na.playground.klarna.com/"
mollie.base_url = "https://api.mollie.com/v2/"
multisafepay.base_url = "https://testapi.multisafepay.com/"
nuvei.base_url = "https://ppp-test.nuvei.com/"
opennode.base_url = "https://dev-api.opennode.com"
paypal.base_url = "https://www.sandbox.paypal.com/"
payu.base_url = "https://secure.snd.payu.com/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
@ -157,6 +159,7 @@ wallets = ["klarna", "braintree", "applepay"]
cards = [
"adyen",
"authorizedotnet",
"coinbase",
"braintree",
"checkout",
"cybersource",

View File

@ -78,6 +78,7 @@ bambora.base_url = "https://api.na.bambora.com"
bluesnap.base_url = "https://sandbox.bluesnap.com/"
braintree.base_url = "https://api.sandbox.braintreegateway.com/"
checkout.base_url = "https://api.sandbox.checkout.com/"
coinbase.base_url = "https://api.commerce.coinbase.com"
cybersource.base_url = "https://apitest.cybersource.com/"
dlocal.base_url = "https://sandbox.dlocal.com/"
fiserv.base_url = "https://cert.api.fiservapps.com/"
@ -86,6 +87,7 @@ klarna.base_url = "https://api-na.playground.klarna.com/"
mollie.base_url = "https://api.mollie.com/v2/"
multisafepay.base_url = "https://testapi.multisafepay.com/"
nuvei.base_url = "https://ppp-test.nuvei.com/"
opennode.base_url = "https://dev-api.opennode.com"
paypal.base_url = "https://www.sandbox.paypal.com/"
payu.base_url = "https://secure.snd.payu.com/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
@ -108,6 +110,7 @@ cards = [
"bluesnap",
"braintree",
"checkout",
"coinbase",
"cybersource",
"dlocal",
"fiserv",
@ -115,6 +118,7 @@ cards = [
"mollie",
"multisafepay",
"nuvei",
"opennode",
"paypal",
"payu",
"shift4",

View File

@ -34,6 +34,7 @@ pub enum AttemptStatus {
VoidFailed,
AutoRefunded,
PartialCharged,
Unresolved,
#[default]
Pending,
Failure,
@ -266,6 +267,8 @@ pub enum Currency {
#[strum(serialize_all = "snake_case")]
pub enum EventType {
PaymentSucceeded,
PaymentProcessing,
ActionRequired,
RefundSucceeded,
RefundFailed,
DisputeOpened,
@ -299,6 +302,7 @@ pub enum IntentStatus {
Cancelled,
Processing,
RequiresCustomerAction,
RequiresMerchantAction,
RequiresPaymentMethod,
#[default]
RequiresConfirmation,
@ -416,6 +420,7 @@ pub enum PaymentMethodType {
GooglePay,
ApplePay,
Paypal,
CryptoCurrency,
}
#[derive(
@ -441,6 +446,7 @@ pub enum PaymentMethod {
PayLater,
Wallet,
BankRedirect,
Crypto,
}
#[derive(
@ -561,9 +567,11 @@ pub enum Connector {
Bluesnap,
Braintree,
Checkout,
Coinbase,
Cybersource,
#[default]
Dummy,
Opennode,
Bambora,
Dlocal,
Fiserv,
@ -618,6 +626,7 @@ pub enum RoutableConnectors {
Bluesnap,
Braintree,
Checkout,
Coinbase,
Cybersource,
Dlocal,
Fiserv,
@ -626,6 +635,7 @@ pub enum RoutableConnectors {
Mollie,
Multisafepay,
Nuvei,
Opennode,
Paypal,
Payu,
Rapyd,
@ -758,6 +768,7 @@ impl From<AttemptStatus> for IntentStatus {
AttemptStatus::Authorized => Self::RequiresCapture,
AttemptStatus::AuthenticationPending => Self::RequiresCustomerAction,
AttemptStatus::Unresolved => Self::RequiresMerchantAction,
AttemptStatus::PartialCharged
| AttemptStatus::Started
@ -826,3 +837,10 @@ pub enum DisputeStatus {
// dispute has been unsuccessfully challenged
DisputeLost,
}
#[derive(Debug, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
pub struct UnresolvedResponseReason {
pub code: String,
/// A message to merchant to give hint on next action he/she should do to resolve
pub message: String,
}

View File

@ -441,6 +441,7 @@ pub enum PaymentMethodData {
Wallet(WalletData),
PayLater(PayLaterData),
BankRedirect(BankRedirectData),
Crypto(CryptoData),
}
#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
@ -455,6 +456,7 @@ pub enum AdditionalPaymentData {
},
Wallet {},
PayLater {},
Crypto {},
}
impl From<&PaymentMethodData> for AdditionalPaymentData {
@ -478,6 +480,7 @@ impl From<&PaymentMethodData> for AdditionalPaymentData {
},
PaymentMethodData::Wallet(_) => Self::Wallet {},
PaymentMethodData::PayLater(_) => Self::PayLater {},
PaymentMethodData::Crypto(_) => Self::Crypto {},
}
}
}
@ -516,6 +519,10 @@ pub enum BankRedirectData {
},
}
#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub struct CryptoData {}
#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize, ToSchema)]
pub struct SofortBilling {
/// The country associated with the billing
@ -620,6 +627,7 @@ pub enum PaymentMethodDataResponse {
PayLater(PayLaterData),
Paypal,
BankRedirect(BankRedirectData),
Crypto(CryptoData),
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, ToSchema)]
@ -1125,6 +1133,7 @@ impl From<PaymentMethodData> for PaymentMethodDataResponse {
PaymentMethodData::BankRedirect(bank_redirect_data) => {
Self::BankRedirect(bank_redirect_data)
}
PaymentMethodData::Crypto(crpto_data) => Self::Crypto(crpto_data),
}
}
}

View File

@ -9,6 +9,9 @@ use crate::{disputes, enums as api_enums, payments, refunds};
pub enum IncomingWebhookEvent {
PaymentIntentFailure,
PaymentIntentSuccess,
PaymentIntentProcessing,
PaymentActionRequired,
EventNotSupported,
RefundFailure,
RefundSuccess,
DisputeOpened,
@ -36,6 +39,9 @@ impl From<IncomingWebhookEvent> for WebhookFlow {
match evt {
IncomingWebhookEvent::PaymentIntentFailure => Self::Payment,
IncomingWebhookEvent::PaymentIntentSuccess => Self::Payment,
IncomingWebhookEvent::PaymentIntentProcessing => Self::Payment,
IncomingWebhookEvent::PaymentActionRequired => Self::Payment,
IncomingWebhookEvent::EventNotSupported => Self::Payment,
IncomingWebhookEvent::RefundSuccess => Self::Refund,
IncomingWebhookEvent::RefundFailure => Self::Refund,
IncomingWebhookEvent::DisputeOpened => Self::Dispute,

View File

@ -221,6 +221,7 @@ impl From<api_enums::IntentStatus> for StripePaymentStatus {
api_enums::IntentStatus::Failed => Self::Canceled,
api_enums::IntentStatus::Processing => Self::Processing,
api_enums::IntentStatus::RequiresCustomerAction => Self::RequiresAction,
api_enums::IntentStatus::RequiresMerchantAction => Self::RequiresAction,
api_enums::IntentStatus::RequiresPaymentMethod => Self::RequiresPaymentMethod,
api_enums::IntentStatus::RequiresConfirmation => Self::RequiresConfirmation,
api_enums::IntentStatus::RequiresCapture => Self::RequiresCapture,

View File

@ -187,6 +187,7 @@ impl From<api_enums::IntentStatus> for StripeSetupStatus {
api_enums::IntentStatus::Failed => Self::Canceled,
api_enums::IntentStatus::Processing => Self::Processing,
api_enums::IntentStatus::RequiresCustomerAction => Self::RequiresAction,
api_enums::IntentStatus::RequiresMerchantAction => Self::RequiresAction,
api_enums::IntentStatus::RequiresPaymentMethod => Self::RequiresPaymentMethod,
api_enums::IntentStatus::RequiresConfirmation => Self::RequiresConfirmation,
api_enums::IntentStatus::RequiresCapture => {

View File

@ -255,6 +255,7 @@ pub struct Connectors {
pub bluesnap: ConnectorParams,
pub braintree: ConnectorParams,
pub checkout: ConnectorParams,
pub coinbase: ConnectorParams,
pub cybersource: ConnectorParams,
pub dlocal: ConnectorParams,
pub fiserv: ConnectorParams,
@ -263,6 +264,7 @@ pub struct Connectors {
pub mollie: ConnectorParams,
pub multisafepay: ConnectorParams,
pub nuvei: ConnectorParams,
pub opennode: ConnectorParams,
pub paypal: ConnectorParams,
pub payu: ConnectorParams,
pub rapyd: ConnectorParams,

View File

@ -7,6 +7,7 @@ pub mod bambora;
pub mod bluesnap;
pub mod braintree;
pub mod checkout;
pub mod coinbase;
pub mod cybersource;
pub mod dlocal;
pub mod fiserv;
@ -14,6 +15,7 @@ pub mod globalpay;
pub mod klarna;
pub mod multisafepay;
pub mod nuvei;
pub mod opennode;
pub mod paypal;
pub mod payu;
pub mod rapyd;
@ -29,8 +31,9 @@ pub mod mollie;
pub use self::{
aci::Aci, adyen::Adyen, airwallex::Airwallex, applepay::Applepay,
authorizedotnet::Authorizedotnet, bambora::Bambora, bluesnap::Bluesnap, braintree::Braintree,
checkout::Checkout, cybersource::Cybersource, dlocal::Dlocal, fiserv::Fiserv,
globalpay::Globalpay, klarna::Klarna, mollie::Mollie, multisafepay::Multisafepay, nuvei::Nuvei,
paypal::Paypal, payu::Payu, rapyd::Rapyd, shift4::Shift4, stripe::Stripe, trustpay::Trustpay,
worldline::Worldline, worldpay::Worldpay,
checkout::Checkout, coinbase::Coinbase, cybersource::Cybersource, dlocal::Dlocal,
fiserv::Fiserv, globalpay::Globalpay, klarna::Klarna, mollie::Mollie,
multisafepay::Multisafepay, nuvei::Nuvei, opennode::Opennode, paypal::Paypal, payu::Payu,
rapyd::Rapyd, shift4::Shift4, stripe::Stripe, trustpay::Trustpay, worldline::Worldline,
worldpay::Worldpay,
};

View File

@ -111,6 +111,11 @@ impl TryFrom<&types::PaymentsAuthorizeRouterData> for AciPaymentsRequest {
api::PaymentMethodData::PayLater(_) => PaymentDetails::Klarna,
api::PaymentMethodData::Wallet(_) => PaymentDetails::Wallet,
api::PaymentMethodData::BankRedirect(_) => PaymentDetails::BankRedirect,
api::PaymentMethodData::Crypto(_) => Err(errors::ConnectorError::NotSupported {
payment_method: format!("{:?}", item.payment_method),
connector: "Aci",
payment_experience: api_models::enums::PaymentExperience::RedirectToUrl.to_string(),
})?,
};
let auth = AciAuthType::try_from(&item.connector_auth_type)?;

View File

@ -431,6 +431,14 @@ impl<'a> TryFrom<&types::PaymentsAuthorizeRouterData> for AdyenPaymentRequest<'a
storage_models::enums::PaymentMethod::BankRedirect => {
get_bank_redirect_specific_payment_data(item)
}
storage_models::enums::PaymentMethod::Crypto => {
Err(errors::ConnectorError::NotSupported {
payment_method: format!("{:?}", item.payment_method),
connector: "Adyen",
payment_experience: api_models::enums::PaymentExperience::RedirectToUrl
.to_string(),
})?
}
}
}
}
@ -645,6 +653,11 @@ fn get_payment_method_data<'a>(
}
}
}
api::PaymentMethodData::Crypto(_) => Err(errors::ConnectorError::NotSupported {
payment_method: format!("{:?}", item.payment_method),
connector: "Adyen",
payment_experience: api_models::enums::PaymentExperience::RedirectToUrl.to_string(),
})?,
}
}

View File

@ -68,11 +68,12 @@ enum PaymentDetails {
BankRedirect,
}
impl From<api_models::payments::PaymentMethodData> for PaymentDetails {
fn from(value: api_models::payments::PaymentMethodData) -> Self {
impl TryFrom<api_models::payments::PaymentMethodData> for PaymentDetails {
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(value: api_models::payments::PaymentMethodData) -> Result<Self, Self::Error> {
match value {
api::PaymentMethodData::Card(ref ccard) => {
Self::CreditCard(CreditCardDetails {
Ok(Self::CreditCard(CreditCardDetails {
card_number: ccard.card_number.clone(),
// expiration_date: format!("{expiry_year}-{expiry_month}").into(),
expiration_date: ccard
@ -81,11 +82,16 @@ impl From<api_models::payments::PaymentMethodData> for PaymentDetails {
.zip(ccard.card_exp_year.clone())
.map(|(expiry_month, expiry_year)| format!("{expiry_year}-{expiry_month}")),
card_code: Some(ccard.card_cvc.clone()),
})
}))
}
api::PaymentMethodData::PayLater(_) => Self::Klarna,
api::PaymentMethodData::Wallet(_) => Self::Wallet,
api::PaymentMethodData::BankRedirect(_) => Self::BankRedirect,
api::PaymentMethodData::PayLater(_) => Ok(Self::Klarna),
api::PaymentMethodData::Wallet(_) => Ok(Self::Wallet),
api::PaymentMethodData::BankRedirect(_) => Ok(Self::BankRedirect),
api::PaymentMethodData::Crypto(_) => Err(errors::ConnectorError::NotSupported {
payment_method: format!("{value:?}"),
connector: "AuthorizeDotNet",
payment_experience: api_models::enums::PaymentExperience::RedirectToUrl.to_string(),
})?,
}
}
}
@ -159,7 +165,7 @@ impl From<enums::CaptureMethod> for AuthorizationType {
impl TryFrom<&types::PaymentsAuthorizeRouterData> for CreateTransactionRequest {
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result<Self, Self::Error> {
let payment_details = item.request.payment_method_data.clone().into();
let payment_details = PaymentDetails::try_from(item.request.payment_method_data.clone())?;
let authorization_indicator_type =
item.request.capture_method.map(|c| AuthorizationIndicator {
authorization_indicator: c.into(),

View File

@ -72,7 +72,8 @@ impl TryFrom<&types::PaymentsAuthorizeRouterData> for PaymentsRequest {
api::PaymentMethodData::Card(ref ccard) => Some(ccard),
api::PaymentMethodData::Wallet(_)
| api::PaymentMethodData::PayLater(_)
| api::PaymentMethodData::BankRedirect(_) => None,
| api::PaymentMethodData::BankRedirect(_)
| api::PaymentMethodData::Crypto(_) => None,
};
let three_ds = match item.auth_type {

View File

@ -0,0 +1,570 @@
mod transformers;
use std::fmt::Debug;
use common_utils::{crypto, ext_traits::ByteSliceExt};
use error_stack::{IntoReport, ResultExt};
use transformers as coinbase;
use self::coinbase::CoinbaseWebhookDetails;
use super::utils;
use crate::{
configs::settings,
core::errors::{self, CustomResult},
db, headers,
services::{self, ConnectorIntegration},
types::{
self,
api::{self, ConnectorCommon, ConnectorCommonExt},
ErrorResponse, Response,
},
utils::{BytesExt, Encode},
};
#[derive(Debug, Clone)]
pub struct Coinbase;
impl api::Payment for Coinbase {}
impl api::PaymentSession for Coinbase {}
impl api::ConnectorAccessToken for Coinbase {}
impl api::PreVerify for Coinbase {}
impl api::PaymentAuthorize for Coinbase {}
impl api::PaymentSync for Coinbase {}
impl api::PaymentCapture for Coinbase {}
impl api::PaymentVoid for Coinbase {}
impl api::Refund for Coinbase {}
impl api::RefundExecute for Coinbase {}
impl api::RefundSync for Coinbase {}
impl<Flow, Request, Response> ConnectorCommonExt<Flow, Request, Response> for Coinbase
where
Self: ConnectorIntegration<Flow, Request, Response>,
{
fn build_headers(
&self,
req: &types::RouterData<Flow, Request, Response>,
_connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
let mut header = vec![
(
headers::CONTENT_TYPE.to_string(),
self.common_get_content_type().to_string(),
),
(headers::X_CC_VERSION.to_string(), "2018-03-22".to_string()),
];
let mut api_key = self.get_auth_header(&req.connector_auth_type)?;
header.append(&mut api_key);
Ok(header)
}
}
impl ConnectorCommon for Coinbase {
fn id(&self) -> &'static str {
"coinbase"
}
fn common_get_content_type(&self) -> &'static str {
"application/json"
}
fn base_url<'a>(&self, connectors: &'a settings::Connectors) -> &'a str {
connectors.coinbase.base_url.as_ref()
}
fn get_auth_header(
&self,
auth_type: &types::ConnectorAuthType,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
let auth: coinbase::CoinbaseAuthType = coinbase::CoinbaseAuthType::try_from(auth_type)
.change_context(errors::ConnectorError::FailedToObtainAuthType)?;
Ok(vec![(headers::X_CC_API_KEY.to_string(), auth.api_key)])
}
fn build_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
let response: coinbase::CoinbaseErrorResponse = res
.response
.parse_struct("CoinbaseErrorResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
Ok(ErrorResponse {
status_code: res.status_code,
code: response.code,
message: response.message,
reason: response.reason,
})
}
}
impl ConnectorIntegration<api::Session, types::PaymentsSessionData, types::PaymentsResponseData>
for Coinbase
{
//TODO: implement sessions flow
}
impl ConnectorIntegration<api::AccessTokenAuth, types::AccessTokenRequestData, types::AccessToken>
for Coinbase
{
}
impl ConnectorIntegration<api::Verify, types::VerifyRequestData, types::PaymentsResponseData>
for Coinbase
{
}
impl ConnectorIntegration<api::Authorize, types::PaymentsAuthorizeData, types::PaymentsResponseData>
for Coinbase
{
fn get_headers(
&self,
req: &types::PaymentsAuthorizeRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::PaymentsAuthorizeRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Ok(format!("{}/charges", self.base_url(_connectors)))
}
fn get_request_body(
&self,
req: &types::PaymentsAuthorizeRouterData,
) -> CustomResult<Option<String>, errors::ConnectorError> {
let req_obj = coinbase::CoinbasePaymentsRequest::try_from(req)?;
let coinbase_req =
Encode::<coinbase::CoinbasePaymentsRequest>::encode_to_string_of_json(&req_obj)
.change_context(errors::ConnectorError::RequestEncodingFailed)?;
Ok(Some(coinbase_req))
}
fn build_request(
&self,
req: &types::PaymentsAuthorizeRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
Ok(Some(
services::RequestBuilder::new()
.method(services::Method::Post)
.url(&types::PaymentsAuthorizeType::get_url(
self, req, connectors,
)?)
.headers(types::PaymentsAuthorizeType::get_headers(
self, req, connectors,
)?)
.body(types::PaymentsAuthorizeType::get_request_body(self, req)?)
.build(),
))
}
fn handle_response(
&self,
data: &types::PaymentsAuthorizeRouterData,
res: Response,
) -> CustomResult<types::PaymentsAuthorizeRouterData, errors::ConnectorError> {
let response: coinbase::CoinbasePaymentsResponse = res
.response
.parse_struct("Coinbase PaymentsAuthorizeResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::PSync, types::PaymentsSyncData, types::PaymentsResponseData>
for Coinbase
{
fn get_headers(
&self,
req: &types::PaymentsSyncRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::PaymentsSyncRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
let connector_id = _req
.request
.connector_transaction_id
.get_connector_transaction_id()
.change_context(errors::ConnectorError::MissingConnectorTransactionID)?;
Ok(format!(
"{}/charges/{}",
self.base_url(_connectors),
connector_id
))
}
fn build_request(
&self,
req: &types::PaymentsSyncRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
Ok(Some(
services::RequestBuilder::new()
.method(services::Method::Get)
.url(&types::PaymentsSyncType::get_url(self, req, connectors)?)
.headers(types::PaymentsSyncType::get_headers(self, req, connectors)?)
.build(),
))
}
fn handle_response(
&self,
data: &types::PaymentsSyncRouterData,
res: Response,
) -> CustomResult<types::PaymentsSyncRouterData, errors::ConnectorError> {
let response: coinbase::CoinbasePaymentsResponse = res
.response
.parse_struct("coinbase PaymentsSyncResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::Capture, types::PaymentsCaptureData, types::PaymentsResponseData>
for Coinbase
{
fn get_headers(
&self,
req: &types::PaymentsCaptureRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::PaymentsCaptureRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into())
}
fn get_request_body(
&self,
_req: &types::PaymentsCaptureRouterData,
) -> CustomResult<Option<String>, errors::ConnectorError> {
Err(errors::ConnectorError::NotImplemented("get_request_body method".to_string()).into())
}
fn build_request(
&self,
req: &types::PaymentsCaptureRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
Ok(Some(
services::RequestBuilder::new()
.method(services::Method::Post)
.url(&types::PaymentsCaptureType::get_url(self, req, connectors)?)
.headers(types::PaymentsCaptureType::get_headers(
self, req, connectors,
)?)
.build(),
))
}
fn handle_response(
&self,
data: &types::PaymentsCaptureRouterData,
res: Response,
) -> CustomResult<types::PaymentsCaptureRouterData, errors::ConnectorError> {
let response: coinbase::CoinbasePaymentsResponse = res
.response
.parse_struct("Coinbase PaymentsCaptureResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::Void, types::PaymentsCancelData, types::PaymentsResponseData>
for Coinbase
{
}
impl ConnectorIntegration<api::Execute, types::RefundsData, types::RefundsResponseData>
for Coinbase
{
fn get_headers(
&self,
req: &types::RefundsRouterData<api::Execute>,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::RefundsRouterData<api::Execute>,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into())
}
fn get_request_body(
&self,
req: &types::RefundsRouterData<api::Execute>,
) -> CustomResult<Option<String>, errors::ConnectorError> {
let req_obj = coinbase::CoinbaseRefundRequest::try_from(req)?;
let coinbase_req =
Encode::<coinbase::CoinbaseRefundRequest>::encode_to_string_of_json(&req_obj)
.change_context(errors::ConnectorError::RequestEncodingFailed)?;
Ok(Some(coinbase_req))
}
fn build_request(
&self,
req: &types::RefundsRouterData<api::Execute>,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
let request = services::RequestBuilder::new()
.method(services::Method::Post)
.url(&types::RefundExecuteType::get_url(self, req, connectors)?)
.headers(types::RefundExecuteType::get_headers(
self, req, connectors,
)?)
.body(types::RefundExecuteType::get_request_body(self, req)?)
.build();
Ok(Some(request))
}
fn handle_response(
&self,
data: &types::RefundsRouterData<api::Execute>,
res: Response,
) -> CustomResult<types::RefundsRouterData<api::Execute>, errors::ConnectorError> {
let response: coinbase::RefundResponse = res
.response
.parse_struct("coinbase RefundResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::RSync, types::RefundsData, types::RefundsResponseData> for Coinbase {
fn get_headers(
&self,
req: &types::RefundSyncRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::RefundSyncRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into())
}
fn build_request(
&self,
req: &types::RefundSyncRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
Ok(Some(
services::RequestBuilder::new()
.method(services::Method::Get)
.url(&types::RefundSyncType::get_url(self, req, connectors)?)
.headers(types::RefundSyncType::get_headers(self, req, connectors)?)
.body(types::RefundSyncType::get_request_body(self, req)?)
.build(),
))
}
fn handle_response(
&self,
data: &types::RefundSyncRouterData,
res: Response,
) -> CustomResult<types::RefundSyncRouterData, errors::ConnectorError> {
let response: coinbase::RefundResponse = res
.response
.parse_struct("coinbase RefundSyncResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
#[async_trait::async_trait]
impl api::IncomingWebhook for Coinbase {
fn get_webhook_source_verification_algorithm(
&self,
_request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Box<dyn crypto::VerifySignature + Send>, errors::ConnectorError> {
Ok(Box::new(crypto::HmacSha256))
}
fn get_webhook_source_verification_signature(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let base64_signature =
utils::get_header_key_value("X-CC-Webhook-Signature", request.headers)?;
hex::decode(base64_signature)
.into_report()
.change_context(errors::ConnectorError::WebhookSourceVerificationFailed)
}
fn get_webhook_source_verification_message(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
_merchant_id: &str,
_secret: &[u8],
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let message = std::str::from_utf8(request.body)
.into_report()
.change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?;
Ok(message.to_string().into_bytes())
}
async fn get_webhook_source_verification_merchant_secret(
&self,
db: &dyn db::StorageInterface,
merchant_id: &str,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let key = format!("whsec_verification_{}_{}", self.id(), merchant_id);
let secret = db
.get_key(&key)
.await
.change_context(errors::ConnectorError::WebhookVerificationSecretNotFound)?;
Ok(secret)
}
fn get_webhook_object_reference_id(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> {
let notif: CoinbaseWebhookDetails = request
.body
.parse_struct("CoinbaseWebhookDetails")
.change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?;
Ok(api_models::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::ConnectorTransactionId(notif.event.data.id),
))
}
fn get_webhook_event_type(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api::IncomingWebhookEvent, errors::ConnectorError> {
let notif: CoinbaseWebhookDetails = request
.body
.parse_struct("CoinbaseWebhookDetails")
.change_context(errors::ConnectorError::WebhookEventTypeNotFound)?;
match notif.event.event_type {
coinbase::WebhookEventType::Confirmed | coinbase::WebhookEventType::Resolved => {
Ok(api::IncomingWebhookEvent::PaymentIntentSuccess)
}
coinbase::WebhookEventType::Failed => {
Ok(api::IncomingWebhookEvent::PaymentActionRequired)
}
coinbase::WebhookEventType::Pending => {
Ok(api::IncomingWebhookEvent::PaymentIntentProcessing)
}
_ => Ok(api::IncomingWebhookEvent::EventNotSupported),
}
}
fn get_webhook_resource_object(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<serde_json::Value, errors::ConnectorError> {
let notif: CoinbaseWebhookDetails = request
.body
.parse_struct("CoinbaseWebhookDetails")
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
Encode::<CoinbaseWebhookDetails>::encode_to_value(&notif.event)
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)
}
}

View File

@ -0,0 +1,405 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{
connector::utils::{self, AddressDetailsData, PaymentsAuthorizeRequestData, RouterData},
core::errors,
pii::Secret,
services,
types::{self, api, storage::enums},
};
#[derive(Debug, Default, Eq, PartialEq, Serialize)]
pub struct LocalPrice {
pub amount: String,
pub currency: String,
}
#[derive(Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct Metadata {
pub customer_id: String,
pub customer_name: String,
}
#[derive(Default, Debug, Serialize, Eq, PartialEq)]
pub struct CoinbasePaymentsRequest {
pub name: Secret<String>,
pub description: String,
pub pricing_type: String,
pub local_price: LocalPrice,
pub redirect_url: String,
pub cancel_url: String,
}
impl TryFrom<&types::PaymentsAuthorizeRouterData> for CoinbasePaymentsRequest {
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result<Self, Self::Error> {
get_crypto_specific_payment_data(item)
}
}
// Auth Struct
pub struct CoinbaseAuthType {
pub(super) api_key: String,
}
impl TryFrom<&types::ConnectorAuthType> for CoinbaseAuthType {
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(_auth_type: &types::ConnectorAuthType) -> Result<Self, Self::Error> {
if let types::ConnectorAuthType::HeaderKey { api_key } = _auth_type {
Ok(Self {
api_key: api_key.to_string(),
})
} else {
Err(errors::ConnectorError::FailedToObtainAuthType.into())
}
}
}
// PaymentsResponse
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub enum CoinbasePaymentStatus {
New,
#[default]
Pending,
Completed,
Expired,
Unresolved,
Resolved,
Cancelled,
#[serde(rename = "PENDING REFUND")]
PendingRefund,
Refunded,
}
impl From<CoinbasePaymentStatus> for enums::AttemptStatus {
fn from(item: CoinbasePaymentStatus) -> Self {
match item {
CoinbasePaymentStatus::Completed | CoinbasePaymentStatus::Resolved => Self::Charged,
CoinbasePaymentStatus::Expired => Self::Failure,
CoinbasePaymentStatus::New => Self::AuthenticationPending,
CoinbasePaymentStatus::Unresolved => Self::Unresolved,
_ => Self::Pending,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, strum::Display)]
#[serde(rename_all = "UPPERCASE")]
#[strum(serialize_all = "UPPERCASE")]
pub enum UnResolvedContext {
Underpaid,
Overpaid,
Delayed,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Timeline {
status: CoinbasePaymentStatus,
context: Option<UnResolvedContext>,
time: String,
pub payment: Option<TimelinePayment>,
}
#[derive(Default, Debug, Serialize, Deserialize)]
pub struct CoinbasePaymentsResponse {
// status: CoinbasePaymentStatus,
// id: String,
data: CoinbasePaymentResponseData,
}
impl<F, T>
TryFrom<types::ResponseRouterData<F, CoinbasePaymentsResponse, T, types::PaymentsResponseData>>
for types::RouterData<F, T, types::PaymentsResponseData>
{
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(
item: types::ResponseRouterData<
F,
CoinbasePaymentsResponse,
T,
types::PaymentsResponseData,
>,
) -> Result<Self, Self::Error> {
let form_fields = HashMap::new();
let redirection_data = services::RedirectForm {
endpoint: item.response.data.hosted_url.to_string(),
method: services::Method::Get,
form_fields,
};
let timeline = item
.response
.data
.timeline
.last()
.ok_or_else(|| errors::ConnectorError::ResponseHandlingFailed)?
.clone();
let connector_id = types::ResponseId::ConnectorTransactionId(item.response.data.id);
let attempt_status = timeline.status.clone();
let response_data = timeline.context.map_or(
Ok(types::PaymentsResponseData::TransactionResponse {
resource_id: connector_id.clone(),
redirection_data: Some(redirection_data),
mandate_reference: None,
connector_metadata: None,
}),
|context| {
Ok(types::PaymentsResponseData::TransactionUnresolvedResponse{
resource_id: connector_id,
reason: Some(api::enums::UnresolvedResponseReason {
code: context.to_string(),
message: "Please check the transaction in coinbase dashboard and resolve manually"
.to_string(),
})
})
},
);
Ok(Self {
status: enums::AttemptStatus::from(attempt_status),
response: response_data,
..item.data
})
}
}
// REFUND :
// Type definition for RefundRequest
#[derive(Default, Debug, Serialize)]
pub struct CoinbaseRefundRequest {}
impl<F> TryFrom<&types::RefundsRouterData<F>> for CoinbaseRefundRequest {
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(_item: &types::RefundsRouterData<F>) -> Result<Self, Self::Error> {
Err(errors::ConnectorError::NotImplemented("try_from RefundsRouterData".to_string()).into())
}
}
// Type definition for Refund Response
#[allow(dead_code)]
#[derive(Debug, Serialize, Default, Deserialize, Clone)]
pub enum RefundStatus {
Succeeded,
Failed,
#[default]
Processing,
}
impl From<RefundStatus> for enums::RefundStatus {
fn from(item: RefundStatus) -> Self {
match item {
RefundStatus::Succeeded => Self::Success,
RefundStatus::Failed => Self::Failure,
RefundStatus::Processing => Self::Pending,
}
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct RefundResponse {}
impl TryFrom<types::RefundsResponseRouterData<api::Execute, RefundResponse>>
for types::RefundsRouterData<api::Execute>
{
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(
_item: types::RefundsResponseRouterData<api::Execute, RefundResponse>,
) -> Result<Self, Self::Error> {
Err(errors::ConnectorError::NotImplemented(
"try_from RefundsResponseRouterData".to_string(),
)
.into())
}
}
impl TryFrom<types::RefundsResponseRouterData<api::RSync, RefundResponse>>
for types::RefundsRouterData<api::RSync>
{
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(
_item: types::RefundsResponseRouterData<api::RSync, RefundResponse>,
) -> Result<Self, Self::Error> {
Err(errors::ConnectorError::NotImplemented(
"try_from RefundsResponseRouterData".to_string(),
)
.into())
}
}
#[derive(Default, Debug, Serialize, Deserialize, PartialEq)]
pub struct CoinbaseErrorResponse {
pub code: String,
pub message: String,
pub reason: Option<String>,
}
#[derive(Default, Debug, Deserialize, PartialEq)]
pub struct CoinbaseConnectorMeta {
pub pricing_type: String,
}
fn get_crypto_specific_payment_data(
item: &types::PaymentsAuthorizeRouterData,
) -> Result<CoinbasePaymentsRequest, error_stack::Report<errors::ConnectorError>> {
let billing_address = item
.get_billing()?
.address
.as_ref()
.ok_or_else(utils::missing_field_err("billing.address"))?;
let name = billing_address.get_first_name()?.to_owned();
let description = item.get_description()?;
let connector_meta: CoinbaseConnectorMeta =
utils::to_connector_meta_from_secret(item.connector_meta_data.clone())?;
let pricing_type = connector_meta.pricing_type;
let local_price = get_local_price(item);
let redirect_url = item.request.get_return_url()?;
let cancel_url = item.request.get_return_url()?;
Ok(CoinbasePaymentsRequest {
name,
description,
pricing_type,
local_price,
redirect_url,
cancel_url,
})
}
fn get_local_price(item: &types::PaymentsAuthorizeRouterData) -> LocalPrice {
LocalPrice {
amount: format!("{:?}", item.request.amount),
currency: item.request.currency.to_string(),
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CoinbaseWebhookDetails {
pub attempt_number: i64,
pub event: Event,
pub id: String,
pub scheduled_for: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Event {
pub api_version: String,
pub created_at: String,
pub data: CoinbasePaymentResponseData,
pub id: String,
pub resource: String,
#[serde(rename = "type")]
pub event_type: WebhookEventType,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum WebhookEventType {
#[serde(rename = "charge:confirmed")]
Confirmed,
#[serde(rename = "charge:created")]
Created,
#[serde(rename = "charge:pending")]
Pending,
#[serde(rename = "charge:failed")]
Failed,
#[serde(rename = "charge:resolved")]
Resolved,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CoinbasePaymentResponseData {
pub id: String,
pub code: String,
pub name: String,
pub utxo: bool,
pub pricing: HashMap<String, OverpaymentAbsoluteThreshold>,
pub fee_rate: f64,
pub logo_url: String,
pub metadata: Metadata,
pub payments: Vec<PaymentElement>,
pub resource: String,
pub timeline: Vec<Timeline>,
pub pwcb_only: bool,
pub cancel_url: String,
pub created_at: String,
pub expires_at: String,
pub hosted_url: String,
pub brand_color: String,
pub description: String,
pub confirmed_at: Option<String>,
pub fees_settled: bool,
pub pricing_type: String,
pub redirect_url: String,
pub support_email: String,
pub brand_logo_url: String,
pub offchain_eligible: bool,
pub organization_name: String,
pub payment_threshold: PaymentThreshold,
pub coinbase_managed_merchant: bool,
}
#[derive(Debug, Serialize, Default, Deserialize)]
pub struct PaymentThreshold {
pub overpayment_absolute_threshold: OverpaymentAbsoluteThreshold,
pub overpayment_relative_threshold: String,
pub underpayment_absolute_threshold: OverpaymentAbsoluteThreshold,
pub underpayment_relative_threshold: String,
}
#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq)]
pub struct OverpaymentAbsoluteThreshold {
pub amount: String,
pub currency: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PaymentElement {
pub net: CoinbaseProcessingFee,
pub block: Block,
pub value: CoinbaseProcessingFee,
pub status: String,
pub network: String,
pub deposited: Deposited,
pub payment_id: String,
pub detected_at: String,
pub transaction_id: String,
pub coinbase_processing_fee: CoinbaseProcessingFee,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Block {
pub hash: Option<String>,
pub height: Option<i64>,
pub confirmations: Option<i64>,
pub confirmations_required: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CoinbaseProcessingFee {
pub local: Option<OverpaymentAbsoluteThreshold>,
pub crypto: OverpaymentAbsoluteThreshold,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Deposited {
pub amount: Amount,
pub status: String,
pub destination: String,
pub exchange_rate: Option<serde_json::Value>,
pub autoconversion_status: String,
pub autoconversion_enabled: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Amount {
pub net: CoinbaseProcessingFee,
pub gross: CoinbaseProcessingFee,
pub coinbase_fee: CoinbaseProcessingFee,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TimelinePayment {
pub value: OverpaymentAbsoluteThreshold,
pub network: String,
pub transaction_id: String,
}

View File

@ -0,0 +1,575 @@
mod transformers;
use std::fmt::Debug;
use common_utils::crypto;
use error_stack::{IntoReport, ResultExt};
use transformers as opennode;
use self::opennode::OpennodeWebhookDetails;
use crate::{
configs::settings,
core::errors::{self, CustomResult},
db, headers,
services::{self, ConnectorIntegration},
types::{
self,
api::{self, ConnectorCommon, ConnectorCommonExt},
ErrorResponse, Response,
},
utils::{BytesExt, Encode},
};
#[derive(Debug, Clone)]
pub struct Opennode;
impl api::Payment for Opennode {}
impl api::PaymentSession for Opennode {}
impl api::ConnectorAccessToken for Opennode {}
impl api::PreVerify for Opennode {}
impl api::PaymentAuthorize for Opennode {}
impl api::PaymentSync for Opennode {}
impl api::PaymentCapture for Opennode {}
impl api::PaymentVoid for Opennode {}
impl api::Refund for Opennode {}
impl api::RefundExecute for Opennode {}
impl api::RefundSync for Opennode {}
impl<Flow, Request, Response> ConnectorCommonExt<Flow, Request, Response> for Opennode
where
Self: ConnectorIntegration<Flow, Request, Response>,
{
fn build_headers(
&self,
req: &types::RouterData<Flow, Request, Response>,
_connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
let mut header = vec![
(
headers::CONTENT_TYPE.to_string(),
self.common_get_content_type().to_string(),
),
(
headers::ACCEPT.to_string(),
self.common_get_content_type().to_string(),
),
];
let mut api_key = self.get_auth_header(&req.connector_auth_type)?;
header.append(&mut api_key);
Ok(header)
}
}
impl ConnectorCommon for Opennode {
fn id(&self) -> &'static str {
"opennode"
}
fn common_get_content_type(&self) -> &'static str {
"application/json"
}
fn base_url<'a>(&self, connectors: &'a settings::Connectors) -> &'a str {
connectors.opennode.base_url.as_ref()
}
fn get_auth_header(
&self,
auth_type: &types::ConnectorAuthType,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
let auth = opennode::OpennodeAuthType::try_from(auth_type)
.change_context(errors::ConnectorError::FailedToObtainAuthType)?;
Ok(vec![(headers::AUTHORIZATION.to_string(), auth.api_key)])
}
fn build_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
let response: opennode::OpennodeErrorResponse = res
.response
.parse_struct("OpennodeErrorResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
Ok(ErrorResponse {
status_code: res.status_code,
code: response.code,
message: response.message,
reason: response.reason,
})
}
}
impl ConnectorIntegration<api::Session, types::PaymentsSessionData, types::PaymentsResponseData>
for Opennode
{
//TODO: implement sessions flow
}
impl ConnectorIntegration<api::AccessTokenAuth, types::AccessTokenRequestData, types::AccessToken>
for Opennode
{
}
impl ConnectorIntegration<api::Verify, types::VerifyRequestData, types::PaymentsResponseData>
for Opennode
{
}
impl ConnectorIntegration<api::Authorize, types::PaymentsAuthorizeData, types::PaymentsResponseData>
for Opennode
{
fn get_headers(
&self,
req: &types::PaymentsAuthorizeRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::PaymentsAuthorizeRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Ok(format!("{}/v1/charges", self.base_url(_connectors)))
}
fn get_request_body(
&self,
req: &types::PaymentsAuthorizeRouterData,
) -> CustomResult<Option<String>, errors::ConnectorError> {
let req_obj = opennode::OpennodePaymentsRequest::try_from(req)?;
let opennode_req =
Encode::<opennode::OpennodePaymentsRequest>::encode_to_string_of_json(&req_obj)
.change_context(errors::ConnectorError::RequestEncodingFailed)?;
Ok(Some(opennode_req))
}
fn build_request(
&self,
req: &types::PaymentsAuthorizeRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
Ok(Some(
services::RequestBuilder::new()
.method(services::Method::Post)
.url(&types::PaymentsAuthorizeType::get_url(
self, req, connectors,
)?)
.headers(types::PaymentsAuthorizeType::get_headers(
self, req, connectors,
)?)
.body(types::PaymentsAuthorizeType::get_request_body(self, req)?)
.build(),
))
}
fn handle_response(
&self,
data: &types::PaymentsAuthorizeRouterData,
res: Response,
) -> CustomResult<types::PaymentsAuthorizeRouterData, errors::ConnectorError> {
let response: opennode::OpennodePaymentsResponse = res
.response
.parse_struct("Opennode PaymentsAuthorizeResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::PSync, types::PaymentsSyncData, types::PaymentsResponseData>
for Opennode
{
fn get_headers(
&self,
req: &types::PaymentsSyncRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::PaymentsSyncRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
let connector_id = _req
.request
.connector_transaction_id
.get_connector_transaction_id()
.change_context(errors::ConnectorError::MissingConnectorTransactionID)?;
Ok(format!(
"{}/v2/charge/{}",
self.base_url(_connectors),
connector_id
))
}
fn build_request(
&self,
req: &types::PaymentsSyncRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
Ok(Some(
services::RequestBuilder::new()
.method(services::Method::Get)
.url(&types::PaymentsSyncType::get_url(self, req, connectors)?)
.headers(types::PaymentsSyncType::get_headers(self, req, connectors)?)
.build(),
))
}
fn handle_response(
&self,
data: &types::PaymentsSyncRouterData,
res: Response,
) -> CustomResult<types::PaymentsSyncRouterData, errors::ConnectorError> {
let response: opennode::OpennodePaymentsResponse = res
.response
.parse_struct("opennode PaymentsSyncResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::Capture, types::PaymentsCaptureData, types::PaymentsResponseData>
for Opennode
{
fn get_headers(
&self,
req: &types::PaymentsCaptureRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::PaymentsCaptureRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into())
}
fn get_request_body(
&self,
_req: &types::PaymentsCaptureRouterData,
) -> CustomResult<Option<String>, errors::ConnectorError> {
Err(errors::ConnectorError::NotImplemented("get_request_body method".to_string()).into())
}
fn build_request(
&self,
req: &types::PaymentsCaptureRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
Ok(Some(
services::RequestBuilder::new()
.method(services::Method::Post)
.url(&types::PaymentsCaptureType::get_url(self, req, connectors)?)
.headers(types::PaymentsCaptureType::get_headers(
self, req, connectors,
)?)
.build(),
))
}
fn handle_response(
&self,
data: &types::PaymentsCaptureRouterData,
res: Response,
) -> CustomResult<types::PaymentsCaptureRouterData, errors::ConnectorError> {
let response: opennode::OpennodePaymentsResponse = res
.response
.parse_struct("Opennode PaymentsCaptureResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::Void, types::PaymentsCancelData, types::PaymentsResponseData>
for Opennode
{
}
impl ConnectorIntegration<api::Execute, types::RefundsData, types::RefundsResponseData>
for Opennode
{
fn get_headers(
&self,
req: &types::RefundsRouterData<api::Execute>,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::RefundsRouterData<api::Execute>,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into())
}
fn get_request_body(
&self,
req: &types::RefundsRouterData<api::Execute>,
) -> CustomResult<Option<String>, errors::ConnectorError> {
let req_obj = opennode::OpennodeRefundRequest::try_from(req)?;
let opennode_req =
Encode::<opennode::OpennodeRefundRequest>::encode_to_string_of_json(&req_obj)
.change_context(errors::ConnectorError::RequestEncodingFailed)?;
Ok(Some(opennode_req))
}
fn build_request(
&self,
req: &types::RefundsRouterData<api::Execute>,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
let request = services::RequestBuilder::new()
.method(services::Method::Post)
.url(&types::RefundExecuteType::get_url(self, req, connectors)?)
.headers(types::RefundExecuteType::get_headers(
self, req, connectors,
)?)
.body(types::RefundExecuteType::get_request_body(self, req)?)
.build();
Ok(Some(request))
}
fn handle_response(
&self,
data: &types::RefundsRouterData<api::Execute>,
res: Response,
) -> CustomResult<types::RefundsRouterData<api::Execute>, errors::ConnectorError> {
let response: opennode::RefundResponse = res
.response
.parse_struct("opennode RefundResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::RSync, types::RefundsData, types::RefundsResponseData> for Opennode {
fn get_headers(
&self,
req: &types::RefundSyncRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self,
_req: &types::RefundSyncRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into())
}
fn build_request(
&self,
req: &types::RefundSyncRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
Ok(Some(
services::RequestBuilder::new()
.method(services::Method::Get)
.url(&types::RefundSyncType::get_url(self, req, connectors)?)
.headers(types::RefundSyncType::get_headers(self, req, connectors)?)
.build(),
))
}
fn handle_response(
&self,
data: &types::RefundSyncRouterData,
res: Response,
) -> CustomResult<types::RefundSyncRouterData, errors::ConnectorError> {
let response: opennode::RefundResponse = res
.response
.parse_struct("opennode RefundSyncResponse")
.change_context(errors::ConnectorError::ResponseDeserializationFailed)?;
types::RouterData::try_from(types::ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
.change_context(errors::ConnectorError::ResponseHandlingFailed)
}
fn get_error_response(
&self,
res: Response,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
#[async_trait::async_trait]
impl api::IncomingWebhook for Opennode {
fn get_webhook_source_verification_algorithm(
&self,
_request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Box<dyn crypto::VerifySignature + Send>, errors::ConnectorError> {
Ok(Box::new(crypto::HmacSha256))
}
fn get_webhook_source_verification_signature(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let notif = serde_urlencoded::from_bytes::<OpennodeWebhookDetails>(request.body)
.into_report()
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
let base64_signature = notif.hashed_order;
hex::decode(base64_signature)
.into_report()
.change_context(errors::ConnectorError::WebhookSourceVerificationFailed)
}
fn get_webhook_source_verification_message(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
_merchant_id: &str,
_secret: &[u8],
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let message = std::str::from_utf8(request.body)
.into_report()
.change_context(errors::ConnectorError::ParsingFailed)?;
Ok(message.to_string().into_bytes())
}
async fn get_webhook_source_verification_merchant_secret(
&self,
db: &dyn db::StorageInterface,
merchant_id: &str,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let key = format!("whsec_verification_{}_{}", self.id(), merchant_id);
let secret = db
.get_key(&key)
.await
.change_context(errors::ConnectorError::WebhookVerificationSecretNotFound)?;
Ok(secret)
}
fn get_webhook_object_reference_id(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> {
let notif = serde_urlencoded::from_bytes::<OpennodeWebhookDetails>(request.body)
.into_report()
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
Ok(api_models::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::ConnectorTransactionId(notif.id),
))
}
fn get_webhook_event_type(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api::IncomingWebhookEvent, errors::ConnectorError> {
let notif = serde_urlencoded::from_bytes::<OpennodeWebhookDetails>(request.body)
.into_report()
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
match notif.status {
opennode::OpennodePaymentStatus::Paid => {
Ok(api::IncomingWebhookEvent::PaymentIntentSuccess)
}
opennode::OpennodePaymentStatus::Underpaid
| opennode::OpennodePaymentStatus::Expired => {
Ok(api::IncomingWebhookEvent::PaymentActionRequired)
}
opennode::OpennodePaymentStatus::Processing => {
Ok(api::IncomingWebhookEvent::PaymentIntentProcessing)
}
opennode::OpennodePaymentStatus::Refunded => {
Ok(api::IncomingWebhookEvent::RefundSuccess)
}
_ => Ok(api::IncomingWebhookEvent::EventNotSupported),
}
}
fn get_webhook_resource_object(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<serde_json::Value, errors::ConnectorError> {
let notif = serde_urlencoded::from_bytes::<OpennodeWebhookDetails>(request.body)
.into_report()
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
Encode::<OpennodeWebhookDetails>::encode_to_value(&notif.status)
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)
}
}

View File

@ -0,0 +1,256 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{
connector::utils::{PaymentsAuthorizeRequestData, RouterData},
core::errors,
services,
types::{self, api, storage::enums},
};
//TODO: Fill the struct with respective fields
#[derive(Default, Debug, Serialize, Eq, PartialEq)]
pub struct OpennodePaymentsRequest {
amount: i64,
currency: String,
description: String,
auto_settle: bool,
success_url: String,
callback_url: String,
}
impl TryFrom<&types::PaymentsAuthorizeRouterData> for OpennodePaymentsRequest {
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result<Self, Self::Error> {
get_crypto_specific_payment_data(item)
}
}
//TODO: Fill the struct with respective fields
// Auth Struct
pub struct OpennodeAuthType {
pub(super) api_key: String,
}
impl TryFrom<&types::ConnectorAuthType> for OpennodeAuthType {
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(auth_type: &types::ConnectorAuthType) -> Result<Self, Self::Error> {
match auth_type {
types::ConnectorAuthType::HeaderKey { api_key } => Ok(Self {
api_key: api_key.to_string(),
}),
_ => Err(errors::ConnectorError::FailedToObtainAuthType.into()),
}
}
}
// PaymentsResponse
//TODO: Append the remaining status flags
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum OpennodePaymentStatus {
Unpaid,
Paid,
Expired,
#[default]
Processing,
Underpaid,
Refunded,
}
impl From<OpennodePaymentStatus> for enums::AttemptStatus {
fn from(item: OpennodePaymentStatus) -> Self {
match item {
OpennodePaymentStatus::Unpaid => Self::AuthenticationPending,
OpennodePaymentStatus::Paid => Self::Charged,
OpennodePaymentStatus::Expired => Self::Failure,
OpennodePaymentStatus::Underpaid => Self::Unresolved,
_ => Self::Pending,
}
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OpennodePaymentsResponseData {
id: String,
hosted_checkout_url: String,
status: OpennodePaymentStatus,
}
//TODO: Fill the struct with respective fields
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OpennodePaymentsResponse {
data: OpennodePaymentsResponseData,
}
impl<F, T>
TryFrom<types::ResponseRouterData<F, OpennodePaymentsResponse, T, types::PaymentsResponseData>>
for types::RouterData<F, T, types::PaymentsResponseData>
{
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(
item: types::ResponseRouterData<
F,
OpennodePaymentsResponse,
T,
types::PaymentsResponseData,
>,
) -> Result<Self, Self::Error> {
let form_fields = HashMap::new();
let redirection_data = services::RedirectForm {
endpoint: item.response.data.hosted_checkout_url.to_string(),
method: services::Method::Get,
form_fields,
};
let connector_id = types::ResponseId::ConnectorTransactionId(item.response.data.id);
let attempt_status = item.response.data.status;
let response_data = if attempt_status != OpennodePaymentStatus::Underpaid {
Ok(types::PaymentsResponseData::TransactionResponse {
resource_id: connector_id,
redirection_data: Some(redirection_data),
mandate_reference: None,
connector_metadata: None,
})
} else {
Ok(types::PaymentsResponseData::TransactionUnresolvedResponse {
resource_id: connector_id,
reason: Some(api::enums::UnresolvedResponseReason {
code: "UNDERPAID".to_string(),
message:
"Please check the transaction in opennode dashboard and resolve manually"
.to_string(),
}),
})
};
Ok(Self {
status: enums::AttemptStatus::from(attempt_status),
response: response_data,
..item.data
})
}
}
//TODO: Fill the struct with respective fields
// REFUND :
// Type definition for RefundRequest
#[derive(Default, Debug, Serialize)]
pub struct OpennodeRefundRequest {
pub amount: i64,
}
impl<F> TryFrom<&types::RefundsRouterData<F>> for OpennodeRefundRequest {
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(item: &types::RefundsRouterData<F>) -> Result<Self, Self::Error> {
Ok(Self {
amount: item.request.amount,
})
}
}
// Type definition for Refund Response
#[allow(dead_code)]
#[derive(Debug, Serialize, Default, Deserialize, Clone)]
pub enum RefundStatus {
Refunded,
#[default]
Processing,
}
impl From<RefundStatus> for enums::RefundStatus {
fn from(item: RefundStatus) -> Self {
match item {
RefundStatus::Refunded => Self::Success,
RefundStatus::Processing => Self::Pending,
}
}
}
//TODO: Fill the struct with respective fields
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct RefundResponse {
id: String,
status: RefundStatus,
}
impl TryFrom<types::RefundsResponseRouterData<api::Execute, RefundResponse>>
for types::RefundsRouterData<api::Execute>
{
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(
item: types::RefundsResponseRouterData<api::Execute, RefundResponse>,
) -> Result<Self, Self::Error> {
Ok(Self {
response: Ok(types::RefundsResponseData {
connector_refund_id: item.response.id.to_string(),
refund_status: enums::RefundStatus::from(item.response.status),
}),
..item.data
})
}
}
impl TryFrom<types::RefundsResponseRouterData<api::RSync, RefundResponse>>
for types::RefundsRouterData<api::RSync>
{
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(
item: types::RefundsResponseRouterData<api::RSync, RefundResponse>,
) -> Result<Self, Self::Error> {
Ok(Self {
response: Ok(types::RefundsResponseData {
connector_refund_id: item.response.id.to_string(),
refund_status: enums::RefundStatus::from(item.response.status),
}),
..item.data
})
}
}
//TODO: Fill the struct with respective fields
#[derive(Default, Debug, Serialize, Deserialize, PartialEq)]
pub struct OpennodeErrorResponse {
pub status_code: u16,
pub code: String,
pub message: String,
pub reason: Option<String>,
}
fn get_crypto_specific_payment_data(
item: &types::PaymentsAuthorizeRouterData,
) -> Result<OpennodePaymentsRequest, error_stack::Report<errors::ConnectorError>> {
let amount = item.request.amount;
let currency = item.request.currency.to_string();
let description = item.get_description()?;
let auto_settle = true;
let success_url = item.get_return_url()?;
let callback_url = item.request.get_webhook_url()?;
Ok(OpennodePaymentsRequest {
amount,
currency,
description,
auto_settle,
success_url,
callback_url,
})
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OpennodeWebhookDetails {
pub id: String,
pub callback_url: String,
pub success_url: String,
pub status: OpennodePaymentStatus,
pub payment_method: String,
pub missing_amt: String,
pub order_id: String,
pub description: String,
pub price: String,
pub fee: String,
pub auto_settle: String,
pub fiat_value: String,
pub net_fiat_value: String,
pub overpaid_by: String,
pub hashed_order: String,
}

View File

@ -1297,6 +1297,11 @@ impl
}))
}
api::PaymentMethodData::Wallet(_) => Ok(Self::Wallet),
api::PaymentMethodData::Crypto(_) => Err(errors::ConnectorError::NotSupported {
payment_method: format!("{pm_type:?}"),
connector: "Stripe",
payment_experience: api_models::enums::PaymentExperience::RedirectToUrl.to_string(),
})?,
}
}
}

View File

@ -50,6 +50,7 @@ pub trait RouterData {
fn get_billing_country(&self) -> Result<api_models::enums::CountryCode, Error>;
fn get_billing_phone(&self) -> Result<&api::PhoneDetails, Error>;
fn get_description(&self) -> Result<String, Error>;
fn get_return_url(&self) -> Result<String, Error>;
fn get_billing_address(&self) -> Result<&api::AddressDetails, Error>;
fn get_shipping_address(&self) -> Result<&api::AddressDetails, Error>;
fn get_connector_meta(&self) -> Result<pii::SecretSerdeValue, Error>;
@ -89,6 +90,11 @@ impl<Flow, Request, Response> RouterData for types::RouterData<Flow, Request, Re
.clone()
.ok_or_else(missing_field_err("description"))
}
fn get_return_url(&self) -> Result<String, Error> {
self.return_url
.clone()
.ok_or_else(missing_field_err("return_url"))
}
fn get_billing_address(&self) -> Result<&api::AddressDetails, Error> {
self.address
.billing
@ -139,6 +145,7 @@ pub trait PaymentsAuthorizeRequestData {
fn get_browser_info(&self) -> Result<types::BrowserInformation, Error>;
fn get_card(&self) -> Result<api::Card, Error>;
fn get_return_url(&self) -> Result<String, Error>;
fn get_webhook_url(&self) -> Result<String, Error>;
}
impl PaymentsAuthorizeRequestData for types::PaymentsAuthorizeData {
@ -164,6 +171,11 @@ impl PaymentsAuthorizeRequestData for types::PaymentsAuthorizeData {
.clone()
.ok_or_else(missing_field_err("return_url"))
}
fn get_webhook_url(&self) -> Result<String, Error> {
self.router_return_url
.clone()
.ok_or_else(missing_field_err("webhook_url"))
}
}
pub trait PaymentsSyncRequestData {

View File

@ -616,6 +616,7 @@ pub fn should_call_connector<Op: Debug, F: Clone>(
| storage_enums::IntentStatus::Processing
| storage_enums::IntentStatus::Succeeded
| storage_enums::IntentStatus::RequiresCustomerAction
| storage_enums::IntentStatus::RequiresMerchantAction
) && payment_data.force_sync.unwrap_or(false)
}
"PaymentCancel" => matches!(

View File

@ -81,11 +81,13 @@ default_imp_for_complete_authorize!(
connector::Bluesnap,
connector::Braintree,
connector::Checkout,
connector::Coinbase,
connector::Cybersource,
connector::Dlocal,
connector::Fiserv,
connector::Klarna,
connector::Multisafepay,
connector::Opennode,
connector::Payu,
connector::Rapyd,
connector::Shift4,
@ -121,12 +123,14 @@ default_imp_for_connector_redirect_response!(
connector::Bambora,
connector::Bluesnap,
connector::Braintree,
connector::Coinbase,
connector::Cybersource,
connector::Dlocal,
connector::Fiserv,
connector::Globalpay,
connector::Klarna,
connector::Multisafepay,
connector::Opennode,
connector::Payu,
connector::Rapyd,
connector::Shift4,
@ -152,6 +156,7 @@ default_imp_for_connector_request_id!(
connector::Bluesnap,
connector::Braintree,
connector::Checkout,
connector::Coinbase,
connector::Cybersource,
connector::Dlocal,
connector::Fiserv,
@ -160,6 +165,7 @@ default_imp_for_connector_request_id!(
connector::Mollie,
connector::Multisafepay,
connector::Nuvei,
connector::Opennode,
connector::Payu,
connector::Rapyd,
connector::Shift4,

View File

@ -379,6 +379,7 @@ pub fn create_complete_authorize_url(
router_base_url, payment_attempt.payment_id, payment_attempt.merchant_id, connector_name
)
}
fn validate_recurring_mandate(req: api::MandateValidationFields) -> RouterResult<()> {
req.mandate_id.check_value_present("mandate_id")?;
@ -805,6 +806,7 @@ pub async fn make_pm_data<'a, F: Clone, R>(
}
(pm @ Some(api::PaymentMethodData::PayLater(_)), _) => Ok(pm.to_owned()),
(pm @ Some(api::PaymentMethodData::BankRedirect(_)), _) => Ok(pm.to_owned()),
(pm @ Some(api::PaymentMethodData::Crypto(_)), _) => Ok(pm.to_owned()),
(pm_opt @ Some(pm @ api::PaymentMethodData::Wallet(_)), _) => {
let token = vault::Vault::store_payment_method_data_in_locker(
state,

View File

@ -295,8 +295,8 @@ async fn payment_response_update_tracker<F: Clone, T>(
Some(storage::PaymentAttemptUpdate::ErrorUpdate {
connector: None,
status: storage::enums::AttemptStatus::Failure,
error_message: Some(err.message),
error_code: Some(err.code),
error_message: Some(Some(err.message)),
error_code: Some(Some(err.code)),
}),
Some(storage::ConnectorResponseUpdate::ErrorUpdate {
connector_name: Some(router_data.connector.clone()),
@ -324,6 +324,13 @@ async fn payment_response_update_tracker<F: Clone, T>(
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Could not parse the connector response")?;
// incase of success, update error code and error message
let error_status = if router_data.status == enums::AttemptStatus::Charged {
Some(None)
} else {
None
};
if router_data.status == enums::AttemptStatus::Charged {
metrics::SUCCESSFUL_PAYMENT.add(&metrics::CONTEXT, 1, &[]);
}
@ -339,6 +346,8 @@ async fn payment_response_update_tracker<F: Clone, T>(
.clone()
.map(|mandate| mandate.mandate_id),
connector_metadata,
error_code: error_status.clone(),
error_message: error_status,
};
let connector_response_update = storage::ConnectorResponseUpdate::ResponseUpdate {
@ -353,7 +362,27 @@ async fn payment_response_update_tracker<F: Clone, T>(
Some(connector_response_update),
)
}
types::PaymentsResponseData::TransactionUnresolvedResponse {
resource_id,
reason,
} => {
let connector_transaction_id = match resource_id {
types::ResponseId::NoResponseId => None,
types::ResponseId::ConnectorTransactionId(id)
| types::ResponseId::EncodedData(id) => Some(id),
};
(
Some(storage::PaymentAttemptUpdate::UnresolvedResponseUpdate {
status: router_data.status,
connector: None,
connector_transaction_id,
payment_method_id: Some(router_data.payment_method_id),
error_code: Some(reason.clone().map(|cd| cd.code)),
error_message: Some(reason.map(|cd| cd.message)),
}),
None,
)
}
types::PaymentsResponseData::SessionResponse { .. } => (None, None),
types::PaymentsResponseData::SessionTokenResponse { .. } => (None, None),
},

View File

@ -522,10 +522,18 @@ pub async fn webhooks_core<W: api::OutgoingWebhookType>(
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Could not find event type in incoming webhook body")?;
if !matches!(
event_type,
api_models::webhooks::IncomingWebhookEvent::EndpointVerification
) {
let process_webhook_further = utils::lookup_webhook_event(
&*state.store,
connector_name,
&merchant_account.merchant_id,
&event_type,
)
.await;
logger::info!(process_webhook=?process_webhook_further);
logger::info!(event_type=?event_type);
if process_webhook_further {
let source_verified = connector
.verify_webhook_source(
&*state.store,
@ -536,15 +544,6 @@ pub async fn webhooks_core<W: api::OutgoingWebhookType>(
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("There was an issue in incoming webhook source verification")?;
let process_webhook_further = utils::lookup_webhook_event(
&*state.store,
connector_name,
&merchant_account.merchant_id,
&event_type,
)
.await;
if process_webhook_further {
let object_ref_id = connector
.get_webhook_object_reference_id(&request_details)
.change_context(errors::ApiErrorResponse::InternalServerError)
@ -608,7 +607,6 @@ pub async fn webhooks_core<W: api::OutgoingWebhookType>(
.attach_printable("Unsupported Flow Type received in incoming webhooks")?,
}
}
}
let response = connector
.get_webhook_api_response(&request_details)

View File

@ -4,7 +4,13 @@ use crate::{
};
fn default_webhook_config() -> api::MerchantWebhookConfig {
std::collections::HashSet::from([api::IncomingWebhookEvent::PaymentIntentSuccess])
std::collections::HashSet::from([
api::IncomingWebhookEvent::PaymentIntentSuccess,
api::IncomingWebhookEvent::PaymentIntentFailure,
api::IncomingWebhookEvent::PaymentIntentProcessing,
api::IncomingWebhookEvent::PaymentActionRequired,
api::IncomingWebhookEvent::RefundSuccess,
])
}
pub async fn lookup_webhook_event(

View File

@ -45,6 +45,7 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
pub mod headers {
pub const ACCEPT: &str = "Accept";
pub const API_KEY: &str = "API-KEY";
pub const X_CC_API_KEY: &str = "X-CC-Api-Key";
pub const AUTHORIZATION: &str = "Authorization";
pub const CONTENT_TYPE: &str = "Content-Type";
pub const DATE: &str = "Date";
@ -55,6 +56,7 @@ pub mod headers {
pub const X_LOGIN: &str = "X-Login";
pub const X_TRANS_KEY: &str = "X-Trans-Key";
pub const X_VERSION: &str = "X-Version";
pub const X_CC_VERSION: &str = "X-CC-Version";
pub const X_DATE: &str = "X-Date";
}

View File

@ -254,6 +254,11 @@ pub enum PaymentsResponseData {
SessionTokenResponse {
session_token: String,
},
TransactionUnresolvedResponse {
resource_id: ResponseId,
//to add more info on cypto response, like `unresolved` reason(overpaid, underpaid, delayed)
reason: Option<api::enums::UnresolvedResponseReason>,
},
}
#[derive(Debug, Clone, Default)]

View File

@ -192,6 +192,7 @@ impl ConnectorData {
"bluesnap" => Ok(Box::new(&connector::Bluesnap)),
"braintree" => Ok(Box::new(&connector::Braintree)),
"checkout" => Ok(Box::new(&connector::Checkout)),
"coinbase" => Ok(Box::new(&connector::Coinbase)),
"cybersource" => Ok(Box::new(&connector::Cybersource)),
"dlocal" => Ok(Box::new(&connector::Dlocal)),
"fiserv" => Ok(Box::new(&connector::Fiserv)),
@ -199,6 +200,7 @@ impl ConnectorData {
"klarna" => Ok(Box::new(&connector::Klarna)),
"mollie" => Ok(Box::new(&connector::Mollie)),
"nuvei" => Ok(Box::new(&connector::Nuvei)),
"opennode" => Ok(Box::new(&connector::Opennode)),
"payu" => Ok(Box::new(&connector::Payu)),
"rapyd" => Ok(Box::new(&connector::Rapyd)),
"shift4" => Ok(Box::new(&connector::Shift4)),

View File

@ -129,6 +129,7 @@ impl ForeignFrom<storage_enums::AttemptStatus> for storage_enums::IntentStatus {
storage_enums::AttemptStatus::Authorized => Self::RequiresCapture,
storage_enums::AttemptStatus::AuthenticationPending => Self::RequiresCustomerAction,
storage_enums::AttemptStatus::Unresolved => Self::RequiresMerchantAction,
storage_enums::AttemptStatus::PartialCharged
| storage_enums::AttemptStatus::Started
@ -156,6 +157,8 @@ impl ForeignTryFrom<api_enums::IntentStatus> for storage_enums::EventType {
fn foreign_try_from(value: api_enums::IntentStatus) -> Result<Self, Self::Error> {
match value {
api_enums::IntentStatus::Succeeded => Ok(Self::PaymentSucceeded),
api_enums::IntentStatus::Processing => Ok(Self::PaymentProcessing),
api_enums::IntentStatus::RequiresMerchantAction => Ok(Self::ActionRequired),
_ => Err(errors::ValidationError::IncorrectValueProvided {
field_name: "intent_status",
}),

View File

@ -0,0 +1,465 @@
use masking::Secret;
use router::types::{self, api, storage::enums};
use crate::{
connector_auth,
utils::{self, ConnectorActions},
};
#[derive(Clone, Copy)]
struct CoinbaseTest;
impl ConnectorActions for CoinbaseTest {}
impl utils::Connector for CoinbaseTest {
fn get_data(&self) -> types::api::ConnectorData {
use router::connector::Coinbase;
types::api::ConnectorData {
connector: Box::new(&Coinbase),
connector_name: types::Connector::Coinbase,
get_token: types::api::GetToken::Connector,
}
}
fn get_auth_token(&self) -> types::ConnectorAuthType {
types::ConnectorAuthType::from(
connector_auth::ConnectorAuthentication::new()
.coinbase
.expect("Missing connector authentication configuration"),
)
}
fn get_name(&self) -> String {
"coinbase".to_string()
}
}
static CONNECTOR: CoinbaseTest = CoinbaseTest {};
fn get_default_payment_info() -> Option<utils::PaymentInfo> {
None
}
fn payment_method_details() -> Option<types::PaymentsAuthorizeData> {
None
}
// Cards Positive Tests
// Creates a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_only_authorize_payment() {
let response = CONNECTOR
.authorize_payment(payment_method_details(), get_default_payment_info())
.await
.expect("Authorize payment response");
assert_eq!(response.status, enums::AttemptStatus::Authorized);
}
// Captures a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_capture_authorized_payment() {
let response = CONNECTOR
.authorize_and_capture_payment(payment_method_details(), None, get_default_payment_info())
.await
.expect("Capture payment response");
assert_eq!(response.status, enums::AttemptStatus::Charged);
}
// Partially captures a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_partially_capture_authorized_payment() {
let response = CONNECTOR
.authorize_and_capture_payment(
payment_method_details(),
Some(types::PaymentsCaptureData {
amount_to_capture: 50,
..utils::PaymentCaptureType::default().0
}),
get_default_payment_info(),
)
.await
.expect("Capture payment response");
assert_eq!(response.status, enums::AttemptStatus::Charged);
}
// Synchronizes a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_sync_authorized_payment() {
let authorize_response = CONNECTOR
.authorize_payment(payment_method_details(), get_default_payment_info())
.await
.expect("Authorize payment response");
let txn_id = utils::get_connector_transaction_id(authorize_response.response);
let response = CONNECTOR
.psync_retry_till_status_matches(
enums::AttemptStatus::Authorized,
Some(types::PaymentsSyncData {
connector_transaction_id: router::types::ResponseId::ConnectorTransactionId(
txn_id.unwrap(),
),
..Default::default()
}),
get_default_payment_info(),
)
.await
.expect("PSync response");
assert_eq!(response.status, enums::AttemptStatus::Authorized,);
}
// Voids a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_void_authorized_payment() {
let response = CONNECTOR
.authorize_and_void_payment(
payment_method_details(),
Some(types::PaymentsCancelData {
connector_transaction_id: String::from(""),
cancellation_reason: Some("requested_by_customer".to_string()),
..Default::default()
}),
get_default_payment_info(),
)
.await
.expect("Void payment response");
assert_eq!(response.status, enums::AttemptStatus::Voided);
}
// Refunds a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_refund_manually_captured_payment() {
let response = CONNECTOR
.capture_payment_and_refund(
payment_method_details(),
None,
None,
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Partially refunds a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_partially_refund_manually_captured_payment() {
let response = CONNECTOR
.capture_payment_and_refund(
payment_method_details(),
None,
Some(types::RefundsData {
refund_amount: 50,
..utils::PaymentRefundType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Synchronizes a refund using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_sync_manually_captured_refund() {
let refund_response = CONNECTOR
.capture_payment_and_refund(
payment_method_details(),
None,
None,
get_default_payment_info(),
)
.await
.unwrap();
let response = CONNECTOR
.rsync_retry_till_status_matches(
enums::RefundStatus::Success,
refund_response.response.unwrap().connector_refund_id,
None,
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Creates a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_make_payment() {
let authorize_response = CONNECTOR
.make_payment(payment_method_details(), get_default_payment_info())
.await
.unwrap();
assert_eq!(authorize_response.status, enums::AttemptStatus::Charged);
}
// Synchronizes a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_sync_auto_captured_payment() {
let authorize_response = CONNECTOR
.make_payment(payment_method_details(), get_default_payment_info())
.await
.unwrap();
assert_eq!(authorize_response.status, enums::AttemptStatus::Charged);
let txn_id = utils::get_connector_transaction_id(authorize_response.response);
assert_ne!(txn_id, None, "Empty connector transaction id");
let response = CONNECTOR
.psync_retry_till_status_matches(
enums::AttemptStatus::Charged,
Some(types::PaymentsSyncData {
connector_transaction_id: router::types::ResponseId::ConnectorTransactionId(
txn_id.unwrap(),
),
capture_method: Some(enums::CaptureMethod::Automatic),
..Default::default()
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(response.status, enums::AttemptStatus::Charged,);
}
// Refunds a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_refund_auto_captured_payment() {
let response = CONNECTOR
.make_payment_and_refund(payment_method_details(), None, get_default_payment_info())
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Partially refunds a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_partially_refund_succeeded_payment() {
let refund_response = CONNECTOR
.make_payment_and_refund(
payment_method_details(),
Some(types::RefundsData {
refund_amount: 50,
..utils::PaymentRefundType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
refund_response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Creates multiple refunds against a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_refund_succeeded_payment_multiple_times() {
CONNECTOR
.make_payment_and_multiple_refund(
payment_method_details(),
Some(types::RefundsData {
refund_amount: 50,
..utils::PaymentRefundType::default().0
}),
get_default_payment_info(),
)
.await;
}
// Synchronizes a refund using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_sync_refund() {
let refund_response = CONNECTOR
.make_payment_and_refund(payment_method_details(), None, get_default_payment_info())
.await
.unwrap();
let response = CONNECTOR
.rsync_retry_till_status_matches(
enums::RefundStatus::Success,
refund_response.response.unwrap().connector_refund_id,
None,
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Cards Negative scenerios
// Creates a payment with incorrect card number.
#[actix_web::test]
async fn should_fail_payment_for_incorrect_card_number() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_number: Secret::new("1234567891011".to_string()),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Your card number is incorrect.".to_string(),
);
}
// Creates a payment with empty card number.
#[actix_web::test]
async fn should_fail_payment_for_empty_card_number() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_number: Secret::new(String::from("")),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
let x = response.response.unwrap_err();
assert_eq!(
x.message,
"You passed an empty string for 'payment_method_data[card][number]'.",
);
}
// Creates a payment with incorrect CVC.
#[actix_web::test]
async fn should_fail_payment_for_incorrect_cvc() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_cvc: Secret::new("12345".to_string()),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Your card's security code is invalid.".to_string(),
);
}
// Creates a payment with incorrect expiry month.
#[actix_web::test]
async fn should_fail_payment_for_invalid_exp_month() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_exp_month: Secret::new("20".to_string()),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Your card's expiration month is invalid.".to_string(),
);
}
// Creates a payment with incorrect expiry year.
#[actix_web::test]
async fn should_fail_payment_for_incorrect_expiry_year() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_exp_year: Secret::new("2000".to_string()),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Your card's expiration year is invalid.".to_string(),
);
}
// Voids a payment using automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_fail_void_payment_for_auto_capture() {
let authorize_response = CONNECTOR
.make_payment(payment_method_details(), get_default_payment_info())
.await
.unwrap();
assert_eq!(authorize_response.status, enums::AttemptStatus::Charged);
let txn_id = utils::get_connector_transaction_id(authorize_response.response);
assert_ne!(txn_id, None, "Empty connector transaction id");
let void_response = CONNECTOR
.void_payment(txn_id.unwrap(), None, get_default_payment_info())
.await
.unwrap();
assert_eq!(
void_response.response.unwrap_err().message,
"You cannot cancel this PaymentIntent because it has a status of succeeded."
);
}
// Captures a payment using invalid connector payment id.
#[actix_web::test]
async fn should_fail_capture_for_invalid_payment() {
let capture_response = CONNECTOR
.capture_payment("123456789".to_string(), None, get_default_payment_info())
.await
.unwrap();
assert_eq!(
capture_response.response.unwrap_err().message,
String::from("No such payment_intent: '123456789'")
);
}
// Refunds a payment with refund amount higher than payment amount.
#[actix_web::test]
async fn should_fail_for_refund_amount_higher_than_payment_amount() {
let response = CONNECTOR
.make_payment_and_refund(
payment_method_details(),
Some(types::RefundsData {
refund_amount: 150,
..utils::PaymentRefundType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Refund amount (₹1.50) is greater than charge amount (₹1.00)",
);
}
// Connector dependent test cases goes here
// [#478]: add unit tests for non 3DS, wallets & webhooks in connector tests

View File

@ -12,6 +12,7 @@ pub(crate) struct ConnectorAuthentication {
pub bambora: Option<BodyKey>,
pub bluesnap: Option<BodyKey>,
pub checkout: Option<BodyKey>,
pub coinbase: Option<HeaderKey>,
pub cybersource: Option<SignatureKey>,
pub dlocal: Option<SignatureKey>,
pub fiserv: Option<SignatureKey>,
@ -19,6 +20,7 @@ pub(crate) struct ConnectorAuthentication {
pub mollie: Option<HeaderKey>,
pub multisafepay: Option<HeaderKey>,
pub nuvei: Option<SignatureKey>,
pub opennode: Option<HeaderKey>,
pub paypal: Option<BodyKey>,
pub payu: Option<BodyKey>,
pub rapyd: Option<BodyKey>,

View File

@ -7,6 +7,7 @@ mod authorizedotnet;
mod bambora;
mod bluesnap;
mod checkout;
mod coinbase;
mod connector_auth;
mod cybersource;
mod dlocal;
@ -15,6 +16,7 @@ mod globalpay;
mod mollie;
mod multisafepay;
mod nuvei;
mod opennode;
mod paypal;
mod payu;
mod rapyd;

View File

@ -0,0 +1,465 @@
use masking::Secret;
use router::types::{self, api, storage::enums};
use crate::{
connector_auth,
utils::{self, ConnectorActions},
};
#[derive(Clone, Copy)]
struct OpennodeTest;
impl ConnectorActions for OpennodeTest {}
impl utils::Connector for OpennodeTest {
fn get_data(&self) -> types::api::ConnectorData {
use router::connector::Opennode;
types::api::ConnectorData {
connector: Box::new(&Opennode),
connector_name: types::Connector::Opennode,
get_token: types::api::GetToken::Connector,
}
}
fn get_auth_token(&self) -> types::ConnectorAuthType {
types::ConnectorAuthType::from(
connector_auth::ConnectorAuthentication::new()
.opennode
.expect("Missing connector authentication configuration"),
)
}
fn get_name(&self) -> String {
"opennode".to_string()
}
}
static CONNECTOR: OpennodeTest = OpennodeTest {};
fn get_default_payment_info() -> Option<utils::PaymentInfo> {
None
}
fn payment_method_details() -> Option<types::PaymentsAuthorizeData> {
None
}
// Cards Positive Tests
// Creates a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_only_authorize_payment() {
let response = CONNECTOR
.authorize_payment(payment_method_details(), get_default_payment_info())
.await
.expect("Authorize payment response");
assert_eq!(response.status, enums::AttemptStatus::Authorized);
}
// Captures a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_capture_authorized_payment() {
let response = CONNECTOR
.authorize_and_capture_payment(payment_method_details(), None, get_default_payment_info())
.await
.expect("Capture payment response");
assert_eq!(response.status, enums::AttemptStatus::Charged);
}
// Partially captures a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_partially_capture_authorized_payment() {
let response = CONNECTOR
.authorize_and_capture_payment(
payment_method_details(),
Some(types::PaymentsCaptureData {
amount_to_capture: 50,
..utils::PaymentCaptureType::default().0
}),
get_default_payment_info(),
)
.await
.expect("Capture payment response");
assert_eq!(response.status, enums::AttemptStatus::Charged);
}
// Synchronizes a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_sync_authorized_payment() {
let authorize_response = CONNECTOR
.authorize_payment(payment_method_details(), get_default_payment_info())
.await
.expect("Authorize payment response");
let txn_id = utils::get_connector_transaction_id(authorize_response.response);
let response = CONNECTOR
.psync_retry_till_status_matches(
enums::AttemptStatus::Authorized,
Some(types::PaymentsSyncData {
connector_transaction_id: router::types::ResponseId::ConnectorTransactionId(
txn_id.unwrap(),
),
..Default::default()
}),
get_default_payment_info(),
)
.await
.expect("PSync response");
assert_eq!(response.status, enums::AttemptStatus::Authorized,);
}
// Voids a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_void_authorized_payment() {
let response = CONNECTOR
.authorize_and_void_payment(
payment_method_details(),
Some(types::PaymentsCancelData {
connector_transaction_id: String::from(""),
cancellation_reason: Some("requested_by_customer".to_string()),
..Default::default()
}),
get_default_payment_info(),
)
.await
.expect("Void payment response");
assert_eq!(response.status, enums::AttemptStatus::Voided);
}
// Refunds a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_refund_manually_captured_payment() {
let response = CONNECTOR
.capture_payment_and_refund(
payment_method_details(),
None,
None,
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Partially refunds a payment using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_partially_refund_manually_captured_payment() {
let response = CONNECTOR
.capture_payment_and_refund(
payment_method_details(),
None,
Some(types::RefundsData {
refund_amount: 50,
..utils::PaymentRefundType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Synchronizes a refund using the manual capture flow (Non 3DS).
#[actix_web::test]
async fn should_sync_manually_captured_refund() {
let refund_response = CONNECTOR
.capture_payment_and_refund(
payment_method_details(),
None,
None,
get_default_payment_info(),
)
.await
.unwrap();
let response = CONNECTOR
.rsync_retry_till_status_matches(
enums::RefundStatus::Success,
refund_response.response.unwrap().connector_refund_id,
None,
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Creates a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_make_payment() {
let authorize_response = CONNECTOR
.make_payment(payment_method_details(), get_default_payment_info())
.await
.unwrap();
assert_eq!(authorize_response.status, enums::AttemptStatus::Charged);
}
// Synchronizes a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_sync_auto_captured_payment() {
let authorize_response = CONNECTOR
.make_payment(payment_method_details(), get_default_payment_info())
.await
.unwrap();
assert_eq!(authorize_response.status, enums::AttemptStatus::Charged);
let txn_id = utils::get_connector_transaction_id(authorize_response.response);
assert_ne!(txn_id, None, "Empty connector transaction id");
let response = CONNECTOR
.psync_retry_till_status_matches(
enums::AttemptStatus::Charged,
Some(types::PaymentsSyncData {
connector_transaction_id: router::types::ResponseId::ConnectorTransactionId(
txn_id.unwrap(),
),
capture_method: Some(enums::CaptureMethod::Automatic),
..Default::default()
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(response.status, enums::AttemptStatus::Charged,);
}
// Refunds a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_refund_auto_captured_payment() {
let response = CONNECTOR
.make_payment_and_refund(payment_method_details(), None, get_default_payment_info())
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Partially refunds a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_partially_refund_succeeded_payment() {
let refund_response = CONNECTOR
.make_payment_and_refund(
payment_method_details(),
Some(types::RefundsData {
refund_amount: 50,
..utils::PaymentRefundType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
refund_response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Creates multiple refunds against a payment using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_refund_succeeded_payment_multiple_times() {
CONNECTOR
.make_payment_and_multiple_refund(
payment_method_details(),
Some(types::RefundsData {
refund_amount: 50,
..utils::PaymentRefundType::default().0
}),
get_default_payment_info(),
)
.await;
}
// Synchronizes a refund using the automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_sync_refund() {
let refund_response = CONNECTOR
.make_payment_and_refund(payment_method_details(), None, get_default_payment_info())
.await
.unwrap();
let response = CONNECTOR
.rsync_retry_till_status_matches(
enums::RefundStatus::Success,
refund_response.response.unwrap().connector_refund_id,
None,
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap().refund_status,
enums::RefundStatus::Success,
);
}
// Cards Negative scenerios
// Creates a payment with incorrect card number.
#[actix_web::test]
async fn should_fail_payment_for_incorrect_card_number() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_number: Secret::new("1234567891011".to_string()),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Your card number is incorrect.".to_string(),
);
}
// Creates a payment with empty card number.
#[actix_web::test]
async fn should_fail_payment_for_empty_card_number() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_number: Secret::new(String::from("")),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
let x = response.response.unwrap_err();
assert_eq!(
x.message,
"You passed an empty string for 'payment_method_data[card][number]'.",
);
}
// Creates a payment with incorrect CVC.
#[actix_web::test]
async fn should_fail_payment_for_incorrect_cvc() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_cvc: Secret::new("12345".to_string()),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Your card's security code is invalid.".to_string(),
);
}
// Creates a payment with incorrect expiry month.
#[actix_web::test]
async fn should_fail_payment_for_invalid_exp_month() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_exp_month: Secret::new("20".to_string()),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Your card's expiration month is invalid.".to_string(),
);
}
// Creates a payment with incorrect expiry year.
#[actix_web::test]
async fn should_fail_payment_for_incorrect_expiry_year() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_exp_year: Secret::new("2000".to_string()),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Your card's expiration year is invalid.".to_string(),
);
}
// Voids a payment using automatic capture flow (Non 3DS).
#[actix_web::test]
async fn should_fail_void_payment_for_auto_capture() {
let authorize_response = CONNECTOR
.make_payment(payment_method_details(), get_default_payment_info())
.await
.unwrap();
assert_eq!(authorize_response.status, enums::AttemptStatus::Charged);
let txn_id = utils::get_connector_transaction_id(authorize_response.response);
assert_ne!(txn_id, None, "Empty connector transaction id");
let void_response = CONNECTOR
.void_payment(txn_id.unwrap(), None, get_default_payment_info())
.await
.unwrap();
assert_eq!(
void_response.response.unwrap_err().message,
"You cannot cancel this PaymentIntent because it has a status of succeeded."
);
}
// Captures a payment using invalid connector payment id.
#[actix_web::test]
async fn should_fail_capture_for_invalid_payment() {
let capture_response = CONNECTOR
.capture_payment("123456789".to_string(), None, get_default_payment_info())
.await
.unwrap();
assert_eq!(
capture_response.response.unwrap_err().message,
String::from("No such payment_intent: '123456789'")
);
}
// Refunds a payment with refund amount higher than payment amount.
#[actix_web::test]
async fn should_fail_for_refund_amount_higher_than_payment_amount() {
let response = CONNECTOR
.make_payment_and_refund(
payment_method_details(),
Some(types::RefundsData {
refund_amount: 150,
..utils::PaymentRefundType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
assert_eq!(
response.response.unwrap_err().message,
"Refund amount (₹1.50) is greater than charge amount (₹1.00)",
);
}
// Connector dependent test cases goes here
// [#478]: add unit tests for non 3DS, wallets & webhooks in connector tests

View File

@ -73,3 +73,9 @@ key1 = "key1"
[mollie]
api_key = "API Key"
[coinbase]
api_key="API Key"
[opennode]
api_key="API Key"

View File

@ -397,6 +397,7 @@ pub trait ConnectorActions: Connector {
}
Ok(types::PaymentsResponseData::SessionResponse { .. }) => None,
Ok(types::PaymentsResponseData::SessionTokenResponse { .. }) => None,
Ok(types::PaymentsResponseData::TransactionUnresolvedResponse { .. }) => None,
Err(_) => None,
}
}
@ -576,6 +577,7 @@ pub fn get_connector_transaction_id(
}
Ok(types::PaymentsResponseData::SessionResponse { .. }) => None,
Ok(types::PaymentsResponseData::SessionTokenResponse { .. }) => None,
Ok(types::PaymentsResponseData::TransactionUnresolvedResponse { .. }) => None,
Err(_) => None,
}
}

View File

@ -51,6 +51,7 @@ pub enum AttemptStatus {
VoidFailed,
AutoRefunded,
PartialCharged,
Unresolved,
#[default]
Pending,
Failure,
@ -317,6 +318,8 @@ pub enum EventObjectType {
#[strum(serialize_all = "snake_case")]
pub enum EventType {
PaymentSucceeded,
PaymentProcessing,
ActionRequired,
RefundSucceeded,
RefundFailed,
DisputeOpened,
@ -350,6 +353,7 @@ pub enum IntentStatus {
Cancelled,
Processing,
RequiresCustomerAction,
RequiresMerchantAction,
RequiresPaymentMethod,
#[default]
RequiresConfirmation,
@ -450,6 +454,7 @@ pub enum PaymentMethod {
PayLater,
Wallet,
BankRedirect,
Crypto,
}
#[derive(
@ -618,6 +623,7 @@ pub enum PaymentMethodType {
GooglePay,
ApplePay,
Paypal,
CryptoCurrency,
}
#[derive(

View File

@ -135,6 +135,16 @@ pub enum PaymentAttemptUpdate {
payment_method_id: Option<Option<String>>,
mandate_id: Option<String>,
connector_metadata: Option<serde_json::Value>,
error_code: Option<Option<String>>,
error_message: Option<Option<String>>,
},
UnresolvedResponseUpdate {
status: storage_enums::AttemptStatus,
connector: Option<serde_json::Value>,
connector_transaction_id: Option<String>,
payment_method_id: Option<Option<String>>,
error_code: Option<Option<String>>,
error_message: Option<Option<String>>,
},
StatusUpdate {
status: storage_enums::AttemptStatus,
@ -142,8 +152,8 @@ pub enum PaymentAttemptUpdate {
ErrorUpdate {
connector: Option<serde_json::Value>,
status: storage_enums::AttemptStatus,
error_code: Option<String>,
error_message: Option<String>,
error_code: Option<Option<String>>,
error_message: Option<Option<String>>,
},
}
@ -157,14 +167,14 @@ pub struct PaymentAttemptUpdateInternal {
connector: Option<serde_json::Value>,
authentication_type: Option<storage_enums::AuthenticationType>,
payment_method: Option<storage_enums::PaymentMethod>,
error_message: Option<String>,
error_message: Option<Option<String>>,
payment_method_id: Option<Option<String>>,
cancellation_reason: Option<String>,
modified_at: Option<PrimitiveDateTime>,
mandate_id: Option<String>,
browser_info: Option<serde_json::Value>,
payment_token: Option<String>,
error_code: Option<String>,
error_code: Option<Option<String>>,
connector_metadata: Option<serde_json::Value>,
payment_method_data: Option<serde_json::Value>,
payment_method_type: Option<storage_enums::PaymentMethodType>,
@ -184,7 +194,7 @@ impl PaymentAttemptUpdate {
.or(pa_update.connector_transaction_id),
authentication_type: pa_update.authentication_type.or(source.authentication_type),
payment_method: pa_update.payment_method.or(source.payment_method),
error_message: pa_update.error_message.or(source.error_message),
error_message: pa_update.error_message.unwrap_or(source.error_message),
payment_method_id: pa_update
.payment_method_id
.unwrap_or(source.payment_method_id),
@ -274,6 +284,8 @@ impl From<PaymentAttemptUpdate> for PaymentAttemptUpdateInternal {
payment_method_id,
mandate_id,
connector_metadata,
error_code,
error_message,
} => Self {
status: Some(status),
connector,
@ -283,6 +295,8 @@ impl From<PaymentAttemptUpdate> for PaymentAttemptUpdateInternal {
modified_at: Some(common_utils::date_time::now()),
mandate_id,
connector_metadata,
error_code,
error_message,
..Default::default()
},
PaymentAttemptUpdate::ErrorUpdate {
@ -310,6 +324,23 @@ impl From<PaymentAttemptUpdate> for PaymentAttemptUpdateInternal {
connector,
..Default::default()
},
PaymentAttemptUpdate::UnresolvedResponseUpdate {
status,
connector,
connector_transaction_id,
payment_method_id,
error_code,
error_message,
} => Self {
status: Some(status),
connector,
connector_transaction_id,
payment_method_id,
modified_at: Some(common_utils::date_time::now()),
error_code,
error_message,
..Default::default()
},
}
}
}

View File

@ -265,6 +265,7 @@ fn make_client_secret_null_based_on_status(
| storage_enums::IntentStatus::Cancelled => Some(None),
storage_enums::IntentStatus::Processing
| storage_enums::IntentStatus::RequiresCustomerAction
| storage_enums::IntentStatus::RequiresMerchantAction
| storage_enums::IntentStatus::RequiresPaymentMethod
| storage_enums::IntentStatus::RequiresConfirmation
| storage_enums::IntentStatus::RequiresCapture => None,

View File

@ -64,6 +64,7 @@ bambora.base_url = "https://api.na.bambora.com"
bluesnap.base_url = "https://sandbox.bluesnap.com/"
braintree.base_url = "https://api.sandbox.braintreegateway.com/"
checkout.base_url = "https://api.sandbox.checkout.com/"
coinbase.base_url = "https://api.commerce.coinbase.com"
cybersource.base_url = "https://apitest.cybersource.com/"
dlocal.base_url = "https://sandbox.dlocal.com/"
fiserv.base_url = "https://cert.api.fiservapps.com/"
@ -72,6 +73,7 @@ klarna.base_url = "https://api-na.playground.klarna.com/"
mollie.base_url = "https://api.mollie.com/v2/"
multisafepay.base_url = "https://testapi.multisafepay.com/"
nuvei.base_url = "https://ppp-test.nuvei.com/"
opennode.base_url = "https://dev-api.opennode.com"
paypal.base_url = "https://www.sandbox.paypal.com/"
payu.base_url = "https://secure.snd.payu.com/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
@ -93,6 +95,7 @@ cards = [
"bluesnap",
"braintree",
"checkout",
"coinbase",
"cybersource",
"dlocal",
"fiserv",
@ -100,6 +103,7 @@ cards = [
"mollie",
"multisafepay",
"nuvei",
"opennode",
"paypal",
"payu",
"shift4",

View File

@ -0,0 +1,20 @@
DELETE FROM pg_enum
WHERE enumlabel = 'unresolved'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'AttemptStatus'
);
DELETE FROM pg_enum
WHERE enumlabel = 'requires_merchant_action'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'IntentStatus'
);
DELETE FROM pg_enum
WHERE enumlabel = 'action_required'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'EventType'
);
DELETE FROM pg_enum
WHERE enumlabel = 'payment_processing'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'EventType'
);

View File

@ -0,0 +1,4 @@
ALTER TYPE "AttemptStatus" ADD VALUE IF NOT EXISTS 'unresolved';
ALTER TYPE "IntentStatus" ADD VALUE IF NOT EXISTS 'requires_merchant_action' after 'requires_customer_action';
ALTER TYPE "EventType" ADD VALUE IF NOT EXISTS 'action_required';
ALTER TYPE "EventType" ADD VALUE IF NOT EXISTS 'payment_processing';

View File

@ -4,7 +4,7 @@ function find_prev_connector() {
git checkout $self
cp $self $self.tmp
# add new connector to existing list and sort it
connectors=(aci adyen airwallex applepay authorizedotnet bambora bluesnap braintree checkout cybersource dlocal fiserv globalpay klarna mollie multisafepay nuvei payu rapyd shift4 stripe trustpay worldline worldpay "$1")
connectors=(aci adyen airwallex applepay authorizedotnet bambora bluesnap braintree checkout coinbase cybersource dlocal fiserv globalpay klarna mollie multisafepay nuvei opennode payu rapyd shift4 stripe trustpay worldline worldpay "$1")
IFS=$'\n' sorted=($(sort <<<"${connectors[*]}")); unset IFS
res=`echo ${sorted[@]}`
sed -i'' -e "s/^ connectors=.*/ connectors=($res \"\$1\")/" $self.tmp