Skip to content
Open
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
117 changes: 117 additions & 0 deletions src/components/modules/ThreeDsMethodModule.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Native module binding for Redsys 3DS method POST.
// On native (iOS/Android), delegates to a native module that performs a hidden WKWebView/WebView form POST.
// On web, uses a hidden iframe + form POST (same approach as the web SDK).

// --- Native module binding ---
type module_ = {
performThreeDsMethodPost: (string, string, string, int) => promise<string>,
}

@val external require: string => module_ = "require"

let nativePerformThreeDsMethodPost = switch try {
require("@juspay-tech/react-native-hyperswitch-3ds-method")->Some
} catch {
| _ => None
} {
| Some(mod) => mod.performThreeDsMethodPost
| None => (_, _, _, _) => Promise.resolve("N")
}

// --- Web fallback: DOM-based hidden iframe + form POST ---
@val @scope("document") external createDomElement: string => Dom.element = "createElement"
@val @scope("document") external domBody: Dom.element = "body"
@send external domAppendChild: (Dom.element, Dom.element) => unit = "appendChild"
@send external domRemoveChild: (Dom.element, Dom.element) => unit = "removeChild"
@set external setId: (Dom.element, string) => unit = "id"
@set external setName: (Dom.element, string) => unit = "name"
@set external setDomValue: (Dom.element, string) => unit = "value"
@set external setInputType: (Dom.element, string) => unit = "type"
@set external setAction: (Dom.element, string) => unit = "action"
@set external setFormMethod: (Dom.element, string) => unit = "method"
@set external setTarget: (Dom.element, string) => unit = "target"
@send external setAttribute: (Dom.element, string, string) => unit = "setAttribute"
@send external submitForm: Dom.element => unit = "submit"
@send external addLoadListener: (Dom.element, @as("load") _, unit => unit) => unit =
"addEventListener"

let performThreeDsMethodWeb = (~url: string, ~data: string, ~methodKey: string, ~timeoutMs: int) => {
Promise.make((resolve, _reject) => {
let isCompleted = ref(false)
let container = ref(None)
let timeoutId = ref(None)

let complete = (indicator: string) => {
if !isCompleted.contents {
isCompleted := true
// Clean up timeout
timeoutId.contents->Option.forEach(id => clearTimeout(id))
// Clean up DOM elements
container.contents->Option.forEach(el => {
try {
domRemoveChild(domBody, el)
} catch {
| _ => ()
}
})
resolve(indicator)
}
}

// Create hidden container div
let containerDiv = createDomElement("div")
setAttribute(containerDiv, "style", "position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;opacity:0;")
container := Some(containerDiv)

// Create hidden iframe
let iframe = createDomElement("iframe")
setId(iframe, "threeDsMethodIframe")
setName(iframe, "threeDsMethodIframe")
setAttribute(iframe, "style", "width:0;height:0;border:none;")
domAppendChild(containerDiv, iframe)

// Create form targeting the iframe
let form = createDomElement("form")
setAction(form, url)
setFormMethod(form, "POST")
setTarget(form, "threeDsMethodIframe")

// Create hidden input with 3DS method data
let input = createDomElement("input")
setInputType(input, "hidden")
setName(input, methodKey)
setDomValue(input, data)
domAppendChild(form, input)

domAppendChild(containerDiv, form)

// Listen for iframe load event BEFORE appending to DOM
// First load fires when blank iframe is attached to DOM, second when POST completes.
let loadCount = ref(0)
addLoadListener(iframe, () => {
loadCount := loadCount.contents + 1
if loadCount.contents >= 2 {
complete("Y")
}
})

// Set timeout -> "N"
timeoutId := Some(setTimeout(() => {
complete("N")
}, timeoutMs))

// NOW append container to body (triggers first load event, caught by listener)
domAppendChild(domBody, containerDiv)

// Submit the form
submitForm(form)
})
}

// --- Platform dispatcher ---
let performThreeDsMethod = (~url, ~data, ~methodKey, ~timeoutMs) => {
switch ReactNative.Platform.os {
| #web => performThreeDsMethodWeb(~url, ~data, ~methodKey, ~timeoutMs)
| _ => nativePerformThreeDsMethodPost(url, data, methodKey, timeoutMs)
}
}
31 changes: 31 additions & 0 deletions src/hooks/AllPaymentHooks.res
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ let useRedirectHook = () => {
let logger = LoggerHook.useLoggerHook()
let baseUrl = GlobalHooks.useGetBaseUrl()()
let handleNativeThreeDS = NetceteraThreeDsHooks.useExternalThreeDs()
let handleRedsys3dsFlow = Redsys3dsHooks.useRedsys3dsFlow()
let getOpenProps = PlaidHelperHook.usePlaidProps()
let redirectionHandler = RedirectionHooks.useRedirectionHelperHook()

Expand Down Expand Up @@ -224,6 +225,35 @@ let useRedirectHook = () => {
}
}

