From 3e2e6b33ee9a52560f5aab717fa52a779e9e47a4 Mon Sep 17 00:00:00 2001 From: Shivam Date: Fri, 27 Mar 2026 02:29:30 +0530 Subject: [PATCH 1/2] feat: headless methods in ios --- example/src/App.tsx | 92 +++++- example/src/HeadlessScreen.tsx | 289 ++++++++++++++++++ .../src/{PaymentScreen.tsx => UIScreen.tsx} | 6 +- example/src/styles.ts | 45 +++ .../ReactNative/HyperswitchModule.swift | 181 ++++++++++- .../ReactNative/HyperswitchSdkReactNative.mm | 47 ++- .../src/core/Hyper.res | 38 ++- .../src/hooks/useWidget.res | 24 +- .../react-native-hyperswitch/src/index.tsx | 17 +- .../src/modules/NativeHyperswitchSdk.res | 69 ++++- .../specs/NativeHyperswitchSdkReactNative.ts | 7 + .../src/types/HyperTypes.res | 19 ++ .../src/utils/ResponseHandler.res | 90 ++++++ 13 files changed, 888 insertions(+), 36 deletions(-) create mode 100644 example/src/HeadlessScreen.tsx rename example/src/{PaymentScreen.tsx => UIScreen.tsx} (95%) create mode 100644 packages/@juspay-tech/react-native-hyperswitch/src/utils/ResponseHandler.res diff --git a/example/src/App.tsx b/example/src/App.tsx index 8c79f839..9c0e64a8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,10 +1,16 @@ -import { View, Text } from 'react-native'; +import { useState } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { HyperInit, } from '@juspay-tech/react-native-hyperswitch'; -import PaymentScreen from './PaymentScreen'; +import UIScreen from './UIScreen'; +import HeadlessScreen from './HeadlessScreen'; + +type TabType = 'ui' | 'headless'; export default function App() { + const [activeTab, setActiveTab] = useState('ui'); + const publishableKey = process.env.HYPERSWITCH_PUBLISHABLE_KEY; const profileId = process.env.PROFILE_ID; @@ -15,7 +21,7 @@ export default function App() { if (!publishableKey || !profileId) { return ( - + Configure env and restart Metro server ); @@ -23,11 +29,85 @@ export default function App() { if (!hyperPromise) { return ( - + Initializing... ); } - return (); -} \ No newline at end of file + return ( + + + setActiveTab('ui')} + > + + UI Mode + + + setActiveTab('headless')} + > + + Headless Mode + + + + + + {activeTab === 'ui' ? ( + + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + centerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + subText: { + fontSize: 12, + color: '#666', + marginTop: 8, + }, + tabContainer: { + flexDirection: 'row', + backgroundColor: '#f5f5f5', + paddingTop: 50, + borderBottomWidth: 1, + borderBottomColor: '#ddd', + }, + tab: { + flex: 1, + paddingVertical: 16, + alignItems: 'center', + }, + activeTab: { + backgroundColor: '#fff', + borderBottomWidth: 2, + borderBottomColor: '#007AFF', + }, + tabText: { + fontSize: 16, + color: '#666', + fontWeight: '500', + }, + activeTabText: { + color: '#007AFF', + fontWeight: '600', + }, + content: { + flex: 1, + }, +}); diff --git a/example/src/HeadlessScreen.tsx b/example/src/HeadlessScreen.tsx new file mode 100644 index 00000000..f2374665 --- /dev/null +++ b/example/src/HeadlessScreen.tsx @@ -0,0 +1,289 @@ +import { useState, useCallback } from 'react'; +import { View, Text, TextInput, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native'; +import { + initPaymentSession, + type HyperInstance, + type PaymentSession, + type HeadlessResponse, + type SavedPaymentMethod, +} from '@juspay-tech/react-native-hyperswitch'; +import { + initialBaseUrl, + getStatus, + getErrorMessage, +} from './utils'; +import { styles } from './styles'; + +interface HeadlessScreenProps { + hyperPromise: Promise; +} + +// Type alias for convenience +type PaymentMethod = SavedPaymentMethod; + +export default function HeadlessScreen({ hyperPromise }: HeadlessScreenProps) { + const [status, setStatus] = useState(''); + const [message, setMessage] = useState(''); + const [baseURL, setBaseURL] = useState(initialBaseUrl); + const [clientSecret, setClientSecret] = useState(''); + const [paymentSession, setPaymentSession] = useState(null); + const [lastUsedMethod, setLastUsedMethod] = useState(null); + const [defaultMethod, setDefaultMethod] = useState(null); + const [loading, setLoading] = useState(false); + + const createPaymentIntent = useCallback(async (): Promise => { + const response = await fetch(`${baseURL}/create-payment-intent`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to create payment intent'); + } + return data.clientSecret; + }, [baseURL]); + + const initializeSession = async () => { + try { + setLoading(true); + setStatus('Initializing...'); + setMessage(''); + + const secret = await createPaymentIntent(); + setClientSecret(secret); + + + console.log('-- HeadlessScreen:', "Initializing session with secret:", secret); + const session = await initPaymentSession(hyperPromise, secret); + setPaymentSession(session); + + setStatus('Session initialized'); + setMessage('Payment session created successfully'); + } catch (error) { + console.error('Initialization failed:', error); + setStatus('Initialization Error'); + setMessage(getErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const fetchLastUsedMethod = async () => { + if (!paymentSession) { + setStatus('Error'); + setMessage('Please initialize session first'); + return; + } + + try { + setLoading(true); + setStatus('Fetching last used method...'); + + const result = await paymentSession.getCustomerLastUsedPaymentMethodData() as HeadlessResponse; + + if (result.status === 'success' && result.data) { + const paymentMethod = result.data; + setLastUsedMethod(paymentMethod); + setStatus('Last used method fetched'); + setMessage(`Method: ${paymentMethod.payment_method_str}`); + } else { + setLastUsedMethod(null); + setStatus('No method found'); + setMessage(result.message || result.code || 'No saved payment method found'); + } + } catch (error) { + console.error('Fetch last used failed:', error); + setStatus('Error'); + setMessage(getErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const fetchDefaultMethod = async () => { + if (!paymentSession) { + setStatus('Error'); + setMessage('Please initialize session first'); + return; + } + + try { + setLoading(true); + setStatus('Fetching default method...'); + + const result = await paymentSession.getCustomerDefaultSavedPaymentMethodData() as HeadlessResponse; + console.log('-- HeadlessScreen:', 'Default method:', result); + + if (result.status === 'success' && result.data) { + const paymentMethod = result.data; + setDefaultMethod(paymentMethod); + setStatus('Default method fetched'); + setMessage(`Method: ${paymentMethod.payment_method_str}`); + } else { + setDefaultMethod(null); + setStatus('No method found'); + setMessage(result.message || result.code || 'No default payment method found'); + } + } catch (error) { + console.error('Fetch default failed:', error); + setStatus('Error'); + setMessage(getErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const confirmWithLastUsed = async () => { + if (!paymentSession) { + setStatus('Error'); + setMessage('Please initialize session first'); + return; + } + console.log('-- HeadlessScreen:', 'Confirm with last used method called'); + + try { + setLoading(true); + setStatus('Confirming payment...'); + + const result = await paymentSession.confirmWithCustomerLastUsedPaymentMethod() as HeadlessResponse; + console.log('-- HeadlessScreen:', 'Confirm with last used method result:', result); + + setStatus(getStatus(result.status)); + setMessage(result.message || result.status); + } catch (error) { + console.error('Confirm payment failed:', error); + setStatus('Error'); + setMessage(getErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const confirmWithDefault = async () => { + if (!paymentSession) { + setStatus('Error'); + setMessage('Please initialize session first'); + return; + } + + try { + setLoading(true); + setStatus('Confirming payment...'); + + const result = await paymentSession.confirmWithCustomerDefaultPaymentMethod() as HeadlessResponse; + console.log('-- HeadlessScreen:', 'Confirm with default method result:', result); + + setStatus(getStatus(result.status)); + setMessage(result.message || result.status); + } catch (error) { + console.error('Confirm payment failed:', error); + setStatus('Error'); + setMessage(getErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const renderPaymentMethod = (method: PaymentMethod | null, label: string) => { + if (!method) return null; + + const cardDetails = method.card; + + return ( + + {label} + Type: {method.payment_method_str} + {cardDetails && ( + <> + + Card: **** {cardDetails.last4_digits} + + Scheme: {cardDetails.scheme} + + Holder: {cardDetails.card_holder_name} + + + Expires: {cardDetails.expiry_month}/{cardDetails.expiry_year} + + + )} + Last Used: {method.last_used_at} + {method.default_payment_method_set && ( + DEFAULT + )} + + ); + }; + + return ( + + + Headless Mode + + + + + + {loading ? 'Initializing...' : '1. Initialize Session'} + + + + + + {loading ? 'Fetching...' : '2. Get Last Used Method'} + + + + + + {loading ? 'Fetching...' : '3. Get Default Method'} + + + + {renderPaymentMethod(lastUsedMethod, 'Last Used Method')} + {renderPaymentMethod(defaultMethod, 'Default Method')} + + + + {loading ? 'Processing...' : 'Confirm with Last Used'} + + + + + + {loading ? 'Processing...' : 'Confirm with Default'} + + + + {loading && } + + + {status} + {message && {message}} + + + + ); +} diff --git a/example/src/PaymentScreen.tsx b/example/src/UIScreen.tsx similarity index 95% rename from example/src/PaymentScreen.tsx rename to example/src/UIScreen.tsx index 6654c983..0dc11b15 100644 --- a/example/src/PaymentScreen.tsx +++ b/example/src/UIScreen.tsx @@ -14,11 +14,11 @@ import { } from './utils'; import { styles } from './styles'; -interface PaymentScreenProps { +interface UIScreenProps { hyperPromise: Promise; } -export default function PaymentScreen({ hyperPromise }: PaymentScreenProps) { +export default function UIScreen({ hyperPromise }: UIScreenProps) { const [status, setStatus] = useState(null); const [message, setMessage] = useState(null); const [baseURL, setBaseURL] = useState(initialBaseUrl); @@ -49,7 +49,6 @@ export default function PaymentScreen({ hyperPromise }: PaymentScreenProps) { }, [createPaymentIntent]); const checkout = async (): Promise => { - // Checkout logic would go here using useWidget hook console.log('Checkout initiated'); }; @@ -65,6 +64,7 @@ export default function PaymentScreen({ hyperPromise }: PaymentScreenProps) { return ( + UI Mode Void { + self.paymentSessionHandler = handler + } + + @objc + public func getCustomerSavedPaymentMethods( + withResolve resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) -> Void { + self.paymentSession?.getCustomerSavedPaymentMethods(initSavedPaymentMethodSessionCallback) + + resolve(["status": "success", "message": "Payment methods initialized"]) + } + + @objc + public func getCustomerDefaultSavedPaymentMethodData( + withResolve resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) -> Void { + guard let handler = self.paymentSessionHandler else { + resolve([ + "status": "error", + "code": "UNKNOWN", + "message": "Payment session handler not initialized." + ]) + return + } + + let result = handler.getCustomerDefaultSavedPaymentMethodData() + switch result { + case .success(let paymentMethod): + if let jsonData = try? JSONEncoder().encode(paymentMethod), + let jsonDict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + resolve([ + "status": "success", + "message": "Default payment method retrieved", + "data": jsonDict + ]) + } else { + resolve([ + "status": "error", + "code": "ENCODE_ERROR", + "message": "Failed to encode payment method data" + ]) + } + case .failure(let error): + resolve([ + "status": "failed", + "code": error.code, + "message": error.message + ]) + } + } + + @objc + public func getCustomerLastUsedPaymentMethodData( + withResolve resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) -> Void { + guard let handler = self.paymentSessionHandler else { + resolve([ + "status": "error", + "code": "UNKNOWN", + "message": "Payment session handler not initialized." + ]) + return + } + + let result = handler.getCustomerLastUsedPaymentMethodData() + switch result { + case .success(let paymentMethod): + if let jsonData = try? JSONEncoder().encode(paymentMethod), + let jsonDict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + resolve([ + "status": "success", + "message": "Last used payment method retrieved", + "data": jsonDict + ]) + } else { + resolve([ + "status": "error", + "code": "ENCODE_ERROR", + "message": "Failed to encode payment method" + ]) + } + case .failure(let error): + resolve([ + "status": "failed", + "code": error.code, + "message": error.message + ]) + } + } + + @objc + public func confirmWithCustomerDefaultPaymentMethod( + withResolve resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) -> Void { + guard let handler = self.paymentSessionHandler else { + resolve([ + "status": "error", + "code": "UNKNOWN", + "message": "Payment session handler not initialized." + ]) + return + } + + handler.confirmWithCustomerDefaultPaymentMethod { result in + switch result { + case .completed(let data): + resolve([ + "status": "success", + "message": "Payment confirmed successfully", + "data": data + ]) + case .failed(let error as NSError): + resolve([ + "status": "failed", + "code": error.domain, + "message": error.userInfo["message"] as? String ?? "Payment confirmation failed" + ]) + case .canceled(let data): + resolve([ + "status": "cancelled", + "message": "Payment confirmation cancelled", + "data": data + ]) + } + } + } + + @objc + public func confirmWithCustomerLastUsedPaymentMethod( + withResolve resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) -> Void { + guard let handler = self.paymentSessionHandler else { + resolve([ + "status": "error", + "code": "NO_HANDLER", + "message": "Payment session handler not initialized." + ]) + return + } + + handler.confirmWithCustomerLastUsedPaymentMethod { result in + switch result { + case .completed(let data): + resolve([ + "status": "success", + "message": "Payment confirmed successfully", + "data": data + ]) + case .failed(let error as NSError): + resolve([ + "status": "failed", + "code": error.domain, + "message": error.userInfo["message"] as? String ?? "Payment confirmation failed" + ]) + case .canceled(let data): + resolve([ + "status": "cancelled", + "message": "Payment confirmation cancelled", + "data": data + ]) + } + } + } } diff --git a/packages/@juspay-tech/react-native-hyperswitch/ios/Modules/ReactNative/HyperswitchSdkReactNative.mm b/packages/@juspay-tech/react-native-hyperswitch/ios/Modules/ReactNative/HyperswitchSdkReactNative.mm index 398353c3..148368d1 100644 --- a/packages/@juspay-tech/react-native-hyperswitch/ios/Modules/ReactNative/HyperswitchSdkReactNative.mm +++ b/packages/@juspay-tech/react-native-hyperswitch/ios/Modules/ReactNative/HyperswitchSdkReactNative.mm @@ -23,7 +23,7 @@ @implementation HyperswitchSdkReactNative RCT_EXPORT_METHOD(initPaymentSession:(nonnull NSString *)paymentIntentClientSecret resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject) { - + [HyperswitchModule.shared initPaymentSessionWithpaymentIntentClientSecret:paymentIntentClientSecret resolve:resolve reject:reject]; } @@ -34,11 +34,46 @@ @implementation HyperswitchSdkReactNative [HyperswitchModule.shared presentPaymentSheetWithConfiguration:configuration resolver:resolve rejecter:reject]; } +#pragma mark - Headless Payment Methods + +RCT_EXPORT_METHOD(getCustomerSavedPaymentMethods:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [HyperswitchModule.shared getCustomerSavedPaymentMethodsWithResolve:resolve reject:reject]; +} -//- (std::shared_ptr)getTurboModule: -//(const facebook::react::ObjCTurboModule::InitParams &)params -//{ -// return std::make_shared(params); -//} +RCT_EXPORT_METHOD(getCustomerDefaultSavedPaymentMethodData:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [HyperswitchModule.shared getCustomerDefaultSavedPaymentMethodDataWithResolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(getCustomerLastUsedPaymentMethodData:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [HyperswitchModule.shared getCustomerLastUsedPaymentMethodDataWithResolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(confirmWithCustomerDefaultPaymentMethod:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [HyperswitchModule.shared confirmWithCustomerDefaultPaymentMethodWithResolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(confirmWithCustomerLastUsedPaymentMethod:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [HyperswitchModule.shared confirmWithCustomerLastUsedPaymentMethodWithResolve:resolve reject:reject]; +} + ++ (BOOL)requiresMainQueueSetup +{ + return YES; +} + +- (dispatch_queue_t)methodQueue +{ + return dispatch_get_main_queue(); +} @end diff --git a/packages/@juspay-tech/react-native-hyperswitch/src/core/Hyper.res b/packages/@juspay-tech/react-native-hyperswitch/src/core/Hyper.res index 4ba2fb53..b145769d 100644 --- a/packages/@juspay-tech/react-native-hyperswitch/src/core/Hyper.res +++ b/packages/@juspay-tech/react-native-hyperswitch/src/core/Hyper.res @@ -3,7 +3,7 @@ // Provides Hyper.init() function to create a hyper instance with configuration open NativeHyperswitchSdk - +open ResponseHandler type globalConfig = { publishableKey: string, @@ -44,7 +44,8 @@ let parsePaymentResult = (result: 'a): Js.Json.t => { } catch { | _ => let errorObj = Dict.make() - errorObj->Dict.set("status", "failed"->Js.Json.string) + errorObj->Dict.set("status", "error"->Js.Json.string) + errorObj->Dict.set("code", "PARSE_ERROR"->Js.Json.string) errorObj->Dict.set("message", "Failed to parse payment result"->Js.Json.string) errorObj->Js.Json.object_ } @@ -130,3 +131,36 @@ let init = ( Promise.resolve(createHyperInstance()) } +// Initialize payment session +@genType +let initPaymentSession = ( + ~hyperPromise: promise, + ~paymentIntentClientSecret: string, +): promise => { + hyperPromise->Promise.then(_hyperInstance => { + nativeHyperswitchSdk.initPaymentSession(~paymentIntentClientSecret) + ->Promise.then(_initResult => { + nativeHyperswitchSdk.getCustomerSavedPaymentMethods() + ->Promise.then(_savedMethodsResult => { + Promise.resolve({ + getCustomerDefaultSavedPaymentMethodData: () => { + nativeHyperswitchSdk.getCustomerDefaultSavedPaymentMethodData() + ->Promise.thenResolve(parseResponse) + }, + getCustomerLastUsedPaymentMethodData: () => { + nativeHyperswitchSdk.getCustomerLastUsedPaymentMethodData() + ->Promise.thenResolve(parseResponse) + }, + confirmWithCustomerDefaultPaymentMethod: () => { + nativeHyperswitchSdk.confirmWithCustomerDefaultPaymentMethod() + ->Promise.thenResolve(parseResponse) + }, + confirmWithCustomerLastUsedPaymentMethod: () => { + nativeHyperswitchSdk.confirmWithCustomerLastUsedPaymentMethod() + ->Promise.thenResolve(parseResponse) + }, + }: HyperTypes.paymentSession) + }) + }) + }) +} diff --git a/packages/@juspay-tech/react-native-hyperswitch/src/hooks/useWidget.res b/packages/@juspay-tech/react-native-hyperswitch/src/hooks/useWidget.res index 3e2b3ce8..d3b20ebe 100644 --- a/packages/@juspay-tech/react-native-hyperswitch/src/hooks/useWidget.res +++ b/packages/@juspay-tech/react-native-hyperswitch/src/hooks/useWidget.res @@ -62,12 +62,12 @@ let useWidget = (): HyperTypes.widgetController => { let presentPaymentSheet = React.useCallback1( (params: presentPaymentSheetParams) => { if !isReady { - Promise.resolve({ + Promise.resolve(({ error: { code: "failed", message: "Hyperswitch is not initialized", }, - }) + }: presentPaymentSheetResult)) } else { nativeHyperswitchSdk.presentPaymentSheet(params->Obj.magic) ->Promise.then(result => { @@ -115,24 +115,24 @@ let useWidget = (): HyperTypes.widgetController => { type_: typeData, } - let error = { + let error: error = { code, message: errorMessage, } if errorMessage != "" { - Promise.resolve({error, paymentResult}) + Promise.resolve({error, paymentResult: paymentResult}) } else { Promise.resolve({paymentResult: paymentResult}) } }) ->Promise.catch(_err => { - Promise.resolve({ + Promise.resolve(({ error: { code: "failed", message: "Failed to present payment sheet", }, - }) + }: presentPaymentSheetResult)) }) } }, @@ -190,12 +190,12 @@ let useWidgetLegacy = (): useWidgetLegacyResult => { let presentPaymentSheet: presentPaymentSheetFn = React.useCallback1( (params: presentPaymentSheetParams) => { if !isReady { - Promise.resolve({ + Promise.resolve(({ error: { code: "failed", message: "Hyperswitch is not initialized", }, - }) + }: presentPaymentSheetResult)) } else { nativeHyperswitchSdk.presentPaymentSheet(params->Obj.magic) ->Promise.then(result => { @@ -243,24 +243,24 @@ let useWidgetLegacy = (): useWidgetLegacyResult => { type_: typeData, } - let error = { + let error: error = { code, message: errorMessage, } if errorMessage != "" { - Promise.resolve({error, paymentResult}) + Promise.resolve({error, paymentResult: paymentResult}) } else { Promise.resolve({paymentResult: paymentResult}) } }) ->Promise.catch(_err => { - Promise.resolve({ + Promise.resolve(({ error: { code: "failed", message: "Failed to present payment sheet", }, - }) + }: presentPaymentSheetResult)) }) } }, diff --git a/packages/@juspay-tech/react-native-hyperswitch/src/index.tsx b/packages/@juspay-tech/react-native-hyperswitch/src/index.tsx index 1aeb7f33..4b3168ef 100644 --- a/packages/@juspay-tech/react-native-hyperswitch/src/index.tsx +++ b/packages/@juspay-tech/react-native-hyperswitch/src/index.tsx @@ -4,8 +4,14 @@ // Hyper.init - Initialize the SDK with publishable key and profile id export { init as HyperInit, + initPaymentSession, } from './core/Hyper.gen'; +// PaymentSession type +export type { + paymentSession as PaymentSession, +} from './types/HyperTypes.gen'; + // HyperElements - Context provider component export { make as HyperElements, @@ -30,7 +36,6 @@ export type { } from './modules/NativeHyperswitchSdk.gen'; export type { - hyperElementsContextData as HyperElementsContextData, widgetController as WidgetController, } from './types/HyperTypes.gen'; @@ -44,6 +49,14 @@ export type { export type { hyperInstance as HyperInstance, -} from './core/Hyper.gen'; +} from './types/HyperTypes.gen'; + +// Headless Payment Response Types +export type { + headlessResponse as HeadlessResponse, + headlessResponseStatus as HeadlessResponseStatus, + cardDetails as CardDetails, + savedPaymentMethod as SavedPaymentMethod, +} from './modules/NativeHyperswitchSdk.gen'; diff --git a/packages/@juspay-tech/react-native-hyperswitch/src/modules/NativeHyperswitchSdk.res b/packages/@juspay-tech/react-native-hyperswitch/src/modules/NativeHyperswitchSdk.res index 79cfb415..0d530195 100644 --- a/packages/@juspay-tech/react-native-hyperswitch/src/modules/NativeHyperswitchSdk.res +++ b/packages/@juspay-tech/react-native-hyperswitch/src/modules/NativeHyperswitchSdk.res @@ -25,6 +25,9 @@ type initPaymentSessionResult = {error?: string} @genType type presentPaymentSheetParams = PaymentSheetConfiguration.options +@genType +type presentPaymentSheet = presentPaymentSheetParams => promise + type status = | @as("succeeded") Completed | @as("cancelled") Canceled @@ -50,8 +53,66 @@ type presentPaymentSheetResult = { paymentResult?: paymentResult } +// Card details for saved payment methods @genType -type presentPaymentSheet = presentPaymentSheetParams => promise +type cardDetails = { + expiry_year: string, + card_issuer: string, + expiry_month: string, + nick_name: string, + last4_digits: string, + card_holder_name: string, + card_network: string, + card_isin: string, + scheme: string, + issuer_country: string, + card_type: string, + saved_to_locker: bool, +} + +// Saved payment method data structure +@genType +type savedPaymentMethod = { + card?: cardDetails, + requires_cvv: bool, + payment_method_str: string, + payment_method_type: string, + payment_experience: array, + default_payment_method_set: bool, + recurring_enabled: bool, + payment_method_issuer: string, + last_used_at: string, + installment_payment_enabled: bool, + payment_method_id: string, + customer_id: string, + payment_token: string, + created: string, +} + +// Headless payment response types +@genType +type headlessResponseStatus = + | @as("succeeded") Succeeded + | @as("requires_action") RequiresAction + | @as("requires_confirmation") RequiresConfirmation + | @as("requires_customer_action") RequiresCustomerAction + | @as("failed") Failed + +@genType +type headlessResponse = { + status: headlessResponseStatus, + message?: string, + error?: error, + paymentIntentId?: string, + clientSecret?: string, +} + +// Headless Payment Method types +type getCustomerSavedPaymentMethods = unit => promise +type getCustomerDefaultSavedPaymentMethodData = unit => promise +type getCustomerLastUsedPaymentMethodData = unit => promise +type confirmWithCustomerDefaultPaymentMethod = unit => promise +type confirmWithCustomerLastUsedPaymentMethod = unit => promise type nativeHyperswitchSdk = { initialise: initialise, @@ -62,6 +123,12 @@ type nativeHyperswitchSdk = { confirmCardPayment: confirmCardPayment, retrievePaymentIntent: retrievePaymentIntent, completeUpdateIntent: completeUpdateIntent, + // Headless Payment Methods + getCustomerSavedPaymentMethods: getCustomerSavedPaymentMethods, + getCustomerDefaultSavedPaymentMethodData: getCustomerDefaultSavedPaymentMethodData, + getCustomerLastUsedPaymentMethodData: getCustomerLastUsedPaymentMethodData, + confirmWithCustomerDefaultPaymentMethod: confirmWithCustomerDefaultPaymentMethod, + confirmWithCustomerLastUsedPaymentMethod: confirmWithCustomerLastUsedPaymentMethod, } @module("../specs/NativeHyperswitchSdkReactNative") diff --git a/packages/@juspay-tech/react-native-hyperswitch/src/specs/NativeHyperswitchSdkReactNative.ts b/packages/@juspay-tech/react-native-hyperswitch/src/specs/NativeHyperswitchSdkReactNative.ts index 4db037e1..795e802d 100644 --- a/packages/@juspay-tech/react-native-hyperswitch/src/specs/NativeHyperswitchSdkReactNative.ts +++ b/packages/@juspay-tech/react-native-hyperswitch/src/specs/NativeHyperswitchSdkReactNative.ts @@ -10,6 +10,13 @@ export interface Spec extends TurboModule { ): Promise; initPaymentSession(paymentIntentClientSecret: string): Promise; presentPaymentSheet(configuration: Object): Promise; + + // Headless Payment Methods + getCustomerSavedPaymentMethods(): Promise; + getCustomerDefaultSavedPaymentMethodData(): Promise; + getCustomerLastUsedPaymentMethodData(): Promise; + confirmWithCustomerDefaultPaymentMethod(): Promise; + confirmWithCustomerLastUsedPaymentMethod(): Promise; } export default TurboModuleRegistry.getEnforcing( diff --git a/packages/@juspay-tech/react-native-hyperswitch/src/types/HyperTypes.res b/packages/@juspay-tech/react-native-hyperswitch/src/types/HyperTypes.res index 8bc40b30..19e37a9e 100644 --- a/packages/@juspay-tech/react-native-hyperswitch/src/types/HyperTypes.res +++ b/packages/@juspay-tech/react-native-hyperswitch/src/types/HyperTypes.res @@ -20,3 +20,22 @@ type widgetController = { isLoading: bool, isReady: bool, } + +// Headless Payment Response Types +@genType +// TODO: Expand for all the native layer api responses. +type headlessResponse = { + status: string, + message: string, + code?: string, + data?: Js.Json.t, +} + +// UsePaymentSession response type +@genType +type paymentSession = { + getCustomerDefaultSavedPaymentMethodData: unit => promise, + getCustomerLastUsedPaymentMethodData: unit => promise, + confirmWithCustomerDefaultPaymentMethod: unit => promise, + confirmWithCustomerLastUsedPaymentMethod: unit => promise, +} diff --git a/packages/@juspay-tech/react-native-hyperswitch/src/utils/ResponseHandler.res b/packages/@juspay-tech/react-native-hyperswitch/src/utils/ResponseHandler.res new file mode 100644 index 00000000..a5c607cf --- /dev/null +++ b/packages/@juspay-tech/react-native-hyperswitch/src/utils/ResponseHandler.res @@ -0,0 +1,90 @@ +// ResponseHandler.res +// Utility module for handling standardized responses from native layer + +// Parse JSON string response into headlessResponse type +// Handles both string JSON and object inputs from native SDK +let parseResponse = (result: 'a): HyperTypes.headlessResponse => { + try { + let json = switch Js.typeof(result) { + | "string" => Js.Json.parseExn(result->Obj.magic) + | _ => result->Obj.magic + } + switch Js.Json.decodeObject(json) { + | Some(dict) => + let status = + dict + ->Js.Dict.get("status") + ->Belt.Option.flatMap(Js.Json.decodeString) + ->Belt.Option.getWithDefault("error") + + let message = + dict + ->Js.Dict.get("message") + ->Belt.Option.flatMap(Js.Json.decodeString) + ->Belt.Option.getWithDefault("Unknown error") + + let code = dict->Js.Dict.get("code") + + let data = dict->Js.Dict.get("data") + + // Build the record - optional fields need special handling + switch (code, data) { + | (Some(codeJson), Some(dataJson)) => + switch (Js.Json.decodeString(codeJson), Js.Json.decodeObject(dataJson)) { + | (Some(codeStr), Some(_)) => {status, message, code: codeStr, data: dataJson} + | (Some(codeStr), None) => {status, message, code: codeStr, data: dataJson} + | (None, Some(_)) => {status, message, data: dataJson} + | (None, None) => {status, message, data: dataJson} + } + | (Some(codeJson), None) => + switch Js.Json.decodeString(codeJson) { + | Some(codeStr) => {status, message, code: codeStr} + | None => {status, message} + } + | (None, Some(dataJson)) => {status, message, data: dataJson} + | (None, None) => {status, message} + } + | None => { + status: "error", + message: "Invalid response format", + code: "PARSE_ERROR", + } + } + } catch { + | _ => { + status: "error", + message: "Failed to parse response", + code: "PARSE_ERROR", + } + } +} + +// Check if response is successful +let isSuccess = (response: HyperTypes.headlessResponse): bool => { + response.status == "success" +} + +// Check if response indicates failure +let isFailed = (response: HyperTypes.headlessResponse): bool => { + response.status == "failed" +} + +// Check if response indicates cancellation +let isCancelled = (response: HyperTypes.headlessResponse): bool => { + response.status == "cancelled" +} + +// Check if response indicates an error +let isError = (response: HyperTypes.headlessResponse): bool => { + response.status == "error" +} + +// Get data from response with type safety +let getData = (response: HyperTypes.headlessResponse): option => { + response.data +} + +// Get code from response with type safety +let getCode = (response: HyperTypes.headlessResponse): option => { + response.code +} From 7209f76ceb67630f5415d28ff7d536dd3840e040 Mon Sep 17 00:00:00 2001 From: Shivam Date: Wed, 1 Apr 2026 02:29:27 +0530 Subject: [PATCH 2/2] chore: formatting changes --- .../Modules/ReactNative/HyperswitchModule.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/@juspay-tech/react-native-hyperswitch/ios/Modules/ReactNative/HyperswitchModule.swift b/packages/@juspay-tech/react-native-hyperswitch/ios/Modules/ReactNative/HyperswitchModule.swift index 1d238eda..bb93dc38 100644 --- a/packages/@juspay-tech/react-native-hyperswitch/ios/Modules/ReactNative/HyperswitchModule.swift +++ b/packages/@juspay-tech/react-native-hyperswitch/ios/Modules/ReactNative/HyperswitchModule.swift @@ -69,8 +69,7 @@ public class HyperswitchModule: NSObject { @objc public func getCustomerSavedPaymentMethods( withResolve resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock - ) -> Void { + reject: @escaping RCTPromiseRejectBlock) -> Void { self.paymentSession?.getCustomerSavedPaymentMethods(initSavedPaymentMethodSessionCallback) resolve(["status": "success", "message": "Payment methods initialized"]) @@ -79,8 +78,7 @@ public class HyperswitchModule: NSObject { @objc public func getCustomerDefaultSavedPaymentMethodData( withResolve resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock - ) -> Void { + reject: @escaping RCTPromiseRejectBlock) -> Void { guard let handler = self.paymentSessionHandler else { resolve([ "status": "error", @@ -119,8 +117,7 @@ public class HyperswitchModule: NSObject { @objc public func getCustomerLastUsedPaymentMethodData( withResolve resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock - ) -> Void { + reject: @escaping RCTPromiseRejectBlock) -> Void { guard let handler = self.paymentSessionHandler else { resolve([ "status": "error", @@ -159,8 +156,7 @@ public class HyperswitchModule: NSObject { @objc public func confirmWithCustomerDefaultPaymentMethod( withResolve resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock - ) -> Void { + reject: @escaping RCTPromiseRejectBlock) -> Void { guard let handler = self.paymentSessionHandler else { resolve([ "status": "error", @@ -197,8 +193,7 @@ public class HyperswitchModule: NSObject { @objc public func confirmWithCustomerLastUsedPaymentMethod( withResolve resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock - ) -> Void { + reject: @escaping RCTPromiseRejectBlock) -> Void { guard let handler = self.paymentSessionHandler else { resolve([ "status": "error",