mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-29 00:49:42 +08:00
feat(mandate): added amount based validation and database fields (#99)
This commit is contained in:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
Reference in New Issue
Block a user