feat(mandate): added amount based validation and database fields (#99)

This commit is contained in:
Nishant Joshi
2022-12-11 17:35:43 +05:30
committed by GitHub
parent 4db63d92b8
commit 21a0a3d81d
10 changed files with 184 additions and 97 deletions

View File

@ -146,6 +146,8 @@ pub(crate) enum ErrorCode {
current_value: String,
states: String,
},
#[error(error_type = StripeErrorType::InvalidRequestError, code = "", message = "The mandate information is invalid. {message}")]
PaymentIntentMandateInvalid { message: String },
// TODO: Some day implement all stripe error codes https://stripe.com/docs/error-codes
// AccountCountryInvalidAddress,
// AccountErrorCountryChangeRequiresAdditionalSteps,
@ -227,7 +229,6 @@ pub(crate) enum ErrorCode {
// PaymentIntentIncompatiblePaymentMethod,
// PaymentIntentInvalidParameter,
// PaymentIntentKonbiniRejectedConfirmationNumber,
// PaymentIntentMandateInvalid,
// PaymentIntentPaymentAttemptExpired,
// PaymentIntentUnexpectedState,
// PaymentMethodBankAccountAlreadyVerified,
@ -350,6 +351,9 @@ impl From<ApiErrorResponse> for ErrorCode {
ErrorCode::MerchantConnectorAccountNotFound
}
ApiErrorResponse::MandateNotFound => ErrorCode::MandateNotFound,
ApiErrorResponse::MandateValidationFailed { reason } => {
ErrorCode::PaymentIntentMandateInvalid { message: reason }
}
ApiErrorResponse::ReturnUrlUnavailable => ErrorCode::ReturnUrlUnavailable,
ApiErrorResponse::DuplicateMerchantAccount => ErrorCode::DuplicateMerchantAccount,
ApiErrorResponse::DuplicateMerchantConnectorAccount => {
@ -427,6 +431,7 @@ impl actix_web::ResponseError for ErrorCode {
| ErrorCode::SuccessfulPaymentNotFound
| ErrorCode::AddressNotFound
| ErrorCode::ResourceIdNotFound
| ErrorCode::PaymentIntentMandateInvalid { .. }
| ErrorCode::PaymentIntentUnexpectedState { .. } => StatusCode::BAD_REQUEST,
ErrorCode::RefundFailed | ErrorCode::InternalServerError => {
StatusCode::INTERNAL_SERVER_ERROR

View File

@ -120,6 +120,8 @@ pub enum ApiErrorResponse {
SuccessfulPaymentNotFound,
#[error(error_type = ErrorType::ObjectNotFound, code = "RE_05", message = "Address does not exist in our records.")]
AddressNotFound,
#[error(error_type = ErrorType::ValidationError, code = "RE_03", message = "Mandate Validation Failed" )]
MandateValidationFailed { reason: String },
#[error(error_type = ErrorType::ServerNotAvailable, code = "IR_00", message = "This API is under development and will be made available soon.")]
NotImplemented,
}
@ -159,7 +161,8 @@ impl actix_web::ResponseError for ApiErrorResponse {
| ApiErrorResponse::CardExpired { .. }
| ApiErrorResponse::RefundFailed { .. }
| ApiErrorResponse::VerificationFailed { .. }
| ApiErrorResponse::PaymentUnexpectedState { .. } => StatusCode::BAD_REQUEST, // 400
| ApiErrorResponse::PaymentUnexpectedState { .. }
| ApiErrorResponse::MandateValidationFailed { .. } => StatusCode::BAD_REQUEST, // 400
ApiErrorResponse::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, // 500
ApiErrorResponse::DuplicateRefundRequest => StatusCode::BAD_REQUEST, // 400

View File

@ -1,10 +1,8 @@
use async_trait::async_trait;
use error_stack::ResultExt;
use masking::Secret;
use super::{ConstructFlowSpecificData, Feature};
use crate::{
consts,
core::{
errors::{self, ConnectorErrorExt, RouterResult, StorageErrorExt},
payments::{self, helpers, transformers, PaymentData},
@ -17,7 +15,6 @@ use crate::{
storage::{self, enums as storage_enums},
PaymentsAuthorizeData, PaymentsAuthorizeRouterData, PaymentsResponseData,
},
utils,
};
#[async_trait]
@ -124,7 +121,20 @@ impl PaymentsAuthorizeRouterData {
)
.await
.change_context(errors::ApiErrorResponse::MandateNotFound),
storage_enums::MandateType::MultiUse => Ok(mandate),
storage_enums::MandateType::MultiUse => state
.store
.update_mandate_by_merchant_id_mandate_id(
&resp.merchant_id,
mandate_id,
storage::MandateUpdate::CaptureAmountUpdate {
amount_captured: Some(
mandate.amount_captured.unwrap_or(0)
+ self.request.amount,
),
},
)
.await
.change_context(errors::ApiErrorResponse::MandateNotFound),
}?;
resp.payment_method_id = Some(mandate.payment_method_id);
@ -142,9 +152,13 @@ impl PaymentsAuthorizeRouterData {
.payment_method_id;
resp.payment_method_id = Some(payment_method_id.clone());
if let Some(new_mandate_data) =
self.generate_mandate(maybe_customer, payment_method_id)
{
if let Some(new_mandate_data) = helpers::generate_mandate(
self.merchant_id.clone(),
self.connector.clone(),
None,
maybe_customer,
payment_method_id,
) {
resp.request.mandate_id = Some(new_mandate_data.mandate_id.clone());
state.store.insert_mandate(new_mandate_data).await.map_err(
|err| {
@ -163,44 +177,4 @@ impl PaymentsAuthorizeRouterData {
_ => Ok(self.clone()),
}
}
fn generate_mandate(
&self,
customer: &Option<storage::Customer>,
payment_method_id: String,
) -> Option<storage::MandateNew> {
match (self.request.setup_mandate_details.clone(), customer) {
(Some(data), Some(cus)) => {
let mandate_id = utils::generate_id(consts::ID_LENGTH, "man");
// The construction of the mandate new must be visible
let mut new_mandate = storage::MandateNew::default();
new_mandate
.set_mandate_id(mandate_id)
.set_customer_id(cus.customer_id.clone())
.set_merchant_id(self.merchant_id.clone())
.set_payment_method_id(payment_method_id)
.set_mandate_status(storage_enums::MandateStatus::Active)
.set_customer_ip_address(
data.customer_acceptance.get_ip_address().map(Secret::new),
)
.set_customer_user_agent(data.customer_acceptance.get_user_agent())
.set_customer_accepted_at(Some(data.customer_acceptance.get_accepted_at()));
Some(match data.mandate_type {
api::MandateType::SingleUse(data) => new_mandate
.set_single_use_amount(Some(data.amount))
.set_single_use_currency(Some(data.currency))
.set_mandate_type(storage_enums::MandateType::SingleUse)
.to_owned(),
api::MandateType::MultiUse => new_mandate
.set_mandate_type(storage_enums::MandateType::MultiUse)
.to_owned(),
})
}
(_, _) => None,
}
}
}

View File

@ -1,10 +1,8 @@
use async_trait::async_trait;
use error_stack::ResultExt;
use masking::Secret;
use super::{ConstructFlowSpecificData, Feature};
use crate::{
consts,
core::{
errors::{self, ConnectorErrorExt, RouterResult, StorageErrorExt},
payments::{self, helpers, transformers, PaymentData},
@ -15,7 +13,6 @@ use crate::{
self, api,
storage::{self, enums},
},
utils,
};
#[async_trait]
@ -108,8 +105,9 @@ impl types::VerifyRouterData {
.payment_method_id;
resp.payment_method_id = Some(payment_method_id.clone());
if let Some(new_mandate_data) = generate_mandate(
if let Some(new_mandate_data) = helpers::generate_mandate(
self.merchant_id.clone(),
self.connector.clone(),
self.request.setup_mandate_details.clone(),
maybe_customer,
payment_method_id,
@ -132,29 +130,3 @@ impl types::VerifyRouterData {
}
}
}
fn generate_mandate(
merchant_id: String,
setup_mandate_details: Option<api::MandateData>,
customer: &Option<storage::Customer>,
payment_method_id: String,
) -> Option<storage::MandateNew> {
match (setup_mandate_details, customer) {
(Some(data), Some(cus)) => {
let mandate_id = utils::generate_id(consts::ID_LENGTH, "man");
Some(storage::MandateNew {
mandate_id,
customer_id: cus.customer_id.clone(),
merchant_id,
payment_method_id,
mandate_status: enums::MandateStatus::Active,
mandate_type: enums::MandateType::MultiUse,
customer_ip_address: data.customer_acceptance.get_ip_address().map(Secret::new),
customer_user_agent: data.customer_acceptance.get_user_agent(),
customer_accepted_at: Some(data.customer_acceptance.get_accepted_at()),
..Default::default()
})
}
(_, _) => None,
}
}

View File

@ -141,10 +141,7 @@ pub async fn get_token_for_recurring_mandate(
.await
.map_err(|error| error.to_not_found_response(errors::ApiErrorResponse::MandateNotFound))?;
utils::when(
mandate.mandate_status != storage_enums::MandateStatus::Active,
Err(errors::ApiErrorResponse::MandateNotFound),
)?;
// TODO: Make currency in payments request as Currency enum
let customer = req.customer_id.clone().get_required_value("customer_id")?;
@ -159,8 +156,13 @@ pub async fn get_token_for_recurring_mandate(
message: "mandate is not active".into()
}))?
};
mandate.payment_method_id
mandate.payment_method_id.clone()
};
verify_mandate_details(
req.amount.get_required_value("amount")?.into(),
req.currency.clone().get_required_value("currency")?,
mandate.clone(),
)?;
let payment_method = db
.find_payment_method(payment_method_id.as_str())
@ -346,6 +348,44 @@ fn validate_recurring_mandate(req: api::MandateValidationFields) -> RouterResult
Ok(())
}
pub fn verify_mandate_details(
request_amount: i32,
request_currency: String,
mandate: storage::Mandate,
) -> RouterResult<()> {
match mandate.mandate_type {
storage_enums::MandateType::SingleUse => utils::when(
mandate
.mandate_amount
.map(|mandate_amount| request_amount > mandate_amount)
.unwrap_or(true),
Err(report!(errors::ApiErrorResponse::MandateValidationFailed {
reason: "request amount is greater than mandate amount".to_string()
})),
),
storage::enums::MandateType::MultiUse => utils::when(
mandate
.mandate_amount
.map(|mandate_amount| {
(mandate.amount_captured.unwrap_or(0) + request_amount) > mandate_amount
})
.unwrap_or(false),
Err(report!(errors::ApiErrorResponse::MandateValidationFailed {
reason: "request amount is greater than mandate amount".to_string()
})),
),
}?;
utils::when(
mandate
.mandate_currency
.map(|mandate_currency| mandate_currency.to_string() != request_currency)
.unwrap_or(true),
Err(report!(errors::ApiErrorResponse::MandateValidationFailed {
reason: "cross currency mandates not supported".to_string()
})),
)
}
#[instrument(skip_all)]
pub fn payment_attempt_status_fsm(
payment_method_data: &Option<api::PaymentMethod>,
@ -1077,3 +1117,53 @@ pub fn hmac_sha256_sorted_query_params<'a>(
pub fn check_if_operation_confirm<Op: std::fmt::Debug>(operations: Op) -> bool {
format!("{:?}", operations) == "PaymentConfirm"
}
pub fn generate_mandate(
merchant_id: String,
connector: String,
setup_mandate_details: Option<api::MandateData>,
customer: &Option<storage::Customer>,
payment_method_id: String,
) -> Option<storage::MandateNew> {
match (setup_mandate_details, customer) {
(Some(data), Some(cus)) => {
let mandate_id = utils::generate_id(consts::ID_LENGTH, "man");
// The construction of the mandate new must be visible
let mut new_mandate = storage::MandateNew::default();
new_mandate
.set_mandate_id(mandate_id)
.set_customer_id(cus.customer_id.clone())
.set_merchant_id(merchant_id)
.set_payment_method_id(payment_method_id)
.set_connector(connector)
.set_mandate_status(storage_enums::MandateStatus::Active)
.set_customer_ip_address(
data.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()));
Some(match data.mandate_type {
api::MandateType::SingleUse(data) => new_mandate
.set_mandate_amount(Some(data.amount))
.set_mandate_currency(Some(data.currency))
.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_currency(Some(data.currency)),
None => &mut new_mandate,
}
.set_mandate_type(storage_enums::MandateType::MultiUse)
.to_owned(),
})
}
(_, _) => None,
}
}

View File

@ -127,8 +127,11 @@ diesel::table! {
network_transaction_id -> Nullable<Varchar>,
previous_transaction_id -> Nullable<Varchar>,
created_at -> Timestamp,
single_use_amount -> Nullable<Int4>,
single_use_currency -> Nullable<Currency>,
mandate_amount -> Nullable<Int4>,
mandate_currency -> Nullable<Currency>,
amount_captured -> Nullable<Int4>,
connector -> Varchar,
connector_mandate_id -> Nullable<Varchar>,
}
}

View File

@ -154,11 +154,16 @@ pub struct MandateData {
pub mandate_type: MandateType,
}
#[derive(Default, Eq, PartialEq, Debug, serde::Deserialize, serde::Serialize, Clone)]
#[derive(Eq, PartialEq, Debug, serde::Deserialize, serde::Serialize, Clone)]
pub enum MandateType {
SingleUse(storage::SingleUseMandate),
#[default]
MultiUse,
SingleUse(storage::MandateAmountData),
MultiUse(Option<storage::MandateAmountData>),
}
impl Default for MandateType {
fn default() -> Self {
Self::MultiUse(None)
}
}
#[derive(Default, Eq, PartialEq, Debug, serde::Deserialize, serde::Serialize, Clone)]

View File

@ -24,8 +24,11 @@ pub struct Mandate {
pub network_transaction_id: Option<String>,
pub previous_transaction_id: Option<String>,
pub created_at: PrimitiveDateTime,
pub single_use_amount: Option<i32>,
pub single_use_currency: Option<storage_enums::Currency>,
pub mandate_amount: Option<i32>,
pub mandate_currency: Option<storage_enums::Currency>,
pub amount_captured: Option<i32>,
pub connector: String,
pub connector_mandate_id: Option<String>,
}
#[derive(
@ -45,8 +48,11 @@ pub struct MandateNew {
pub network_transaction_id: Option<String>,
pub previous_transaction_id: Option<String>,
pub created_at: Option<PrimitiveDateTime>,
pub single_use_amount: Option<i32>,
pub single_use_currency: Option<storage_enums::Currency>,
pub mandate_amount: Option<i32>,
pub mandate_currency: Option<storage_enums::Currency>,
pub amount_captured: Option<i32>,
pub connector: String,
pub connector_mandate_id: String,
}
#[derive(Debug)]
@ -54,10 +60,13 @@ pub enum MandateUpdate {
StatusUpdate {
mandate_status: storage_enums::MandateStatus,
},
CaptureAmountUpdate {
amount_captured: Option<i32>,
},
}
#[derive(Clone, Eq, PartialEq, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct SingleUseMandate {
pub struct MandateAmountData {
pub amount: i32,
pub currency: storage_enums::Currency,
}
@ -65,13 +74,21 @@ pub struct SingleUseMandate {
#[derive(Clone, Debug, Default, AsChangeset, router_derive::DebugAsDisplay)]
#[diesel(table_name = mandate)]
pub(super) struct MandateUpdateInternal {
mandate_status: storage_enums::MandateStatus,
mandate_status: Option<storage_enums::MandateStatus>,
amount_captured: Option<i32>,
}
impl From<MandateUpdate> for MandateUpdateInternal {
fn from(mandate_update: MandateUpdate) -> Self {
match mandate_update {
MandateUpdate::StatusUpdate { mandate_status } => Self { mandate_status },
MandateUpdate::StatusUpdate { mandate_status } => Self {
mandate_status: Some(mandate_status),
amount_captured: None,
},
MandateUpdate::CaptureAmountUpdate { amount_captured } => Self {
mandate_status: None,
amount_captured,
},
}
}
}

View File

@ -0,0 +1,9 @@
-- This file should undo anything in `up.sql`
ALTER TABLE mandate
RENAME COLUMN mandate_amount TO single_use_amount;
ALTER TABLE mandate
RENAME COLUMN mandate_currency TO single_use_currency;
ALTER TABLE mandate
DROP COLUMN IF EXISTS amount_captured,
DROP COLUMN IF EXISTS connector,
DROP COLUMN IF EXISTS connector_mandate_id;

View File

@ -0,0 +1,9 @@
-- Your SQL goes here
ALTER TABLE mandate
RENAME COLUMN single_use_amount TO mandate_amount;
ALTER TABLE mandate
RENAME COLUMN single_use_currency TO mandate_currency;
ALTER TABLE mandate
ADD IF NOT EXISTS amount_captured INTEGER DEFAULT NULL,
ADD IF NOT EXISTS connector VARCHAR(255) NOT NULL DEFAULT 'dummy',
ADD IF NOT EXISTS connector_mandate_id VARCHAR(255) DEFAULT NULL;