let handleInvokeHiddenIframeFlow = (~nextAction) => {
let action = nextAction->Option.getOr(defaultNextAction)
handleRedsys3dsFlow(
~clientSecret,
~publishableKey,
~nextAction=action,
~responseCallback,
~errorCallback,
~paymentMethod,
~browserRedirectionHandler=(
~clientSecret,
~publishableKey,
~openUrl,
~responseCallback,
~errorCallback,
~paymentMethod,
) => {
browserRedirectionHandler(
~clientSecret,
~publishableKey,
~openUrl,
~responseCallback,
~errorCallback,
~paymentMethod,
)
},
)->ignore
}

let handleDefaultPaymentFlows = (~status, ~reUri, ~error: error) => {
let terminalStatusHandler = () => {status, message: "", code: "", type_: ""}

Expand Down Expand Up @@ -286,6 +316,7 @@ let useRedirectHook = () => {
| "three_ds_invoke" => handleInvokeThreeDSFlow(~nextAction)
| "third_party_sdk_session_token" => handleThirdPartySDKSessionFlow(~nextAction)
| "display_bank_transfer_information" => handleBankTransferFlow(~nextAction)
| "invoke_hidden_iframe" => handleInvokeHiddenIframeFlow(~nextAction)
| _ => handleDefaultPaymentFlows(~status, ~reUri, ~error)
}
}
Expand Down
145 changes: 145 additions & 0 deletions src/hooks/Redsys3dsHooks.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
open PaymentConfirmTypes

let useRedsys3dsFlow = () => {
let (nativeProp, _) = React.useContext(NativePropContext.nativePropContext)
let apiLogWrapper = LoggerHook.useApiLogWrapper()
let baseUrl = GlobalHooks.useGetBaseUrl()()
let logger = LoggerHook.useLoggerHook()

async (
~clientSecret: string,
~publishableKey: string,
~nextAction: nextAction,
~responseCallback: (
~paymentStatus: LoadingContext.sdkPaymentState,
~status: error,
) => unit,
~errorCallback: (~errorMessage: error, ~closeSDK: bool, unit) => unit,
~paymentMethod: string,
~browserRedirectionHandler: (
~clientSecret: string,
~publishableKey: string,
~openUrl: string,
~responseCallback: (
~paymentStatus: LoadingContext.sdkPaymentState,
~status: error,
) => unit,
~errorCallback: (~errorMessage: error, ~closeSDK: bool, unit) => unit,
~paymentMethod: string,
) => promise<promise<unit>>,
) => {
switch nextAction.iframeData {
| Some(iframeData) if iframeData.threeDsMethodUrl !== "" =>
let paymentId = clientSecret->String.split("_secret_")->Array.get(0)->Option.getOr("")

// Step 1: Perform 3DS method POST via native module
logger(
~logType=INFO,
~value="Redsys 3DS method call started",
~category=API,
~eventName=THREE_DS_METHOD_CALL,
(),
)

let indicator = try {
await ThreeDsMethodModule.performThreeDsMethod(
~url=iframeData.threeDsMethodUrl,
~data=iframeData.threeDsMethodData,
~methodKey=iframeData.methodKey,
~timeoutMs=10000,
)
} catch {
| _ => "N" // On any error, fall back to "N"
}

// Step 2: Call complete_authorize
let uri = `${baseUrl}/payments/${paymentId}/complete_authorize`
let headers = Utils.getHeader(publishableKey, nativeProp.hyperParams.appId)
let body =
[
("client_secret", clientSecret->JSON.Encode.string),
("threeds_method_comp_ind", indicator->JSON.Encode.string),
]
->Dict.fromArray
->JSON.Encode.object
->JSON.stringify

logger(
~logType=INFO,
~value=`complete_authorize indicator: ${indicator}`,
~category=API,
~eventName=COMPLETE_AUTHORIZE_CALL_INIT,
(),
)

let jsonResponse = try {
await APIUtils.fetchApiWrapper(
~uri,
~body,
~method=#POST,
~headers,
~eventName=LoggerTypes.COMPLETE_AUTHORIZE_CALL,
~apiLogWrapper,
)
} catch {
| _ => JSON.Encode.null
}

// Step 3: Route response
if jsonResponse == JSON.Encode.null {
errorCallback(~errorMessage=defaultConfirmError, ~closeSDK=true, ())
} else {
let {nextAction: completeNextAction, status, error} =
jsonResponse->Utils.getDictFromJson->itemToObjMapper

switch status {
| "succeeded" =>
responseCallback(
~paymentStatus=LoadingContext.PaymentSuccess,
~status={status: "succeeded", message: "", code: "", type_: ""},
)
| "processing" | "requires_capture" | "requires_confirmation" | "requires_merchant_action" =>
responseCallback(
~paymentStatus=ProcessingPayments,
~status={status, message: "", code: "", type_: ""},
)
| "requires_customer_action" =>
let redirectUrl = completeNextAction.redirectToUrl
if redirectUrl !== "" {
browserRedirectionHandler(
~clientSecret,
~publishableKey,
~openUrl=redirectUrl,
~responseCallback,
~errorCallback,
~paymentMethod,
)->ignore
} else {
errorCallback(
~errorMessage={
status: "failed",
message: "Missing redirect URL for 3DS challenge",
type_: "",
code: "",
},
~closeSDK=true,
(),
)
}
| _ => errorCallback(~errorMessage=error, ~closeSDK=true, ())
}
}
| _ =>
errorCallback(
~errorMessage={
status: "failed",
message: "Missing or invalid iframe data for Redsys 3DS",
type_: "",
code: "",
},
~closeSDK=true,
(),
)
}
}
}
21 changes: 21 additions & 0 deletions src/types/AllApiDataTypes/PaymentConfirmTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,19 @@ type ach_credit_transfer = {
}
type bank_transfer_steps_and_charges_details = {ach_credit_transfer?: ach_credit_transfer}

