feat(router): add payments authentication api flow (#3996)

Co-authored-by: hrithikesh026 <hrithikesh.vm@juspay.in>
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
Co-authored-by: Hrithikesh <61539176+hrithikesh026@users.noreply.github.com>
This commit is contained in:
Sai Harsha Vardhan
2024-03-09 12:46:09 +05:30
committed by GitHub
parent c27a235edc
commit 41556baed9
7 changed files with 475 additions and 1 deletions

View File

@ -78,6 +78,7 @@ Never share your secret api keys. Keep them guarded and secure.
routes::payments::payments_list,
routes::payments::payments_incremental_authorization,
routes::payment_link::payment_link_retrieve,
routes::payments::payments_external_authentication,
// Routes for refunds
routes::refunds::refunds_create,

View File

@ -450,3 +450,23 @@ pub fn payments_list() {}
security(("api_key" = []))
)]
pub fn payments_incremental_authorization() {}
/// Payments - External 3DS Authentication
///
/// External 3DS Authentication is performed and returns the AuthenticationResponse
#[utoipa::path(
post,
path = "/payments/{payment_id}/3ds/authentication",
request_body=PaymentsExternalAuthenticationRequest,
params(
("payment_id" = String, Path, description = "The identifier for payment")
),
responses(
(status = 200, description = "Authentication created", body = PaymentsExternalAuthenticationResponse),
(status = 400, description = "Missing mandatory fields")
),
tag = "Payments",
operation_id = "Initiate external authentication for a Payment",
security(("publishable_key" = []))
)]
pub fn payments_external_authentication() {}

View File

