diff --git a/android/src/main/java/jp/pay/reactnative/PayjpReactNativePackage.kt b/android/src/main/java/jp/pay/reactnative/PayjpReactNativePackage.kt index 1b5a1492..2ac23451 100644 --- a/android/src/main/java/jp/pay/reactnative/PayjpReactNativePackage.kt +++ b/android/src/main/java/jp/pay/reactnative/PayjpReactNativePackage.kt @@ -31,9 +31,11 @@ import com.facebook.react.uimanager.ViewManager class PayjpReactNativePackage : ReactPackage { override fun createNativeModules(reactContext: ReactApplicationContext): List { val cardFormModule = PayjpCardFormModule(reactContext) + val threeDSecureProcessHandlerModule = PayjpThreeDSecureProcessHandlerModule(reactContext) return listOf( PayjpModule(reactContext, cardFormModule), - cardFormModule + cardFormModule, + threeDSecureProcessHandlerModule ) } diff --git a/android/src/main/java/jp/pay/reactnative/PayjpThreeDSecureProcessHandlerModule.kt b/android/src/main/java/jp/pay/reactnative/PayjpThreeDSecureProcessHandlerModule.kt new file mode 100644 index 00000000..116faba3 --- /dev/null +++ b/android/src/main/java/jp/pay/reactnative/PayjpThreeDSecureProcessHandlerModule.kt @@ -0,0 +1,83 @@ +package jp.pay.reactnative + +import android.app.Activity +import android.content.Intent +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.Arguments +import com.facebook.react.module.annotations.ReactModule +import jp.pay.android.verifier.PayjpVerifier +import jp.pay.android.verifier.ui.PayjpThreeDSecureResult +import jp.pay.android.verifier.ui.PayjpThreeDSecureResultCallback + + +@ReactModule(name = PayjpThreeDSecureProcessHandlerModule.MODULE_NAME) +class PayjpThreeDSecureProcessHandlerModule( + private val reactContext: ReactApplicationContext +) : ReactContextBaseJavaModule(reactContext), ActivityEventListener { + + companion object { + const val MODULE_NAME = "RNPAYThreeDSecureProcessHandler" + } + + private var pendingPromise: Promise? = null + + override fun getName(): String = MODULE_NAME + + init { + reactContext.addActivityEventListener(this) + } + + @ReactMethod + fun startThreeDSecureProcess(resourceId: String, promise: Promise) { + val activity: Activity? = reactContext.currentActivity + if (activity == null) { + promise.reject("NO_ACTIVITY", "Current activity is null") + return + } + if (pendingPromise != null) { + promise.reject("PENDING_OPERATION", "Another 3DS process is already in progress.") + return + } + this.pendingPromise = promise + PayjpVerifier.startThreeDSecureFlow(resourceId, activity) + } + + override fun onActivityResult( + activity: Activity, + requestCode: Int, + resultCode: Int, + data: Intent? + ) { + pendingPromise?.let { promise -> + PayjpVerifier.handleThreeDSecureResult(requestCode, object : PayjpThreeDSecureResultCallback { + override fun onResult(result: PayjpThreeDSecureResult) { + when (result) { + is PayjpThreeDSecureResult.SuccessResourceId -> { + val resultMap = Arguments.createMap().apply { + putString("status", "completed") + } + promise.resolve(resultMap) + } + PayjpThreeDSecureResult.Canceled -> { + val resultMap = Arguments.createMap().apply { + putString("status", "canceled") + } + promise.resolve(resultMap) + } + else -> { + promise.reject("THREE_D_SECURE_FAILED", "Unknown ThreeDSecure result.") + } + } + pendingPromise = null + } + }) + } + } + + override fun onNewIntent(intent: Intent?) {} +} diff --git a/example/app.json b/example/app.json index 7c929098..9355a8bb 100644 --- a/example/app.json +++ b/example/app.json @@ -5,7 +5,7 @@ "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", - "scheme": "myapp", + "scheme": "jp.pay.example", "userInterfaceStyle": "automatic", "splash": { "image": "./assets/images/splash.png", @@ -14,7 +14,7 @@ }, "ios": { "supportsTablet": true, - "bundleIdentifier": "com.anonymous.PAYJPExample", + "bundleIdentifier": "jp.pay.example.PAYJPExample", "infoPlist": { "NSCameraUsageDescription": "This app uses the camera to scan QR codes." }, @@ -29,7 +29,7 @@ "foregroundImage": "./assets/images/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "package": "com.anonymous.PAYJPExample" + "package": "jp.pay.example.PAYJPExample" }, "web": { "bundler": "metro", @@ -37,7 +37,12 @@ "favicon": "./assets/images/favicon.png" }, "plugins": [ - "expo-router", + [ + "expo-router", + { + "origin": "jp.pay.example://" + } + ], "@config-plugins/detox", "./config/add-android-dependencies", "./config/payjp-three-d-secure" diff --git a/example/app/(tabs)/_layout.tsx b/example/app/(tabs)/_layout.tsx index 7602ce75..8bf3835c 100644 --- a/example/app/(tabs)/_layout.tsx +++ b/example/app/(tabs)/_layout.tsx @@ -33,6 +33,21 @@ export default function TabLayout() { ), }} /> + ( + + ), + }} + /> + ); } diff --git a/example/app/(tabs)/index.tsx b/example/app/(tabs)/index.tsx index b0d5ba9d..97eb4c36 100644 --- a/example/app/(tabs)/index.tsx +++ b/example/app/(tabs)/index.tsx @@ -54,6 +54,7 @@ export default function HomeScreen() { PayjpCardForm.startCardForm({ cardFormType: formType, extraAttributes: selectedOption.attributes, + useThreeDSecure: true, }); }, ); diff --git a/example/app/(tabs)/tds/finish.tsx b/example/app/(tabs)/tds/finish.tsx new file mode 100644 index 00000000..6cbab3b8 --- /dev/null +++ b/example/app/(tabs)/tds/finish.tsx @@ -0,0 +1,67 @@ +import { StyleSheet, TouchableOpacity } from 'react-native'; +import { ThemedText } from '@/components/ThemedText'; +import { ThemedView } from '@/components/ThemedView'; +import React from 'react'; +import { useRouter } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function ThreeDSecureFinishScreen() { + const router = useRouter(); + + const handleReturn = () => { + router.replace('/'); + }; + + return ( + + + + 3Dセキュア認証が終了しました。 + + この結果をサーバーサイドに伝え、完了処理や結果のハンドリングをおこなってください。 + + + 戻る + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + paddingTop: 15, + justifyContent: 'center', + }, + resultContainer: { + gap: 20, + alignItems: 'center', + justifyContent: 'center', + flex: 1, + paddingVertical: 40, + }, + button: { + backgroundColor: '#4287f5', + padding: 14, + borderRadius: 8, + alignItems: 'center', + marginVertical: 8, + minWidth: 200, + }, + buttonText: { + color: '#fff', + fontWeight: 'bold', + fontSize: 16, + }, + resultText: { + fontSize: 18, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 8, + }, +}); diff --git a/example/app/(tabs)/tds/index.tsx b/example/app/(tabs)/tds/index.tsx new file mode 100644 index 00000000..1cf48300 --- /dev/null +++ b/example/app/(tabs)/tds/index.tsx @@ -0,0 +1,159 @@ +import { StyleSheet, TextInput, TouchableOpacity, View, ScrollView, SafeAreaView } from 'react-native'; +import { ThemedText } from '@/components/ThemedText'; +import { ThemedView } from '@/components/ThemedView'; +import React, { useState } from 'react'; +import { PayjpThreeDSecure } from 'payjp-react-native'; +import { Linking } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { useRouter } from 'expo-router'; + +export default function ThreeDSecureScreen() { + const [resourceId, setResourceId] = useState(''); + const [resultMessage, setResultMessage] = useState(''); + const router = useRouter(); + + const startThreeDSecure = async () => { + try { + if (!resourceId) { + setResultMessage('リソースIDを入力してください'); + return; + } + + setResultMessage('3Dセキュア認証を開始します...'); + await PayjpThreeDSecure.startThreeDSecureProcess( + resourceId, + status => { + if (status === PayjpThreeDSecure.ThreeDSecureProcessStatus.COMPLETED) { + setResultMessage('3Dセキュア認証が完了しました'); + router.push('/tds/finish'); + } else if (status === PayjpThreeDSecure.ThreeDSecureProcessStatus.CANCELED) { + setResultMessage('3Dセキュア認証がキャンセルされました'); + } + }, + (error: { message: string; code: number }) => { + console.error('3Dセキュア認証が失敗しました', error); + setResultMessage(`エラー: ${error.message}`); + }, + ); + } catch (e: any) { + console.error('The 3D Secure process promise was rejected:', e); + } + }; + + const handleChargeTDSLink = () => { + Linking.openURL('https://pay.jp/docs/charge-tds'); + }; + + const handleCustomerCardTDSLink = () => { + Linking.openURL('https://pay.jp/docs/customer-card-tds'); + }; + + return ( + + + + + 3Dセキュア + + + + + + 3Dセキュア開始 + + + {resultMessage ? {resultMessage} : null} + + 1. 下記を参考に、先にサーバーサイドで支払い、または3Dセキュアリクエストを作成してください。 + + + + 支払い作成時の3Dセキュア: + + https://pay.jp/docs/charge-tds + + + + + 顧客カードに対する3Dセキュア: + + https://pay.jp/docs/customer-card-tds + + + + + 2. 作成したリソースのIDを上記に入力して3Dセキュアを開始してください。 + + + + 3. + 立ち上がった画面が閉じ、認証が終了したら、ドキュメントを参考にサーバーサイドにて結果を確認してください。 + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + paddingTop: 15, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 16, + }, + formContainer: { + gap: 20, + }, + instruction: { + fontSize: 15, + lineHeight: 22, + marginBottom: 4, + }, + linkContainer: { + marginBottom: 8, + }, + linkLabel: { + marginBottom: 4, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 12, + backgroundColor: '#fff', + fontSize: 14, + marginVertical: 8, + }, + button: { + backgroundColor: '#4287f5', + padding: 14, + borderRadius: 8, + alignItems: 'center', + marginVertical: 8, + }, + buttonText: { + color: '#fff', + fontWeight: 'bold', + fontSize: 16, + }, + urlText: { + color: '#4287f5', + textDecorationLine: 'underline', + }, + statusText: { + marginTop: 8, + color: '#f44336', + }, +}); diff --git a/example/yarn.lock b/example/yarn.lock index 4fc66130..1d0063c5 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -7167,7 +7167,7 @@ path-type@^4.0.0: integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== payjp-react-native@../: - version "0.8.4" + version "0.9.0" picocolors@^1.0.0, picocolors@^1.0.1: version "1.0.1" diff --git a/ios/Classes/RNPAY.m b/ios/Classes/RNPAY.m index 8e94246a..21b574e9 100644 --- a/ios/Classes/RNPAY.m +++ b/ios/Classes/RNPAY.m @@ -23,4 +23,4 @@ #import "RNPAY.h" NSString *const RNPAYErrorDomain = @"RNPAYErrorDomain"; -NSString *const RNPAYPluginVersion = @"0.8.6"; +NSString *const RNPAYPluginVersion = @"0.9.0"; diff --git a/ios/Classes/RNPAYThreeDSecureProcessHandler.h b/ios/Classes/RNPAYThreeDSecureProcessHandler.h new file mode 100644 index 00000000..52bc9a57 --- /dev/null +++ b/ios/Classes/RNPAYThreeDSecureProcessHandler.h @@ -0,0 +1,30 @@ +/* + * + * Copyright (c) 2020 PAY, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +#import +#import +@import PAYJP; + +@interface RNPAYThreeDSecureProcessHandler + : RCTEventEmitter + +@end diff --git a/ios/Classes/RNPAYThreeDSecureProcessHandler.m b/ios/Classes/RNPAYThreeDSecureProcessHandler.m new file mode 100644 index 00000000..3cf43eb2 --- /dev/null +++ b/ios/Classes/RNPAYThreeDSecureProcessHandler.m @@ -0,0 +1,102 @@ +/* + * + * Copyright (c) 2020 PAY, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +#import "RNPAYThreeDSecureProcessHandler.h" +@import PAYJP; + +@interface RNPAYThreeDSecureProcessHandler () + +@property(nonatomic, strong) RCTPromiseResolveBlock pendingResolve; +@property(nonatomic, strong) RCTPromiseRejectBlock pendingReject; + +@end + +@implementation RNPAYThreeDSecureProcessHandler + +- (dispatch_queue_t)methodQueue { + return dispatch_get_main_queue(); +} + +RCT_EXPORT_MODULE() + +RCT_EXPORT_METHOD(startThreeDSecureProcess : (NSString *)resourceId resolve : ( + RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { + if (self.pendingResolve != nil || self.pendingReject != nil) { + reject(@"PENDING_OPERATION", @"Another 3DS process is already in progress.", nil); + return; + } + + self.pendingResolve = resolve; + self.pendingReject = reject; + + __weak typeof(self) wself = self; + dispatch_async([self methodQueue], ^{ + PAYJPThreeDSecureProcessHandler *handler = [PAYJPThreeDSecureProcessHandler sharedHandler]; + + UIViewController *hostViewController = + UIApplication.sharedApplication.keyWindow.rootViewController; + while (hostViewController.presentedViewController) { + hostViewController = hostViewController.presentedViewController; + } + if ([hostViewController isKindOfClass:[UINavigationController class]]) { + UINavigationController *navigationController = (UINavigationController *)hostViewController; + hostViewController = navigationController.visibleViewController; + } + + [handler startThreeDSecureProcessWithViewController:hostViewController + delegate:wself + resourceId:resourceId]; + }); +} + +#pragma mark - ThreeDSecureProcessHandlerDelegate + +- (void)threeDSecureProcessHandlerDidFinish:(PAYJPThreeDSecureProcessHandler *)handler + status:(ThreeDSecureProcessStatus)status { + if (self.pendingResolve == nil && self.pendingReject == nil) { + return; + } + + switch (status) { + case ThreeDSecureProcessStatusCompleted: + if (self.pendingResolve) { + self.pendingResolve(@{@"status" : @"completed"}); + } + break; + case ThreeDSecureProcessStatusCanceled: + if (self.pendingResolve) { + self.pendingResolve(@{@"status" : @"canceled"}); + } + break; + default: + if (self.pendingReject) { + self.pendingReject(@"THREE_D_SECURE_FAILED", + @"ThreeDSecure process failed with unknown status.", nil); + } + break; + } + + self.pendingResolve = nil; + self.pendingReject = nil; +} + +@end diff --git a/package.json b/package.json index 7bfc7bc1..4c801cf0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "payjp-react-native", "description": "A React Native plugin for PAY.JP SDK", - "version": "0.8.6", + "version": "0.9.0", "author": { "name": "PAY.JP", "email": "support@pay.jp", diff --git a/src/ThreeDSecure.ts b/src/ThreeDSecure.ts new file mode 100644 index 00000000..b8123ba9 --- /dev/null +++ b/src/ThreeDSecure.ts @@ -0,0 +1,56 @@ +// LICENSE : MIT +import { NativeModules } from 'react-native'; + +export enum ThreeDSecureProcessStatus { + COMPLETED = 'completed', + + CANCELED = 'canceled', +} + +/** + * 3Dセキュア処理が成功したときに実行されるリスナー + * + * @param status 処理の結果状態 + */ +export type OnThreeDSecureProcessSucceeded = (status: ThreeDSecureProcessStatus) => void; + +/** + * 3Dセキュア処理が失敗したときに実行されるリスナー + * + * @param error エラー情報 + */ +export type OnThreeDSecureProcessFailed = (error: { message: string; code: number }) => void; + +const { RNPAYThreeDSecureProcessHandler } = NativeModules; + +/** + * リソースIDを使用して3Dセキュア処理を開始します。 + * 「支払い時」「顧客カードに対する3Dセキュア」の両方に対応しています。 + * + * @param resourceId charge_xxx(支払い時)またはcus_xxx_car_xxx(顧客カード)形式のリソースID + * @param onSucceeded 3Dセキュア処理が成功したときに実行されるコールバック + * @param onFailed 3Dセキュア処理が失敗したときに実行されるコールバック + */ +export const startThreeDSecureProcess = async ( + resourceId: string, + onSucceeded: OnThreeDSecureProcessSucceeded, + onFailed: OnThreeDSecureProcessFailed, +): Promise => { + try { + const result = await RNPAYThreeDSecureProcessHandler.startThreeDSecureProcess(resourceId); + + if (result.status === 'completed') { + onSucceeded(ThreeDSecureProcessStatus.COMPLETED); + } else if (result.status === 'canceled') { + onSucceeded(ThreeDSecureProcessStatus.CANCELED); + } else { + const errorMessage = `Unknown status: ${result.status}`; + const errorPayload = { message: errorMessage, code: 1 }; + onFailed(errorPayload); + } + } catch (nativeError: any) { + const errorMessage = `Native Code: ${nativeError.code}, Message: ${nativeError.message || 'Unknown error'}`; + const errorPayload = { message: errorMessage, code: 1 }; + onFailed(errorPayload); + } +}; diff --git a/src/index.ts b/src/index.ts index d5c03926..16ee3d64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import * as PayjpApplePay from './ApplePay'; import * as PayjpCardForm from './CardForm'; import * as PayjpCore from './Core'; +import * as PayjpThreeDSecure from './ThreeDSecure'; -export { PayjpCore, PayjpCardForm, PayjpApplePay }; +export { PayjpCore, PayjpCardForm, PayjpApplePay, PayjpThreeDSecure }; export * from './models'; diff --git a/test/ThreeDSecure.test.ts b/test/ThreeDSecure.test.ts new file mode 100644 index 00000000..8c9afe8e --- /dev/null +++ b/test/ThreeDSecure.test.ts @@ -0,0 +1,134 @@ +// LICENSE : MIT +import type * as PayjpThreeDSecureType from '../src/ThreeDSecure'; + +let currentMockEmitterInstance: { + listeners: Record void>; + removers: Record; + addListener: jest.Mock; + _simulateEvent: jest.Mock; + _getRemoveMockForEvent: jest.Mock; +} | null = null; + +const mockRNPAYThreeDSecureProcessHandler = { + startThreeDSecureProcess: jest.fn(), +}; + +jest.mock('react-native', () => { + const NativeEventEmitterMock = jest.fn().mockImplementation(() => { + const instance = { + listeners: {} as Record void>, + removers: {} as Record, + addListener: jest.fn(function (this: any, eventName: string, callback: (...args: any[]) => void) { + this.listeners[eventName] = callback; + const remover = { + remove: jest.fn(() => {}), + }; + this.removers[eventName] = remover; + return remover; + }), + _simulateEvent: jest.fn(function (this: any, eventName: string, ...args: any[]) { + if (typeof this.listeners[eventName] === 'function') { + this.listeners[eventName](...args); + } + }), + _getRemoveMockForEvent: jest.fn(function (this: any, eventName: string): jest.Mock | undefined { + return this.removers[eventName]?.remove; + }), + }; + currentMockEmitterInstance = instance; + return instance; + }); + + return { + NativeEventEmitter: NativeEventEmitterMock, + NativeModules: { + RNPAYThreeDSecureProcessHandler: mockRNPAYThreeDSecureProcessHandler, + }, + }; +}); + +let PayjpThreeDSecure: typeof PayjpThreeDSecureType; + +describe('PayjpThreeDSecure', () => { + beforeEach(() => { + jest.resetModules(); + currentMockEmitterInstance = null; + mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess.mockClear(); + PayjpThreeDSecure = require('../src/ThreeDSecure'); + const emitter = currentMockEmitterInstance as any; + if (emitter && emitter.addListener) { + emitter.addListener.mockClear(); + } + }); + + describe('startThreeDSecureProcess', () => { + it('calls native method with resourceId and handles completed status', async () => { + const resourceId = 'charge_xxx'; + const onSucceeded = jest.fn(); + const onFailed = jest.fn(); + + mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess.mockResolvedValueOnce({ + status: 'completed', + }); + + await PayjpThreeDSecure.startThreeDSecureProcess(resourceId, onSucceeded, onFailed); + + expect(mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess).toHaveBeenCalledTimes(1); + expect(mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess).toHaveBeenCalledWith(resourceId); + expect(onSucceeded).toHaveBeenCalledTimes(1); + expect(onSucceeded).toHaveBeenCalledWith(PayjpThreeDSecure.ThreeDSecureProcessStatus.COMPLETED); + expect(onFailed).not.toHaveBeenCalled(); + }); + + it('handles canceled status properly', async () => { + const resourceId = 'charge_xxx'; + const onSucceeded = jest.fn(); + const onFailed = jest.fn(); + + mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess.mockResolvedValueOnce({ + status: 'canceled', + }); + + await PayjpThreeDSecure.startThreeDSecureProcess(resourceId, onSucceeded, onFailed); + + expect(onSucceeded).toHaveBeenCalledTimes(1); + expect(onSucceeded).toHaveBeenCalledWith(PayjpThreeDSecure.ThreeDSecureProcessStatus.CANCELED); + expect(onFailed).not.toHaveBeenCalled(); + }); + + it('handles unknown status properly', async () => { + const resourceId = 'charge_xxx'; + const onSucceeded = jest.fn(); + const onFailed = jest.fn(); + + mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess.mockResolvedValueOnce({ + status: 'unknown', + }); + + await PayjpThreeDSecure.startThreeDSecureProcess(resourceId, onSucceeded, onFailed); + + expect(onSucceeded).not.toHaveBeenCalled(); + expect(onFailed).toHaveBeenCalledTimes(1); + expect(onFailed).toHaveBeenCalledWith({ + message: expect.stringContaining('Unknown status: unknown'), + code: 1, + }); + }); + + it('calls onFailed if native throws an error', async () => { + const resourceId = 'charge_xxx'; + const onSucceeded = jest.fn(); + const onFailed = jest.fn(); + const error = { code: 999, message: 'fail' }; + + mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess.mockImplementationOnce(() => { + throw error; + }); + + await PayjpThreeDSecure.startThreeDSecureProcess(resourceId, onSucceeded, onFailed); + + expect(onSucceeded).not.toHaveBeenCalled(); + expect(onFailed).toHaveBeenCalledTimes(1); + }); + }); +});