refactor(mandate): allow merchant to pass the mandate details and customer acceptance separately (#1188)

This commit is contained in:
Nishant Joshi
2023-05-19 12:19:19 +05:30
committed by GitHub
parent f394c4abc0
commit 6c41cdb1c9
20 changed files with 220 additions and 47 deletions

View File

@ -429,6 +429,9 @@ pub struct PaymentMethodListResponse {
] ]
))] ))]
pub payment_methods: Vec<ResponsePaymentMethodsEnabled>, 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)] #[derive(Eq, PartialEq, Hash, Debug, serde::Deserialize, ToSchema)]

View File

@ -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 { pub enum MandateTxnType {
NewMandateTxn, NewMandateTxn,
RecurringMandateTxn, 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)] #[derive(Default, Eq, PartialEq, Debug, serde::Deserialize, serde::Serialize, Clone, ToSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct MandateData { pub struct MandateData {
/// A concent from the customer to store the payment method /// 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 /// 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)] #[derive(Clone, Eq, PartialEq, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]

View File

@ -561,27 +561,27 @@ impl ForeignTryFrom<(Option<MandateOption>, Option<String>)> for Option<payments
let mandate_data = mandate_options.map(|mandate| payments::MandateData { let mandate_data = mandate_options.map(|mandate| payments::MandateData {
mandate_type: match mandate.mandate_type { mandate_type: match mandate.mandate_type {
Some(item) => match item { Some(item) => match item {
StripeMandateType::SingleUse => { StripeMandateType::SingleUse => Some(payments::MandateType::SingleUse(
payments::MandateType::SingleUse(payments::MandateAmountData { payments::MandateAmountData {
amount: mandate.amount.unwrap_or_default(), amount: mandate.amount.unwrap_or_default(),
currency, currency,
start_date: mandate.start_date, start_date: mandate.start_date,
end_date: mandate.end_date, end_date: mandate.end_date,
metadata: None, metadata: None,
}) },
} )),
StripeMandateType::MultiUse => payments::MandateType::MultiUse(None), StripeMandateType::MultiUse => Some(payments::MandateType::MultiUse(None)),
}, },
None => api_models::payments::MandateType::MultiUse(None), None => Some(api_models::payments::MandateType::MultiUse(None)),
}, },
customer_acceptance: payments::CustomerAcceptance { customer_acceptance: Some(payments::CustomerAcceptance {
acceptance_type: payments::AcceptanceType::Online, acceptance_type: payments::AcceptanceType::Online,
accepted_at: mandate.accepted_at, accepted_at: mandate.accepted_at,
online: Some(payments::OnlineMandate { online: Some(payments::OnlineMandate {
ip_address: mandate.ip_address.unwrap_or_default(), ip_address: mandate.ip_address.unwrap_or_default(),
user_agent: mandate.user_agent.unwrap_or_default(), user_agent: mandate.user_agent.unwrap_or_default(),
}), }),
}, }),
}); });
Ok(mandate_data) Ok(mandate_data)
} }

View File

