mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-29 17:19:15 +08:00
refactor(mandate): allow merchant to pass the mandate details and customer acceptance separately (#1188)
This commit is contained in:
@ -429,6 +429,9 @@ pub struct PaymentMethodListResponse {
|
||||
]
|
||||
))]
|
||||
pub payment_methods: Vec<ResponsePaymentMethodsEnabled>,
|
||||
/// Value indicating if the current payment is a mandate payment
|
||||
#[schema(example = "new_mandate_txn")]
|
||||
pub mandate_payment: Option<payments::MandateTxnType>,
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Hash, Debug, serde::Deserialize, ToSchema)]
|
||||
|
||||
@ -300,7 +300,8 @@ impl From<PaymentsRequest> for VerifyRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MandateTxnType {
|
||||
NewMandateTxn,
|
||||
RecurringMandateTxn,
|
||||
@ -333,13 +334,15 @@ impl MandateIds {
|
||||
}
|
||||
}
|
||||
|
||||
// The fields on this struct are optional, as we want to allow the merchant to provide partial
|
||||
// information about creating mandates
|
||||
#[derive(Default, Eq, PartialEq, Debug, serde::Deserialize, serde::Serialize, Clone, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MandateData {
|
||||
/// A concent from the customer to store the payment method
|
||||
pub customer_acceptance: CustomerAcceptance,
|
||||
pub customer_acceptance: Option<CustomerAcceptance>,
|
||||
/// A way to select the type of mandate used
|
||||
pub mandate_type: MandateType,
|
||||
pub mandate_type: Option<MandateType>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@ -561,27 +561,27 @@ impl ForeignTryFrom<(Option<MandateOption>, Option<String>)> for Option<payments
|
||||
let mandate_data = mandate_options.map(|mandate| payments::MandateData {
|
||||
mandate_type: match mandate.mandate_type {
|
||||
Some(item) => match item {
|
||||
StripeMandateType::SingleUse => {
|
||||
payments::MandateType::SingleUse(payments::MandateAmountData {
|
||||
StripeMandateType::SingleUse => Some(payments::MandateType::SingleUse(
|
||||
payments::MandateAmountData {
|
||||
amount: mandate.amount.unwrap_or_default(),
|
||||
currency,
|
||||
start_date: mandate.start_date,
|
||||
end_date: mandate.end_date,
|
||||
metadata: None,
|
||||
})
|
||||
}
|
||||
StripeMandateType::MultiUse => payments::MandateType::MultiUse(None),
|
||||
},
|
||||
None => api_models::payments::MandateType::MultiUse(None),
|
||||
)),
|
||||
StripeMandateType::MultiUse => Some(payments::MandateType::MultiUse(None)),
|
||||
},
|
||||
customer_acceptance: payments::CustomerAcceptance {
|
||||
None => Some(api_models::payments::MandateType::MultiUse(None)),
|
||||
},
|
||||
customer_acceptance: Some(payments::CustomerAcceptance {
|
||||
acceptance_type: payments::AcceptanceType::Online,
|
||||
accepted_at: mandate.accepted_at,
|
||||
online: Some(payments::OnlineMandate {
|
||||
ip_address: mandate.ip_address.unwrap_or_default(),
|
||||
user_agent: mandate.user_agent.unwrap_or_default(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
Ok(mandate_data)
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ use crate::{
|
||||
core::errors,
|
||||
services,
|
||||
types::{self, api, storage::enums, transformers::ForeignTryFrom},
|
||||
utils::OptionExt,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Default, Deserialize)]
|
||||
@ -712,7 +713,12 @@ fn get_card_info<F>(
|
||||
let (is_rebilling, additional_params, user_token_id) =
|
||||
match item.request.setup_mandate_details.clone() {
|
||||
Some(mandate_data) => {
|
||||
let details = match mandate_data.mandate_type {
|
||||
let details = match mandate_data
|
||||
.mandate_type
|
||||
.get_required_value("mandate_type")
|
||||
.change_context(errors::ConnectorError::MissingRequiredField {
|
||||
field_name: "mandate_type",
|
||||
})? {
|
||||
payments::MandateType::SingleUse(details) => details,
|
||||
payments::MandateType::MultiUse(details) => {
|
||||
details.ok_or(errors::ConnectorError::MissingRequiredField {
|
||||
|
||||
@ -1096,6 +1096,7 @@ impl TryFrom<&types::PaymentsAuthorizeRouterData> for PaymentIntentRequest {
|
||||
.and_then(|mandate_details| {
|
||||
mandate_details
|
||||
.customer_acceptance
|
||||
.as_ref()?
|
||||
.online
|
||||
.as_ref()
|
||||
.map(|online_details| StripeMandateRequest {
|
||||
|
||||
@ -210,7 +210,7 @@ where
|
||||
pm_id.get_required_value("payment_method_id")?,
|
||||
mandate_ids,
|
||||
network_txn_id,
|
||||
) {
|
||||
)? {
|
||||
let connector = new_mandate_data.connector.clone();
|
||||
logger::debug!("{:?}", new_mandate_data);
|
||||
resp.request
|
||||
|
||||
@ -1093,6 +1093,10 @@ pub async fn list_payment_methods(
|
||||
api::PaymentMethodListResponse {
|
||||
redirect_url: merchant_account.return_url,
|
||||
payment_methods: payment_method_responses,
|
||||
mandate_payment: payment_attempt
|
||||
.and_then(|inner| inner.mandate_details)
|
||||
// The data stored in the payment attempt only corresponds to a setup mandate.
|
||||
.map(|_mandate_data| api_models::payments::MandateTxnType::NewMandateTxn),
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
@ -296,14 +296,6 @@ pub fn validate_mandate(
|
||||
}
|
||||
|
||||
fn validate_new_mandate_request(req: api::MandateValidationFields) -> RouterResult<()> {
|
||||
let confirm = req.confirm.get_required_value("confirm")?;
|
||||
|
||||
if !confirm {
|
||||
Err(report!(errors::ApiErrorResponse::PreconditionFailed {
|
||||
message: "`confirm` must be `true` for mandates".into()
|
||||
}))?
|
||||
}
|
||||
|
||||
let _ = req.customer_id.as_ref().get_required_value("customer_id")?;
|
||||
|
||||
let mandate_data = req
|
||||
@ -321,8 +313,11 @@ fn validate_new_mandate_request(req: api::MandateValidationFields) -> RouterResu
|
||||
}))?
|
||||
};
|
||||
|
||||
if (mandate_data.customer_acceptance.acceptance_type == api::AcceptanceType::Online)
|
||||
&& mandate_data.customer_acceptance.online.is_none()
|
||||
// Only use this validation if the customer_acceptance is present
|
||||
if mandate_data
|
||||
.customer_acceptance
|
||||
.map(|inner| inner.acceptance_type == api::AcceptanceType::Online && inner.online.is_none())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Err(report!(errors::ApiErrorResponse::PreconditionFailed {
|
||||
message: "`mandate_data.customer_acceptance.online` is required when \
|
||||
@ -332,8 +327,9 @@ fn validate_new_mandate_request(req: api::MandateValidationFields) -> RouterResu
|
||||
}
|
||||
|
||||
let mandate_details = match mandate_data.mandate_type {
|
||||
api_models::payments::MandateType::SingleUse(details) => Some(details),
|
||||
api_models::payments::MandateType::MultiUse(details) => details,
|
||||
Some(api_models::payments::MandateType::SingleUse(details)) => Some(details),
|
||||
Some(api_models::payments::MandateType::MultiUse(details)) => details,
|
||||
None => None,
|
||||
};
|
||||
mandate_details.and_then(|md| md.start_date.zip(md.end_date)).map(|(start_date, end_date)|
|
||||
utils::when (start_date >= end_date, || {
|
||||
@ -1167,7 +1163,7 @@ pub fn generate_mandate(
|
||||
payment_method_id: String,
|
||||
connector_mandate_id: Option<pii::SecretSerdeValue>,
|
||||
network_txn_id: Option<String>,
|
||||
) -> Option<storage::MandateNew> {
|
||||
) -> CustomResult<Option<storage::MandateNew>, errors::ApiErrorResponse> {
|
||||
match (setup_mandate_details, customer) {
|
||||
(Some(data), Some(cus)) => {
|
||||
let mandate_id = utils::generate_id(consts::ID_LENGTH, "man");
|
||||
@ -1175,6 +1171,9 @@ pub fn generate_mandate(
|
||||
// The construction of the mandate new must be visible
|
||||
let mut new_mandate = storage::MandateNew::default();
|
||||
|
||||
let customer_acceptance = data
|
||||
.customer_acceptance
|
||||
.get_required_value("customer_acceptance")?;
|
||||
new_mandate
|
||||
.set_mandate_id(mandate_id)
|
||||
.set_customer_id(cus.customer_id.clone())
|
||||
@ -1185,14 +1184,15 @@ pub fn generate_mandate(
|
||||
.set_connector_mandate_ids(connector_mandate_id)
|
||||
.set_network_transaction_id(network_txn_id)
|
||||
.set_customer_ip_address(
|
||||
data.customer_acceptance
|
||||
customer_acceptance
|
||||
.get_ip_address()
|
||||
.map(masking::Secret::new),
|
||||
)
|
||||
.set_customer_user_agent(data.customer_acceptance.get_user_agent())
|
||||
.set_customer_accepted_at(Some(data.customer_acceptance.get_accepted_at()));
|
||||
.set_customer_user_agent(customer_acceptance.get_user_agent())
|
||||
.set_customer_accepted_at(Some(customer_acceptance.get_accepted_at()));
|
||||
|
||||
Some(match data.mandate_type {
|
||||
Ok(Some(
|
||||
match data.mandate_type.get_required_value("mandate_type")? {
|
||||
api::MandateType::SingleUse(data) => new_mandate
|
||||
.set_mandate_amount(Some(data.amount))
|
||||
.set_mandate_currency(Some(data.currency.foreign_into()))
|
||||
@ -1210,9 +1210,10 @@ pub fn generate_mandate(
|
||||
}
|
||||
.set_mandate_type(storage_enums::MandateType::MultiUse)
|
||||
.to_owned(),
|
||||
})
|
||||
},
|
||||
))
|
||||
}
|
||||
(_, _) => None,
|
||||
(_, _) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1821,6 +1822,7 @@ impl AttemptType {
|
||||
business_sub_label: old_payment_attempt.business_sub_label,
|
||||
// If the algorithm is entered in Create call from server side, it needs to be populated here, however it could be overridden from the request.
|
||||
straight_through_algorithm: old_payment_attempt.straight_through_algorithm,
|
||||
mandate_details: old_payment_attempt.mandate_details,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -167,6 +167,16 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Co
|
||||
payment_intent.billing_address_id = billing_address.clone().map(|i| i.address_id);
|
||||
payment_intent.return_url = request.return_url.as_ref().map(|a| a.to_string());
|
||||
|
||||
// The operation merges mandate data from both request and payment_attempt
|
||||
let setup_mandate = setup_mandate.map(|mandate_data| api_models::payments::MandateData {
|
||||
customer_acceptance: mandate_data.customer_acceptance,
|
||||
mandate_type: payment_attempt
|
||||
.mandate_details
|
||||
.clone()
|
||||
.map(ForeignInto::foreign_into)
|
||||
.or(mandate_data.mandate_type),
|
||||
});
|
||||
|
||||
Ok((
|
||||
Box::new(self),
|
||||
PaymentData {
|
||||
|
||||
@ -201,6 +201,16 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
|
||||
.await
|
||||
.transpose()?;
|
||||
|
||||
// The operation merges mandate data from both request and payment_attempt
|
||||
let setup_mandate = setup_mandate.map(|mandate_data| api_models::payments::MandateData {
|
||||
customer_acceptance: mandate_data.customer_acceptance,
|
||||
mandate_type: payment_attempt
|
||||
.mandate_details
|
||||
.clone()
|
||||
.map(ForeignInto::foreign_into)
|
||||
.or(mandate_data.mandate_type),
|
||||
});
|
||||
|
||||
Ok((
|
||||
Box::new(self),
|
||||
PaymentData {
|
||||
|
||||
@ -213,6 +213,15 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
|
||||
.await
|
||||
.transpose()?;
|
||||
|
||||
// The operation merges mandate data from both request and payment_attempt
|
||||
let setup_mandate = setup_mandate.map(|mandate_data| api_models::payments::MandateData {
|
||||
customer_acceptance: mandate_data.customer_acceptance,
|
||||
mandate_type: mandate_data.mandate_type.or(payment_attempt
|
||||
.mandate_details
|
||||
.clone()
|
||||
.map(ForeignInto::foreign_into)),
|
||||
});
|
||||
|
||||
Ok((
|
||||
operation,
|
||||
PaymentData {
|
||||
@ -498,6 +507,10 @@ impl PaymentCreate {
|
||||
payment_experience: request.payment_experience.map(ForeignInto::foreign_into),
|
||||
payment_method_type: request.payment_method_type.map(ForeignInto::foreign_into),
|
||||
payment_method_data: additional_pm_data,
|
||||
mandate_details: request
|
||||
.mandate_data
|
||||
.as_ref()
|
||||
.and_then(|inner| inner.mandate_type.clone().map(ForeignInto::foreign_into)),
|
||||
..storage::PaymentAttemptNew::default()
|
||||
})
|
||||
}
|
||||
|
||||
@ -267,6 +267,15 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
|
||||
.await
|
||||
.transpose()?;
|
||||
|
||||
// The operation merges mandate data from both request and payment_attempt
|
||||
let setup_mandate = setup_mandate.map(|mandate_data| api_models::payments::MandateData {
|
||||
customer_acceptance: mandate_data.customer_acceptance,
|
||||
mandate_type: mandate_data.mandate_type.or(payment_attempt
|
||||
.mandate_details
|
||||
.clone()
|
||||
.map(ForeignInto::foreign_into)),
|
||||
});
|
||||
|
||||
Ok((
|
||||
next_operation,
|
||||
PaymentData {
|
||||
|
||||
@ -265,6 +265,7 @@ impl PaymentAttemptInterface for MockDb {
|
||||
payment_method_data: payment_attempt.payment_method_data,
|
||||
business_sub_label: payment_attempt.business_sub_label,
|
||||
straight_through_algorithm: payment_attempt.straight_through_algorithm,
|
||||
mandate_details: payment_attempt.mandate_details,
|
||||
};
|
||||
payment_attempts.push(payment_attempt.clone());
|
||||
Ok(payment_attempt)
|
||||
@ -400,6 +401,7 @@ mod storage {
|
||||
straight_through_algorithm: payment_attempt
|
||||
.straight_through_algorithm
|
||||
.clone(),
|
||||
mandate_details: payment_attempt.mandate_details.clone(),
|
||||
};
|
||||
|
||||
let field = format!("pa_{}", created_attempt.attempt_id);
|
||||
|
||||
@ -161,6 +161,55 @@ impl ForeignFrom<storage_enums::AttemptStatus> for storage_enums::IntentStatus {
|
||||
}
|
||||
}
|
||||
|
||||
impl ForeignFrom<api_models::payments::MandateType> for storage_enums::MandateDataType {
|
||||
fn foreign_from(from: api_models::payments::MandateType) -> Self {
|
||||
match from {
|
||||
api_models::payments::MandateType::SingleUse(inner) => {
|
||||
Self::SingleUse(inner.foreign_into())
|
||||
}
|
||||
api_models::payments::MandateType::MultiUse(inner) => {
|
||||
Self::MultiUse(inner.map(ForeignInto::foreign_into))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ForeignFrom<storage_enums::MandateDataType> for api_models::payments::MandateType {
|
||||
fn foreign_from(from: storage_enums::MandateDataType) -> Self {
|
||||
match from {
|
||||
storage_enums::MandateDataType::SingleUse(inner) => {
|
||||
Self::SingleUse(inner.foreign_into())
|
||||
}
|
||||
storage_enums::MandateDataType::MultiUse(inner) => {
|
||||
Self::MultiUse(inner.map(ForeignInto::foreign_into))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ForeignFrom<storage_enums::MandateAmountData> for api_models::payments::MandateAmountData {
|
||||
fn foreign_from(from: storage_enums::MandateAmountData) -> Self {
|
||||
Self {
|
||||
amount: from.amount,
|
||||
currency: from.currency.foreign_into(),
|
||||
start_date: from.start_date,
|
||||
end_date: from.end_date,
|
||||
metadata: from.metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ForeignFrom<api_models::payments::MandateAmountData> for storage_enums::MandateAmountData {
|
||||
fn foreign_from(from: api_models::payments::MandateAmountData) -> Self {
|
||||
Self {
|
||||
amount: from.amount,
|
||||
currency: from.currency.foreign_into(),
|
||||
start_date: from.start_date,
|
||||
end_date: from.end_date,
|
||||
metadata: from.metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ForeignTryFrom<api_enums::IntentStatus> for storage_enums::EventType {
|
||||
type Error = errors::ValidationError;
|
||||
|
||||
|
||||
@ -39,6 +39,8 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
// This will allow the error message that was generated in this function to point to the call site
|
||||
#[track_caller]
|
||||
fn get_required_value(self, field_name: &'static str) -> RouterResult<T> {
|
||||
match self {
|
||||
Some(v) => Ok(v),
|
||||
|
||||
@ -16,6 +16,9 @@ pub mod diesel_exports {
|
||||
}
|
||||
|
||||
pub use common_enums::*;
|
||||
use common_utils::pii;
|
||||
use diesel::serialize::{Output, ToSql};
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
@ -574,6 +577,54 @@ pub enum MandateType {
|
||||
MultiUse,
|
||||
}
|
||||
|
||||
use diesel::{
|
||||
backend::Backend,
|
||||
deserialize::{FromSql, FromSqlRow},
|
||||
expression::AsExpression,
|
||||
sql_types::Jsonb,
|
||||
};
|
||||
#[derive(
|
||||
serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, FromSqlRow, AsExpression,
|
||||
)]
|
||||
#[diesel(sql_type = Jsonb)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MandateDataType {
|
||||
SingleUse(MandateAmountData),
|
||||
MultiUse(Option<MandateAmountData>),
|
||||
}
|
||||
|
||||
impl<DB: Backend> FromSql<Jsonb, DB> for MandateDataType
|
||||
where
|
||||
serde_json::Value: FromSql<Jsonb, DB>,
|
||||
{
|
||||
fn from_sql(bytes: diesel::backend::RawValue<'_, DB>) -> diesel::deserialize::Result<Self> {
|
||||
let value = <serde_json::Value as FromSql<Jsonb, DB>>::from_sql(bytes)?;
|
||||
Ok(serde_json::from_value(value)?)
|
||||
}
|
||||
}
|
||||
impl ToSql<Jsonb, diesel::pg::Pg> for MandateDataType
|
||||
where
|
||||
serde_json::Value: ToSql<Jsonb, diesel::pg::Pg>,
|
||||
{
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result {
|
||||
let value = serde_json::to_value(self)?;
|
||||
|
||||
// the function `reborrow` only works in case of `Pg` backend. But, in case of other backends
|
||||
// please refer to the diesel migration blog:
|
||||
// https://github.com/Diesel-rs/Diesel/blob/master/guide_drafts/migration_guide.md#changed-tosql-implementations
|
||||
<serde_json::Value as ToSql<Jsonb, diesel::pg::Pg>>::to_sql(&value, &mut out.reborrow())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct MandateAmountData {
|
||||
pub amount: i64,
|
||||
pub currency: Currency,
|
||||
pub start_date: Option<PrimitiveDateTime>,
|
||||
pub end_date: Option<PrimitiveDateTime>,
|
||||
pub metadata: Option<pii::SecretSerdeValue>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
|
||||
@ -46,6 +46,8 @@ pub struct PaymentAttempt {
|
||||
pub payment_method_data: Option<serde_json::Value>,
|
||||
pub business_sub_label: Option<String>,
|
||||
pub straight_through_algorithm: Option<serde_json::Value>,
|
||||
// providing a location to store mandate details intermediately for transaction
|
||||
pub mandate_details: Option<storage_enums::MandateDataType>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
@ -91,6 +93,7 @@ pub struct PaymentAttemptNew {
|
||||
pub payment_method_data: Option<serde_json::Value>,
|
||||
pub business_sub_label: Option<String>,
|
||||
pub straight_through_algorithm: Option<serde_json::Value>,
|
||||
pub mandate_details: Option<storage_enums::MandateDataType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@ -321,6 +321,7 @@ diesel::table! {
|
||||
payment_method_data -> Nullable<Jsonb>,
|
||||
business_sub_label -> Nullable<Varchar>,
|
||||
straight_through_algorithm -> Nullable<Jsonb>,
|
||||
mandate_details -> Nullable<Jsonb>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2
migrations/2023-05-16-145008_mandate_data_in_pa/down.sql
Normal file
2
migrations/2023-05-16-145008_mandate_data_in_pa/down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE payment_attempt DROP COLUMN mandate_details;
|
||||
2
migrations/2023-05-16-145008_mandate_data_in_pa/up.sql
Normal file
2
migrations/2023-05-16-145008_mandate_data_in_pa/up.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- Your SQL goes here
|
||||
ALTER TABLE payment_attempt ADD COLUMN mandate_details JSONB;
|
||||
Reference in New Issue
Block a user