Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::http::{
use grpc_api_types::payments::{
composite_payment_method_service_server::CompositePaymentMethodService,
CompositePaymentMethodCreateRequest, CompositePaymentMethodCreateResponse,
CompositePaymentMethodEligibilityRequest, CompositePaymentMethodEligibilityResponse,
CompositePaymentMethodGetRequest, CompositePaymentMethodGetResponse,
CompositePaymentMethodRechargeRequest, CompositePaymentMethodRechargeResponse,
};
Expand Down Expand Up @@ -40,3 +41,11 @@ http_handler!(
recharge,
composite_payment_method_service
);

http_handler!(
eligibility,
CompositePaymentMethodEligibilityRequest,
CompositePaymentMethodEligibilityResponse,
eligibility,
composite_payment_method_service
);
4 changes: 4 additions & 0 deletions crates/grpc-server/grpc-server/src/http/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ pub fn create_router(state: AppState) -> Router {
"/composite/payment_methods/recharge",
post(handlers::composite::payment_methods::recharge),
)
.route(
"/composite/payment_methods/eligibility",
post(handlers::composite::payment_methods::eligibility),
)
.route(
"/composite/pre_authenticate",
post(handlers::composite::payments::pre_authenticate),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ use common_utils::{
};
use domain_types::{
connector_flow::{
Authorize, CreatePaymentMethod, GetPaymentMethod, Recharge, Refund,
ServerAuthenticationToken,
Authorize, CreatePaymentMethod, GetPaymentMethod, PaymentMethodEligibility, Recharge,
Refund, ServerAuthenticationToken,
},
connector_types::{
CreatePaymentMethodData, CreatePaymentMethodResponseData, GetPaymentMethodData,
GetPaymentMethodResponseData, PaymentFlowData, PaymentsAuthorizeData, PaymentsResponseData,
GetPaymentMethodResponseData, PaymentFlowData, PaymentMethodEligibilityData,
PaymentMethodEligibilityResponse, PaymentsAuthorizeData, PaymentsResponseData,
RechargeRequestData, RechargeResponseData, RefundFlowData, RefundsData,
RefundsResponseData, ServerAuthenticationTokenRequestData,
ServerAuthenticationTokenResponseData,
Expand All @@ -40,9 +41,10 @@ use serde::Serialize;
use transformers::{
self as qwikcilver, QwikcilverAuthType, QwikcilverAuthorizeRequest,
QwikcilverAuthorizeResponse, QwikcilverCancelRedeemBody, QwikcilverCancelRedeemResponse,
QwikcilverCreateWalletRequest, QwikcilverEmptyBody, QwikcilverErrorResponse,
QwikcilverGetWalletResponse, QwikcilverRechargeRequest, QwikcilverRechargeResponse,
QwikcilverRedeemRequest, QwikcilverRedeemResponse, QwikcilverWalletEnvelope,
QwikcilverCreateWalletRequest, QwikcilverEligibilityResponse, QwikcilverEmptyBody,
QwikcilverErrorResponse, QwikcilverGetWalletResponse, QwikcilverRechargeRequest,
QwikcilverRechargeResponse, QwikcilverRedeemRequest, QwikcilverRedeemResponse,
QwikcilverWalletEnvelope,
};

use super::macros;
Expand Down Expand Up @@ -79,6 +81,10 @@ impl<T: PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize>
connector_types::GetPaymentMethodV2 for Qwikcilver<T>
{
}
impl<T: PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize>
connector_types::PaymentMethodEligibilityV2 for Qwikcilver<T>
{
}
impl<T: PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize>
connector_types::ServerAuthentication for Qwikcilver<T>
{
Expand Down Expand Up @@ -155,6 +161,11 @@ macros::create_all_prerequisites!(
request_body: QwikcilverEmptyBody,
response_body: QwikcilverGetWalletResponse,
router_data: RouterDataV2<GetPaymentMethod, PaymentFlowData, GetPaymentMethodData, GetPaymentMethodResponseData>,
),
(
flow: PaymentMethodEligibility,
response_body: QwikcilverEligibilityResponse,
router_data: RouterDataV2<PaymentMethodEligibility, PaymentFlowData, PaymentMethodEligibilityData, PaymentMethodEligibilityResponse>,
)
],
amount_converters: [
Expand Down Expand Up @@ -233,6 +244,44 @@ macros::create_all_prerequisites!(
) -> &'a str {
&req.resource_common_data.connectors.qwikcilver.base_url
}

/// Shared wallet lookup used by both `GetPaymentMethod` and
/// `PaymentMethodEligibility`, which make the identical connector call.
/// Primary: wallet number → `/wallet/{wn}`.
/// Fallback: customer phone → `/wallet/customer?phonenumber={phone}` (Pine Labs's
/// documented by-external-id lookup; response envelope is identical to the
/// by-wallet-number variant).
pub fn wallet_lookup_url(
&self,
base: &str,
wallet_number: Option<&str>,
phone: Option<&hyperswitch_masking::Secret<String>>,
) -> CustomResult<String, IntegrationError> {
if let Some(wallet_number) = wallet_number {
return Ok(format!(
"{base}Qwikcilver/eGMS.RestApi/api/v2/wallet/{}",
urlencoding::encode(wallet_number),
));
}
if let Some(phone) = phone {
return Ok(format!(
"{base}Qwikcilver/eGMS.RestApi/api/v2/wallet/customer?phonenumber={}",
urlencoding::encode(phone.peek()),
));
}
Err(IntegrationError::MissingRequiredField {
field_name: "connector_payment_method_id | customer.phone_number",
context: qwikcilver::qc_err_ctx(
"Qwikcilver's wallet lookup accepts either the wallet number (preferred) or \
the customer's phone number as a fallback. Neither was supplied, so \
there's no way to identify which wallet to fetch.",
"Set `connector_payment_method_id` to the wallet number returned by a \
prior Create, OR set `customer.phone_number` to look up by Pine Labs's \
external wallet id (the customer's mobile).",
),
}
.into())
}
}
);

Expand Down Expand Up @@ -545,39 +594,14 @@ macros::macro_connector_implementation!(
&self,
req: &RouterDataV2<GetPaymentMethod, PaymentFlowData, GetPaymentMethodData, GetPaymentMethodResponseData>,
) -> CustomResult<String, IntegrationError> {
// Primary: wallet number → `/wallet/{wn}`.
// Fallback: customer phone → `/wallet/customer?phonenumber={phone}` (Pine Labs's documented
// by-external-id lookup; response envelope is identical to the by-wallet-number variant).
let base = self.connector_base_url_payments(req);
if let Some(wallet_number) = req.request.connector_payment_method_id.as_deref() {
return Ok(format!(
"{base}Qwikcilver/eGMS.RestApi/api/v2/wallet/{}",
urlencoding::encode(wallet_number),
));
}
if let Some(phone) = req
.request
.customer
.as_ref()
.and_then(|c| c.customer_phone_number.as_ref())
{
return Ok(format!(
"{base}Qwikcilver/eGMS.RestApi/api/v2/wallet/customer?phonenumber={}",
urlencoding::encode(phone.peek()),
));
}
Err(IntegrationError::MissingRequiredField {
field_name: "connector_payment_method_id | customer.phone_number",
context: qwikcilver::qc_err_ctx(
"Qwikcilver Get accepts either the wallet number (preferred) or the \
customer's phone number as a fallback. Neither was supplied, so there's \
no way to identify which wallet to fetch.",
"Set `connector_payment_method_id` to the wallet number returned by a \
prior Create, OR set `customer.phone_number` to look up by Pine Labs's \
external wallet id (the customer's mobile).",
),
}
.into())
self.wallet_lookup_url(
self.connector_base_url_payments(req),
req.request.connector_payment_method_id.as_deref(),
req.request
.customer
.as_ref()
.and_then(|c| c.customer_phone_number.as_ref()),
)
}

fn get_headers(
Expand All @@ -594,6 +618,48 @@ macros::macro_connector_implementation!(
}
);

macros::macro_connector_implementation!(
connector_default_implementations: [get_content_type, get_error_response_v2],
connector: Qwikcilver,
curl_response: QwikcilverEligibilityResponse,
flow_name: PaymentMethodEligibility,
resource_common_data: PaymentFlowData,
flow_request: PaymentMethodEligibilityData,
flow_response: PaymentMethodEligibilityResponse,
http_method: Get,
generic_type: T,
[PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize],
other_functions: {
fn get_url(
&self,
req: &RouterDataV2<PaymentMethodEligibility, PaymentFlowData, PaymentMethodEligibilityData, PaymentMethodEligibilityResponse>,
) -> CustomResult<String, IntegrationError> {
// Performs the exact same connector call as `GetPaymentMethod` — eligibility for
// a Qwikcilver wallet is determined from the same wallet lookup response.
self.wallet_lookup_url(
self.connector_base_url_payments(req),
req.request.connector_payment_method_id.as_deref(),
req.request
.customer
.as_ref()
.and_then(|c| c.customer_phone_number.as_ref()),
)
}

fn get_headers(
&self,
req: &RouterDataV2<PaymentMethodEligibility, PaymentFlowData, PaymentMethodEligibilityData, PaymentMethodEligibilityResponse>,
) -> CustomResult<Vec<(String, Maskable<String>)>, IntegrationError> {
let token = self.extract_access_token(req.resource_common_data.access_token.as_ref())?;
let date = qwikcilver::current_datetime_qwikcilver();
let txn_id = qwikcilver::derive_transaction_id_from_reference(
&req.resource_common_data.connector_request_reference_id,
);
self.build_authenticated_headers(req, &token, &date, txn_id)
}
}
);

macros::macro_connector_flow_status_impls!(
connector: Qwikcilver,
generic_type: T,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ use common_enums::{AttemptStatus, RechargeStatus, RefundStatus};
use common_utils::types::FloatMajorUnit;
use domain_types::{
connector_flow::{
Authorize, CreatePaymentMethod, GetPaymentMethod, Recharge, Refund,
ServerAuthenticationToken,
Authorize, CreatePaymentMethod, GetPaymentMethod, PaymentMethodEligibility, Recharge,
Refund, ServerAuthenticationToken,
},
connector_types::{
CreatePaymentMethodData, CreatePaymentMethodResponseData, CustomerInfo,
GetPaymentMethodData, GetPaymentMethodResponseData, PaymentFlowData, PaymentsAuthorizeData,
GetPaymentMethodData, GetPaymentMethodResponseData, PaymentFlowData,
PaymentMethodEligibilityData, PaymentMethodEligibilityResponse, PaymentsAuthorizeData,
PaymentsResponseData, RechargeRequestData, RechargeResponseData, RefundFlowData,
RefundsData, RefundsResponseData, ResponseId, ServerAuthenticationTokenRequestData,
ServerAuthenticationTokenResponseData,
Expand Down Expand Up @@ -948,6 +949,14 @@ where
#[serde(transparent)]
pub struct QwikcilverGetWalletResponse(pub QwikcilverWalletEnvelope);

/// Distinct response newtype for `PaymentMethodEligibility`. Wraps the identical
/// `QwikcilverWalletEnvelope` payload `GetPaymentMethod` parses — macro-generated templating
/// types are keyed by response type name, so this flow needs its own type to avoid colliding
/// with `GetPaymentMethod`'s templating impl, even though it's the same connector call.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(transparent)]
pub struct QwikcilverEligibilityResponse(pub QwikcilverWalletEnvelope);

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct QwikcilverWalletDetails {
Expand Down Expand Up @@ -1204,6 +1213,73 @@ impl TryFrom<ResponseRouterData<QwikcilverGetWalletResponse, Self>>
}
}

/// Performs the exact same wallet lookup as `GetPaymentMethod` and derives eligibility from
/// the wallet's status: ACTIVE → Eligible, INACTIVE → Ineligible. The resolved wallet's
/// payment method details (balance, items, etc.) are returned alongside the eligibility
/// verdict in the same response.
impl TryFrom<ResponseRouterData<QwikcilverEligibilityResponse, Self>>
for RouterDataV2<
PaymentMethodEligibility,
PaymentFlowData,
PaymentMethodEligibilityData,
PaymentMethodEligibilityResponse,
>
{
type Error = error_stack::Report<ConnectorError>;

fn try_from(
item: ResponseRouterData<QwikcilverEligibilityResponse, Self>,
) -> Result<Self, Self::Error> {
let mut data = item.router_data;
let body = item.response.0;
data.resource_common_data.raw_connector_response =
serde_json::to_string(&body).ok().map(Secret::new);
data.response = match body.response_code {
QWIKCILVER_SUCCESS_CODE => {
let currency = data.request.amount.currency;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

amount.currency is the order currency, but it drives convert_back on the wallet's balance. GetPaymentMethod (:1178) and CreatePaymentMethod (:1113) read the same envelope via currency_from_feature_data. A JPY order against the AED wallet from your repro turns 2339.68 into 2340 instead of 233968, so /get and /eligibility report different balances for one wallet.

Suggested change
let currency = data.request.amount.currency;
let currency =
currency_from_feature_data(data.request.connector_feature_data.as_ref());

Some(currency) at :1257 then becomes just currency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not required in connector_feature_data, passing it in the amount field itself

let (eligibility, payment_method_details) =
if let Some(wallet) = body.wallet.as_ref() {
let eligibility = match map_wallet_status(wallet.status.as_ref()) {
Some(common_enums::WalletStatus::Active) => {
common_enums::EligibilityStatus::Eligible
}
Some(common_enums::WalletStatus::Inactive) => {
common_enums::EligibilityStatus::Ineligible
}
Some(common_enums::WalletStatus::Unspecified) | None => {
common_enums::EligibilityStatus::Unknown
}
};
(
eligibility,
Some(wallet_details_to_payment_method_details(
wallet,
Some(currency),
)),
)
} else {
(common_enums::EligibilityStatus::Unknown, None)
};
Ok(PaymentMethodEligibilityResponse {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only amount.currency is read here — amount.minor_amount never enters the verdict, so an ACTIVE wallet with a zero balance comes back ELIGIBLE for any order total. Is that intended? For a stored-value instrument I'd expect the balance check to happen here rather than being left to the caller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we will only check for wallet eligibility and not amount cause the payment can also be performed via split payments

eligibility,
payment_method_details,
status_code: u32::from(item.http_code),
})
}
_ => {
let txn_id = body.transaction_id.map(|t| t.to_string());
Err(error_response_from_qc(
(&body).into(),
txn_id,
item.http_code,
None,
))
}
};
Ok(data)
}
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct QwikcilverErrorResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,7 @@ impl TryFrom<ResponseRouterData<TamaraEligibilityResponse, Self>>
Ok(Self {
response: Ok(PaymentMethodEligibilityResponse {
eligibility,
payment_method_details: None,
status_code: u32::from(item.http_code),
}),
..item.router_data.clone()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -870,7 +870,6 @@ default_impl_payment_method_eligibility_v2!(
PinelabsOnline,
Placetopay,
Powertranz,
Qwikcilver,
Rapyd,
Razorpay,
RazorpayV2,
Expand Down
Loading
Loading