type iframeData = {
threeDsMethodUrl: string,
threeDsMethodData: string,
methodKey: string,
}

type nextAction = {
redirectToUrl: string,
type_: string,
threeDsData?: threeDsData,
session_token?: sessionToken,
bank_transfer_steps_and_charges_detail?: bank_transfer_steps_and_charges_details,
iframeData?: iframeData,
}
type error = {message?: string, code?: string, type_?: string, status?: string}
type intent = {nextAction: nextAction, status: string, error: error}
Expand Down Expand Up @@ -157,6 +164,11 @@ let getNextAction = (dict, str) => {
->JSON.Decode.object
->Option.getOr(Dict.make())

let iframeDataDict =
json
->Dict.get("iframe_data")
->Option.flatMap(JSON.Decode.object)

{
redirectToUrl: getString(json, "redirect_to_url", ""),
type_: getString(json, "type", ""),
Expand Down Expand Up @@ -185,6 +197,15 @@ let getNextAction = (dict, str) => {
wallet_name: getString(sessionTokenDict, "wallet_name", ""),
open_banking_session_token: getString(sessionTokenDict, "open_banking_session_token", ""),
},
iframeData: ?switch iframeDataDict {
| Some(dict) =>
Some({
threeDsMethodUrl: getString(dict, "three_ds_method_url", ""),
threeDsMethodData: getString(dict, "three_ds_method_data", ""),
methodKey: getString(dict, "method_key", "threeDSMethodData"),
})
| None => None
},
}
})
->Option.getOr(defaultNextAction)
Expand Down
4 changes: 4 additions & 0 deletions src/types/LoggerTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ type eventName =
| ADD_PAYMENT_METHOD_CALL
| SAMSUNG_PAY
| CARD_SCHEME_SELECTION
| COMPLETE_AUTHORIZE_CALL_INIT
| COMPLETE_AUTHORIZE_CALL
| THREE_DS_METHOD_CALL

type logFile = {
timestamp: string,
Expand Down Expand Up @@ -94,6 +97,7 @@ let getApiInitEvent = (event: eventName): option<eventName> => {
| POLL_STATUS_CALL => Some(POLL_STATUS_CALL_INIT)
| DELETE_PAYMENT_METHODS_CALL => Some(DELETE_PAYMENT_METHODS_CALL_INIT)
| ADD_PAYMENT_METHOD_CALL => Some(ADD_PAYMENT_METHOD_CALL_INIT)
| COMPLETE_AUTHORIZE_CALL => Some(COMPLETE_AUTHORIZE_CALL_INIT)
| _ => None
}
}
Loading