@ -18,6 +18,7 @@ use crate::{
core::errors, core::errors,
services, services,
types::{self, api, storage::enums, transformers::ForeignTryFrom}, types::{self, api, storage::enums, transformers::ForeignTryFrom},
utils::OptionExt,
}; };
#[derive(Debug, Serialize, Default, Deserialize)] #[derive(Debug, Serialize, Default, Deserialize)]
@ -712,7 +713,12 @@ fn get_card_info<F>(
let (is_rebilling, additional_params, user_token_id) = let (is_rebilling, additional_params, user_token_id) =
match item.request.setup_mandate_details.clone() { match item.request.setup_mandate_details.clone() {
Some(mandate_data) => { 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::SingleUse(details) => details,
payments::MandateType::MultiUse(details) => { payments::MandateType::MultiUse(details) => {
details.ok_or(errors::ConnectorError::MissingRequiredField { details.ok_or(errors::ConnectorError::MissingRequiredField {

View File

@ -1096,6 +1096,7 @@ impl TryFrom<&types::PaymentsAuthorizeRouterData> for PaymentIntentRequest {
.and_then(|mandate_details| { .and_then(|mandate_details| {
mandate_details mandate_details
.customer_acceptance .customer_acceptance
.as_ref()?
.online .online
.as_ref() .as_ref()
.map(|online_details| StripeMandateRequest { .map(|online_details| StripeMandateRequest {

View File

@ -210,7 +210,7 @@ where
pm_id.get_required_value("payment_method_id")?, pm_id.get_required_value("payment_method_id")?,
mandate_ids, mandate_ids,
network_txn_id, network_txn_id,
) { )? {
let connector = new_mandate_data.connector.clone(); let connector = new_mandate_data.connector.clone();
logger::debug!("{:?}", new_mandate_data); logger::debug!("{:?}", new_mandate_data);
resp.request resp.request

View File

@ -1093,6 +1093,10 @@ pub async fn list_payment_methods(
api::PaymentMethodListResponse { api::PaymentMethodListResponse {
redirect_url: merchant_account.return_url, redirect_url: merchant_account.return_url,
payment_methods: payment_method_responses, 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),
}, },
))) )))
} }

View File

@ -296,14 +296,6 @@ pub fn validate_mandate(
} }
fn validate_new_mandate_request(req: api::MandateValidationFields) -> RouterResult<()> { 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 _ = req.customer_id.as_ref().get_required_value("customer_id")?;
let mandate_data = req 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) // Only use this validation if the customer_acceptance is present
&& mandate_data.customer_acceptance.online.is_none() 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 { Err(report!(errors::ApiErrorResponse::PreconditionFailed {
message: "`mandate_data.customer_acceptance.online` is required when \ 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 { let mandate_details = match mandate_data.mandate_type {
api_models::payments::MandateType::SingleUse(details) => Some(details), Some(api_models::payments::MandateType::SingleUse(details)) => Some(details),
api_models::payments::MandateType::MultiUse(details) => 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)| mandate_details.and_then(|md| md.start_date.zip(md.end_date)).map(|(start_date, end_date)|
utils::when (start_date >= end_date, || { utils::when (start_date >= end_date, || {
@ -1167,7 +1163,7 @@ pub fn generate_mandate(
payment_method_id: String, payment_method_id: String,
connector_mandate_id: Option<pii::SecretSerdeValue>, connector_mandate_id: Option<pii::SecretSerdeValue>,
network_txn_id: Option<String>, network_txn_id: Option<String>,
) -> Option<storage::MandateNew> { ) -> CustomResult<Option<storage::MandateNew>, errors::ApiErrorResponse> {
match (setup_mandate_details, customer) { match (setup_mandate_details, customer) {
(Some(data), Some(cus)) => { (Some(data), Some(cus)) => {
let mandate_id = utils::generate_id(consts::ID_LENGTH, "man"); 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 // The construction of the mandate new must be visible
let mut new_mandate = storage::MandateNew::default(); let mut new_mandate = storage::MandateNew::default();
let customer_acceptance = data
.customer_acceptance
.get_required_value("customer_acceptance")?;
new_mandate new_mandate
.set_mandate_id(mandate_id) .set_mandate_id(mandate_id)
.set_customer_id(cus.customer_id.clone()) .set_customer_id(cus.customer_id.clone())
@ -1185,34 +1184,36 @@ pub fn generate_mandate(
.set_connector_mandate_ids(connector_mandate_id) .set_connector_mandate_ids(connector_mandate_id)
.set_network_transaction_id(network_txn_id) .set_network_transaction_id(network_txn_id)
.set_customer_ip_address( .set_customer_ip_address(
data.customer_acceptance customer_acceptance
.get_ip_address() .get_ip_address()
.map(masking::Secret::new), .map(masking::Secret::new),
) )
.set_customer_user_agent(data.customer_acceptance.get_user_agent()) .set_customer_user_agent(customer_acceptance.get_user_agent())
.set_customer_accepted_at(Some(data.customer_acceptance.get_accepted_at())); .set_customer_accepted_at(Some(customer_acceptance.get_accepted_at()));
Some(match data.mandate_type { Ok(Some(
api::MandateType::SingleUse(data) => new_mandate match data.mandate_type.get_required_value("mandate_type")? {
.set_mandate_amount(Some(data.amount)) api::MandateType::SingleUse(data) => new_mandate
.set_mandate_currency(Some(data.currency.foreign_into()))
.set_mandate_type(storage_enums::MandateType::SingleUse)
.to_owned(),
api::MandateType::MultiUse(op_data) => match op_data {
Some(data) => new_mandate
.set_mandate_amount(Some(data.amount)) .set_mandate_amount(Some(data.amount))
.set_mandate_currency(Some(data.currency.foreign_into())) .set_mandate_currency(Some(data.currency.foreign_into()))
.set_start_date(data.start_date) .set_mandate_type(storage_enums::MandateType::SingleUse)
.set_end_date(data.end_date) .to_owned(),
.set_metadata(data.metadata),
None => &mut new_mandate, api::MandateType::MultiUse(op_data) => match op_data {
} Some(data) => new_mandate
.set_mandate_type(storage_enums::MandateType::MultiUse) .set_mandate_amount(Some(data.amount))
.to_owned(), .set_mandate_currency(Some(data.currency.foreign_into()))
}) .set_start_date(data.start_date)
.set_end_date(data.end_date)
.set_metadata(data.metadata),
None => &mut new_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, 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. // 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, straight_through_algorithm: old_payment_attempt.straight_through_algorithm,
mandate_details: old_payment_attempt.mandate_details,
} }
} }

View File

@ -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.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()); 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(( Ok((
Box::new(self), Box::new(self),
PaymentData { PaymentData {

View File

@ -201,6 +201,16 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
.await .await
.transpose()?; .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(( Ok((
Box::new(self), Box::new(self),
PaymentData { PaymentData {

View File

@ -213,6 +213,15 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
.await .await
.transpose()?; .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(( Ok((
operation, operation,
PaymentData { PaymentData {
@ -498,6 +507,10 @@ impl PaymentCreate {
payment_experience: request.payment_experience.map(ForeignInto::foreign_into), payment_experience: request.payment_experience.map(ForeignInto::foreign_into),
payment_method_type: request.payment_method_type.map(ForeignInto::foreign_into), payment_method_type: request.payment_method_type.map(ForeignInto::foreign_into),
payment_method_data: additional_pm_data, 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() ..storage::PaymentAttemptNew::default()
}) })
} }

View File

@ -267,6 +267,15 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
.await .await
.transpose()?; .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(( Ok((
next_operation, next_operation,
PaymentData { PaymentData {

View File

@ -265,6 +265,7 @@ impl PaymentAttemptInterface for MockDb {
payment_method_data: payment_attempt.payment_method_data, payment_method_data: payment_attempt.payment_method_data,
business_sub_label: payment_attempt.business_sub_label, business_sub_label: payment_attempt.business_sub_label,
straight_through_algorithm: payment_attempt.straight_through_algorithm, straight_through_algorithm: payment_attempt.straight_through_algorithm,
mandate_details: payment_attempt.mandate_details,
}; };
payment_attempts.push(payment_attempt.clone()); payment_attempts.push(payment_attempt.clone());
Ok(payment_attempt) Ok(payment_attempt)
@ -400,6 +401,7 @@ mod storage {
straight_through_algorithm: payment_attempt straight_through_algorithm: payment_attempt
.straight_through_algorithm .straight_through_algorithm
.clone(), .clone(),
mandate_details: payment_attempt.mandate_details.clone(),
}; };
let field = format!("pa_{}", created_attempt.attempt_id); let field = format!("pa_{}", created_attempt.attempt_id);

View File

@ -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 { impl ForeignTryFrom<api_enums::IntentStatus> for storage_enums::EventType {
type Error = errors::ValidationError; type Error = errors::ValidationError;

View File

@ -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> { fn get_required_value(self, field_name: &'static str) -> RouterResult<T> {
match self { match self {
Some(v) => Ok(v), Some(v) => Ok(v),

View File

@ -16,6 +16,9 @@ pub mod diesel_exports {
} }
pub use common_enums::*; pub use common_enums::*;
use common_utils::pii;
use diesel::serialize::{Output, ToSql};
use time::PrimitiveDateTime;
#[derive( #[derive(
Clone, Clone,
@ -574,6 +577,54 @@ pub enum MandateType {
MultiUse, 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( #[derive(
Clone, Clone,
Copy, Copy,

View File

@ -46,6 +46,8 @@ pub struct PaymentAttempt {
pub payment_method_data: Option<serde_json::Value>, pub payment_method_data: Option<serde_json::Value>,
pub business_sub_label: Option<String>, pub business_sub_label: Option<String>,
pub straight_through_algorithm: Option<serde_json::Value>, 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( #[derive(
@ -91,6 +93,7 @@ pub struct PaymentAttemptNew {
pub payment_method_data: Option<serde_json::Value>, pub payment_method_data: Option<serde_json::Value>,
pub business_sub_label: Option<String>, pub business_sub_label: Option<String>,
pub straight_through_algorithm: Option<serde_json::Value>, pub straight_through_algorithm: Option<serde_json::Value>,
pub mandate_details: Option<storage_enums::MandateDataType>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -321,6 +321,7 @@ diesel::table! {
payment_method_data -> Nullable<Jsonb>, payment_method_data -> Nullable<Jsonb>,
business_sub_label -> Nullable<Varchar>, business_sub_label -> Nullable<Varchar>,
straight_through_algorithm -> Nullable<Jsonb>, straight_through_algorithm -> Nullable<Jsonb>,
mandate_details -> Nullable<Jsonb>,
} }
} }

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE payment_attempt DROP COLUMN mandate_details;

View File

@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE payment_attempt ADD COLUMN mandate_details JSONB;