@ -49,6 +49,7 @@ use crate::core::fraud_check as frm_core;
use crate::{
configs::settings::{ApplePayPreDecryptFlow, PaymentMethodTypeTokenFilter},
core::{
authentication as authentication_core,
errors::{self, CustomResult, RouterResponse, RouterResult},
payment_methods::PaymentMethodRetrieve,
utils,
@ -59,10 +60,11 @@ use crate::{
services::{self, api::Authenticate},
types::{
self as router_types,
api::{self, ConnectorCallType},
api::{self, authentication, ConnectorCallType},
domain,
storage::{self, enums as storage_enums, payment_attempt::PaymentAttemptExt},
transformers::{ForeignInto, ForeignTryInto},
BrowserInformation,
},
utils::{
add_apple_pay_flow_metrics, add_connector_http_status_code_metrics, Encode, OptionExt,
@ -3031,3 +3033,201 @@ where
Ok(ConnectorCallType::Retryable(connector_data))
}
#[instrument(skip_all)]
pub async fn payment_external_authentication(
state: AppState,
merchant_account: domain::MerchantAccount,
key_store: domain::MerchantKeyStore,
req: api_models::payments::PaymentsExternalAuthenticationRequest,
) -> RouterResponse<api_models::payments::PaymentsExternalAuthenticationResponse> {
let db = &*state.store;
let merchant_id = &merchant_account.merchant_id;
let storage_scheme = merchant_account.storage_scheme;
let payment_id = req.payment_id;
let payment_intent = db
.find_payment_intent_by_payment_id_merchant_id(&payment_id, merchant_id, storage_scheme)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
let attempt_id = payment_intent.active_attempt.get_id().clone();
let payment_attempt = db
.find_payment_attempt_by_payment_id_merchant_id_attempt_id(
&payment_intent.payment_id,
merchant_id,
&attempt_id.clone(),
storage_scheme,
)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
if payment_attempt.external_three_ds_authentication_attempted != Some(true) {
Err(errors::ApiErrorResponse::PreconditionFailed {
message:
"You cannot authenticate this payment because payment_attempt.external_three_ds_authentication_attempted is false".to_owned(),
})?
}
helpers::validate_payment_status_against_allowed_statuses(
&payment_intent.status,
&[storage_enums::IntentStatus::RequiresCustomerAction],
"authenticate",
)?;
let optional_customer = match &payment_intent.customer_id {
Some(customer_id) => Some(
state
.store
.find_customer_by_customer_id_merchant_id(
customer_id,
&merchant_account.merchant_id,
&key_store,
)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable_lazy(|| {
format!("error while finding customer with customer_id {customer_id}")
})?,
),
None => None,
};
let profile_id = payment_intent
.profile_id
.as_ref()
.get_required_value("profile_id")
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("'profile_id' not set in payment intent")?;
let currency = payment_attempt.currency.get_required_value("currency")?;
let amount = payment_attempt.get_total_amount().into();
let shipping_address = helpers::create_or_find_address_for_payment_by_request(
db,
None,
payment_intent.shipping_address_id.as_deref(),
merchant_id,
payment_intent.customer_id.as_ref(),
&key_store,
&payment_intent.payment_id,
storage_scheme,
)
.await?;
let billing_address = helpers::create_or_find_address_for_payment_by_request(
db,
None,
payment_intent.billing_address_id.as_deref(),
merchant_id,
payment_intent.customer_id.as_ref(),
&key_store,
&payment_intent.payment_id,
storage_scheme,
)
.await?;
let authentication_connector = payment_attempt
.authentication_connector
.clone()
.ok_or(errors::ApiErrorResponse::InternalServerError)
.into_report()
.attach_printable("authentication_connector not found in payment_attempt")?;
let merchant_connector_account = helpers::get_merchant_connector_account(
&state,
merchant_id,
None,
&key_store,
profile_id,
authentication_connector.as_str(),
None,
)
.await?;
let authentication = db
.find_authentication_by_merchant_id_authentication_id(
merchant_id.to_string(),
payment_attempt
.authentication_id
.clone()
.ok_or(errors::ApiErrorResponse::InternalServerError)
.into_report()
.attach_printable("missing authentication_id in payment_attempt")?,
)
.await
.to_not_found_response(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Error while fetching authentication record")?;
let authentication_data: AuthenticationData = authentication
.authentication_data
.clone()
.parse_value("authentication data")
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Error while parsing authentication_data")?;
let payment_method_details = helpers::get_payment_method_details_from_payment_token(
&state,
&payment_attempt,
&payment_intent,
&key_store,
)
.await?
.ok_or(errors::ApiErrorResponse::InternalServerError)
.into_report()
.attach_printable("missing payment_method_details")?;
let browser_info: Option<BrowserInformation> = payment_attempt
.browser_info
.clone()
.map(|browser_information| browser_information.parse_value("BrowserInformation"))
.transpose()
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "browser_info",
})?;
let payment_connector_name = payment_attempt
.connector
.as_ref()
.ok_or(errors::ApiErrorResponse::InternalServerError)
.into_report()
.attach_printable("missing connector in payment_attempt")?;
let return_url = Some(helpers::create_authorize_url(
&state.conf.server.base_url,
&payment_attempt.clone(),
payment_connector_name,
));
let business_profile = state
.store
.find_business_profile_by_profile_id(profile_id)
.await
.change_context(errors::ApiErrorResponse::BusinessProfileNotFound {
id: profile_id.to_string(),
})?;
let authentication_response = authentication_core::perform_authentication(
&state,
authentication_connector,
payment_method_details.0,
payment_method_details.1,
billing_address
.as_ref()
.map(|address| address.into())
.ok_or(errors::ApiErrorResponse::MissingRequiredField {
field_name: "billing_address",
})?,
shipping_address.as_ref().map(|address| address.into()),
browser_info,
business_profile,
merchant_connector_account,
amount,
Some(currency),
authentication::MessageCategory::Payment,
req.device_channel,
(authentication_data, authentication),
return_url,
req.sdk_information,
req.threeds_method_comp_ind,
optional_customer.and_then(|customer| customer.email.map(common_utils::pii::Email::from)),
)
.await?;
Ok(services::ApplicationResponse::Json(
api_models::payments::PaymentsExternalAuthenticationResponse {
transaction_status: authentication_response.trans_status,
acs_url: authentication_response
.acs_url
.as_ref()
.map(ToString::to_string),
challenge_request: authentication_response.challenge_request,
acs_reference_number: authentication_response.acs_reference_number,
acs_trans_id: authentication_response.acs_trans_id,
three_dsserver_trans_id: authentication_response.three_dsserver_trans_id,
acs_signed_content: authentication_response.acs_signed_content,
},
))
}

View File

@ -36,6 +36,7 @@ use crate::{
errors::{self, CustomResult, RouterResult, StorageErrorExt},
payment_methods::{cards, vault, PaymentMethodRetrieve},
payments,
pm_auth::retrieve_payment_method_from_auth_service,
},
db::StorageInterface,
routes::{metrics, payment_methods, AppState},
@ -895,6 +896,17 @@ pub fn create_redirect_url(
) + creds_identifier_path.as_ref()
}
pub fn create_authorize_url(
router_base_url: &String,
payment_attempt: &PaymentAttempt,
connector_name: &String,
) -> String {
format!(
"{}/payments/{}/{}/authorize/{}",
router_base_url, payment_attempt.payment_id, payment_attempt.merchant_id, connector_name
)
}
pub fn create_webhook_url(
router_base_url: &String,
merchant_id: &String,
@ -3860,6 +3872,125 @@ pub fn validate_session_expiry(session_expiry: u32) -> Result<(), errors::ApiErr
}
}
pub async fn get_payment_method_details_from_payment_token(
state: &AppState,
payment_attempt: &PaymentAttempt,
payment_intent: &PaymentIntent,
key_store: &domain::MerchantKeyStore,
) -> RouterResult<Option<(api::PaymentMethodData, enums::PaymentMethod)>> {
let hyperswitch_token = if let Some(token) = payment_attempt.payment_token.clone() {
let redis_conn = state
.store
.get_redis_conn()
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to get redis connection")?;
let key = format!(
"pm_token_{}_{}_hyperswitch",
token,
payment_attempt
.payment_method
.to_owned()
.get_required_value("payment_method")?,
);
let token_data_string = redis_conn
.get_key::<Option<String>>(&key)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to fetch the token from redis")?
.ok_or(error_stack::Report::new(
errors::ApiErrorResponse::UnprocessableEntity {
message: "Token is invalid or expired".to_owned(),
},
))?;
let token_data_result = token_data_string
.clone()
.parse_struct("PaymentTokenData")
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("failed to deserialize hyperswitch token data");
let token_data = match token_data_result {
Ok(data) => data,
Err(e) => {
// The purpose of this logic is backwards compatibility to support tokens
// in redis that might be following the old format.
if token_data_string.starts_with('{') {
return Err(e);
} else {
storage::PaymentTokenData::temporary_generic(token_data_string)
}
}
};
Some(token_data)
} else {
None
};
let token = hyperswitch_token
.ok_or(errors::ApiErrorResponse::InternalServerError)
.into_report()
.attach_printable("missing hyperswitch_token")?;
match token {
storage::PaymentTokenData::TemporaryGeneric(generic_token) => {
retrieve_payment_method_with_temporary_token(
state,
&generic_token.token,
payment_intent,
key_store,
None,
)
.await
}
storage::PaymentTokenData::Temporary(generic_token) => {
retrieve_payment_method_with_temporary_token(
state,
&generic_token.token,
payment_intent,
key_store,
None,
)
.await
}
storage::PaymentTokenData::Permanent(card_token) => retrieve_card_with_permanent_token(
state,
&card_token.token,
card_token
.payment_method_id
.as_ref()
.unwrap_or(&card_token.token),
payment_intent,
None,
)
.await
.map(|card| Some((card, enums::PaymentMethod::Card))),
storage::PaymentTokenData::PermanentCard(card_token) => retrieve_card_with_permanent_token(
state,
&card_token.token,
card_token
.payment_method_id
.as_ref()
.unwrap_or(&card_token.token),
payment_intent,
None,
)
.await
.map(|card| Some((card, enums::PaymentMethod::Card))),
storage::PaymentTokenData::AuthBankDebit(auth_token) => {
retrieve_payment_method_from_auth_service(
state,
key_store,
&auth_token,
payment_intent,
&None,
)
.await
}
storage::PaymentTokenData::WalletToken(_) => Ok(None),
}
}
// This function validates the mandate_data with its setup_future_usage
pub fn validate_mandate_data_and_future_usage(
setup_future_usages: Option<api_enums::FutureUsage>,

View File

@ -364,6 +364,9 @@ impl Payments {
)
.service(
web::resource("/{payment_id}/incremental_authorization").route(web::post().to(payments_incremental_authorization)),
)
.service(
web::resource("/{payment_id}/3ds/authentication").route(web::post().to(payments_external_authentication)),
);
}
route

