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>,
/// 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)]

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 {
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)]

View File

@ -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)
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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),
},
)))
}

View File

@ -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,
}
}

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.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 {

View File

@ -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 {

View File

@ -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()
})
}

View File

@ -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 {

View File

@ -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);

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 {
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> {
match self {
Some(v) => Ok(v),

View File

@ -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,

View File

@ -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)]

View File

@ -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>,
}
}

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;