View File

@ -1208,6 +1208,58 @@ pub async fn payments_incremental_authorization(
.await
}
/// Payments - External 3DS Authentication
///
/// External 3DS Authentication is performed and returns the AuthenticationResponse
#[utoipa::path(
post,
path = "/payments/{payment_id}/3ds/authentication",
request_body=PaymentsExternalAuthenticationRequest,
params(
("payment_id" = String, Path, description = "The identifier for payment")
),
responses(
(status = 200, description = "Authentication created"),
(status = 400, description = "Missing mandatory fields")
),
tag = "Payments",
operation_id = "Initiate external authentication for a Payment",
security(("api_key" = []))
)]
#[instrument(skip_all, fields(flow = ?Flow::PaymentsExternalAuthentication, payment_id))]
pub async fn payments_external_authentication(
state: web::Data<app::AppState>,
req: actix_web::HttpRequest,
json_payload: web::Json<payment_types::PaymentsExternalAuthenticationRequest>,
path: web::Path<String>,
) -> impl Responder {
let flow = Flow::PaymentsExternalAuthentication;
let mut payload = json_payload.into_inner();
let payment_id = path.into_inner();
tracing::Span::current().record("payment_id", &payment_id);
payload.payment_id = payment_id;
let locking_action = payload.get_locking_input(flow.clone());
Box::pin(api::server_wrap(
flow,
state,
&req,
payload,
|state, auth, req| {
payments::payment_external_authentication(
state,
auth.merchant_account,
auth.key_store,
req,
)
},
&auth::PublishableKeyAuth,
locking_action,
))
.await
}
pub fn get_or_generate_payment_id(
payload: &mut payment_types::PaymentsRequest,
) -> errors::RouterResult<()> {
@ -1409,3 +1461,19 @@ impl GetLockingInput for payment_types::PaymentsIncrementalAuthorizationRequest
}
}
}
impl GetLockingInput for payment_types::PaymentsExternalAuthenticationRequest {
fn get_locking_input<F>(&self, flow: F) -> api_locking::LockAction
where
F: types::FlowMetric,
lock_utils::ApiIdentifier: From<F>,
{
api_locking::LockAction::Hold {
input: api_locking::LockingInput {
unique_locking_key: self.payment_id.to_owned(),
api_identifier: lock_utils::ApiIdentifier::from(flow),
override_lock_retries: None,
},
}
}
}