From a747b09f1ce768e0663bcb4bea637ee41714c67a Mon Sep 17 00:00:00 2001 From: laiso Date: Sun, 11 May 2025 11:18:21 +0700 Subject: [PATCH 1/2] Update plugin version to 0.9.0 and implement 3D Secure process handler --- .../reactnative/PayjpReactNativePackage.kt | 4 +- .../PayjpThreeDSecureProcessHandlerModule.kt | 75 +++++++++ example/app.json | 13 +- example/app/(tabs)/_layout.tsx | 15 ++ example/app/(tabs)/index.tsx | 1 + example/app/(tabs)/tds/finish.tsx | 67 ++++++++ example/app/(tabs)/tds/index.tsx | 155 ++++++++++++++++++ example/yarn.lock | 2 +- ios/Classes/RNPAY.m | 2 +- ios/Classes/RNPAYThreeDSecureProcessHandler.h | 30 ++++ ios/Classes/RNPAYThreeDSecureProcessHandler.m | 99 +++++++++++ package.json | 2 +- src/ThreeDSecure.ts | 40 +++++ src/index.ts | 3 +- test/ThreeDSecure.test.ts | 94 +++++++++++ 15 files changed, 593 insertions(+), 9 deletions(-) create mode 100644 android/src/main/java/jp/pay/reactnative/PayjpThreeDSecureProcessHandlerModule.kt create mode 100644 example/app/(tabs)/tds/finish.tsx create mode 100644 example/app/(tabs)/tds/index.tsx create mode 100644 ios/Classes/RNPAYThreeDSecureProcessHandler.h create mode 100644 ios/Classes/RNPAYThreeDSecureProcessHandler.m create mode 100644 src/ThreeDSecure.ts create mode 100644 test/ThreeDSecure.test.ts diff --git a/android/src/main/java/jp/pay/reactnative/PayjpReactNativePackage.kt b/android/src/main/java/jp/pay/reactnative/PayjpReactNativePackage.kt index 1b5a14920..2ac234519 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 000000000..3dffae63f --- /dev/null +++ b/android/src/main/java/jp/pay/reactnative/PayjpThreeDSecureProcessHandlerModule.kt @@ -0,0 +1,75 @@ +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.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 -> { + promise.resolve(null) + } + PayjpThreeDSecureResult.Canceled -> { + promise.reject("THREE_D_SECURE_CANCELED", "ThreeDSecure process was canceled by user.") + } + else -> { + promise.reject("THREE_D_SECURE_UNKNOWN", "Unknown ThreeDSecure result.") + } + } + pendingPromise = null + } + }) + } + } + + override fun onNewIntent(intent: Intent?) {} +} diff --git a/example/app.json b/example/app.json index 7c9290981..9355a8bbd 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 7602ce75d..8bf3835c5 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 b0d5ba9d1..97eb4c366 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 000000000..c1a087fff --- /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('/tds'); + }; + + 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 000000000..8553a455f --- /dev/null +++ b/example/app/(tabs)/tds/index.tsx @@ -0,0 +1,155 @@ +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, + () => { + setResultMessage('3Dセキュア認証が完了しました'); + router.push('/tds/finish'); + }, + (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 4fc66130d..1d0063c54 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 8e94246a1..21b574e92 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 000000000..52bc9a572 --- /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 000000000..48bca96c5 --- /dev/null +++ b/ios/Classes/RNPAYThreeDSecureProcessHandler.m @@ -0,0 +1,99 @@ +/* + * + * 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([NSNull null]); + } + break; + case ThreeDSecureProcessStatusCanceled: + if (self.pendingReject) { + self.pendingReject(@"THREE_D_SECURE_CANCELED", + @"ThreeDSecure process was canceled by user.", nil); + } + break; + default: + break; + } + + self.pendingResolve = nil; + self.pendingReject = nil; +} + +@end diff --git a/package.json b/package.json index 7bfc7bc10..4c801cf09 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 000000000..9eefb5cac --- /dev/null +++ b/src/ThreeDSecure.ts @@ -0,0 +1,40 @@ +// LICENSE : MIT +import { NativeModules } from 'react-native'; + +/** + * 3Dセキュア処理が成功したときに実行されるリスナー + */ +export type OnThreeDSecureProcessSucceeded = () => 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 { + await RNPAYThreeDSecureProcessHandler.startThreeDSecureProcess(resourceId); + onSucceeded(); + } catch (nativeError: any) { + const errorMessage = `Native Code: ${nativeError.code}, Message: ${nativeError.message || 'Unknown error'}`; + const errorPayload = { message: errorMessage, code: 1 }; + onFailed(errorPayload); + throw nativeError; + } +}; diff --git a/src/index.ts b/src/index.ts index d5c039266..16ee3d640 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 000000000..52377ceb2 --- /dev/null +++ b/test/ThreeDSecure.test.ts @@ -0,0 +1,94 @@ +// 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', async () => { + const resourceId = 'charge_xxx'; + const onSucceeded = jest.fn(); + const onFailed = jest.fn(); + await PayjpThreeDSecure.startThreeDSecureProcess(resourceId, onSucceeded, onFailed); + expect(mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess).toHaveBeenCalledTimes(1); + expect(mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess).toHaveBeenCalledWith(resourceId); + expect(onSucceeded).toHaveBeenCalledTimes(1); + expect(onFailed).not.toHaveBeenCalled(); + }); + it('calls onFailed if native throws', 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 expect(PayjpThreeDSecure.startThreeDSecureProcess(resourceId, onSucceeded, onFailed)).rejects.toBe( + error, + ); + expect(onSucceeded).not.toHaveBeenCalled(); + expect(onFailed).toHaveBeenCalledTimes(1); + expect(onFailed).toHaveBeenCalledWith({ + message: expect.stringContaining('Native Code: 999, Message: fail'), + code: 1, + }); + }); + }); +}); From b8878afdbffc9ae24d76a6af9390af8639a88df6 Mon Sep 17 00:00:00 2001 From: laiso Date: Wed, 21 May 2025 22:06:57 +0700 Subject: [PATCH 2/2] =?UTF-8?q?3D=E3=82=BB=E3=82=AD=E3=83=A5=E3=82=A2?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=81=AE=E7=B5=90=E6=9E=9C=E3=82=92=E8=BF=94?= =?UTF-8?q?=E3=81=99=E9=9A=9B=E3=81=AB=E3=80=81=E6=88=90=E5=8A=9F=E3=81=A8?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=83=B3=E3=82=BB=E3=83=AB=E3=81=AE=E7=8A=B6?= =?UTF-8?q?=E6=85=8B=E3=82=92=E8=BF=94=E3=81=99=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PayjpThreeDSecureProcessHandlerModule.kt | 14 ++++- example/app/(tabs)/tds/finish.tsx | 2 +- example/app/(tabs)/tds/index.tsx | 10 +++- ios/Classes/RNPAYThreeDSecureProcessHandler.m | 11 ++-- src/ThreeDSecure.ts | 24 ++++++-- test/ThreeDSecure.test.ts | 58 ++++++++++++++++--- 6 files changed, 95 insertions(+), 24 deletions(-) diff --git a/android/src/main/java/jp/pay/reactnative/PayjpThreeDSecureProcessHandlerModule.kt b/android/src/main/java/jp/pay/reactnative/PayjpThreeDSecureProcessHandlerModule.kt index 3dffae63f..116faba3f 100644 --- a/android/src/main/java/jp/pay/reactnative/PayjpThreeDSecureProcessHandlerModule.kt +++ b/android/src/main/java/jp/pay/reactnative/PayjpThreeDSecureProcessHandlerModule.kt @@ -7,6 +7,8 @@ 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 @@ -56,13 +58,19 @@ class PayjpThreeDSecureProcessHandlerModule( override fun onResult(result: PayjpThreeDSecureResult) { when (result) { is PayjpThreeDSecureResult.SuccessResourceId -> { - promise.resolve(null) + val resultMap = Arguments.createMap().apply { + putString("status", "completed") + } + promise.resolve(resultMap) } PayjpThreeDSecureResult.Canceled -> { - promise.reject("THREE_D_SECURE_CANCELED", "ThreeDSecure process was canceled by user.") + val resultMap = Arguments.createMap().apply { + putString("status", "canceled") + } + promise.resolve(resultMap) } else -> { - promise.reject("THREE_D_SECURE_UNKNOWN", "Unknown ThreeDSecure result.") + promise.reject("THREE_D_SECURE_FAILED", "Unknown ThreeDSecure result.") } } pendingPromise = null diff --git a/example/app/(tabs)/tds/finish.tsx b/example/app/(tabs)/tds/finish.tsx index c1a087fff..6cbab3b85 100644 --- a/example/app/(tabs)/tds/finish.tsx +++ b/example/app/(tabs)/tds/finish.tsx @@ -10,7 +10,7 @@ export default function ThreeDSecureFinishScreen() { const router = useRouter(); const handleReturn = () => { - router.replace('/tds'); + router.replace('/'); }; return ( diff --git a/example/app/(tabs)/tds/index.tsx b/example/app/(tabs)/tds/index.tsx index 8553a455f..1cf483009 100644 --- a/example/app/(tabs)/tds/index.tsx +++ b/example/app/(tabs)/tds/index.tsx @@ -22,9 +22,13 @@ export default function ThreeDSecureScreen() { setResultMessage('3Dセキュア認証を開始します...'); await PayjpThreeDSecure.startThreeDSecureProcess( resourceId, - () => { - setResultMessage('3Dセキュア認証が完了しました'); - router.push('/tds/finish'); + 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); diff --git a/ios/Classes/RNPAYThreeDSecureProcessHandler.m b/ios/Classes/RNPAYThreeDSecureProcessHandler.m index 48bca96c5..3cf43eb2f 100644 --- a/ios/Classes/RNPAYThreeDSecureProcessHandler.m +++ b/ios/Classes/RNPAYThreeDSecureProcessHandler.m @@ -79,16 +79,19 @@ - (void)threeDSecureProcessHandlerDidFinish:(PAYJPThreeDSecureProcessHandler *)h switch (status) { case ThreeDSecureProcessStatusCompleted: if (self.pendingResolve) { - self.pendingResolve([NSNull null]); + self.pendingResolve(@{@"status" : @"completed"}); } break; case ThreeDSecureProcessStatusCanceled: - if (self.pendingReject) { - self.pendingReject(@"THREE_D_SECURE_CANCELED", - @"ThreeDSecure process was canceled by user.", nil); + 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; } diff --git a/src/ThreeDSecure.ts b/src/ThreeDSecure.ts index 9eefb5cac..b8123ba95 100644 --- a/src/ThreeDSecure.ts +++ b/src/ThreeDSecure.ts @@ -1,10 +1,18 @@ // LICENSE : MIT import { NativeModules } from 'react-native'; +export enum ThreeDSecureProcessStatus { + COMPLETED = 'completed', + + CANCELED = 'canceled', +} + /** * 3Dセキュア処理が成功したときに実行されるリスナー + * + * @param status 処理の結果状態 */ -export type OnThreeDSecureProcessSucceeded = () => void; +export type OnThreeDSecureProcessSucceeded = (status: ThreeDSecureProcessStatus) => void; /** * 3Dセキュア処理が失敗したときに実行されるリスナー @@ -29,12 +37,20 @@ export const startThreeDSecureProcess = async ( onFailed: OnThreeDSecureProcessFailed, ): Promise => { try { - await RNPAYThreeDSecureProcessHandler.startThreeDSecureProcess(resourceId); - onSucceeded(); + 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); - throw nativeError; } }; diff --git a/test/ThreeDSecure.test.ts b/test/ThreeDSecure.test.ts index 52377ceb2..8c9afe8eb 100644 --- a/test/ThreeDSecure.test.ts +++ b/test/ThreeDSecure.test.ts @@ -62,33 +62,73 @@ describe('PayjpThreeDSecure', () => { }); describe('startThreeDSecureProcess', () => { - it('calls native method with resourceId', async () => { + 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('calls onFailed if native throws', async () => { + + it('handles canceled status properly', async () => { const resourceId = 'charge_xxx'; const onSucceeded = jest.fn(); const onFailed = jest.fn(); - const error = { code: 999, message: 'fail' }; - mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess.mockImplementationOnce(() => { - throw error; + + mockRNPAYThreeDSecureProcessHandler.startThreeDSecureProcess.mockResolvedValueOnce({ + status: 'canceled', }); - await expect(PayjpThreeDSecure.startThreeDSecureProcess(resourceId, onSucceeded, onFailed)).rejects.toBe( - error, - ); + + 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('Native Code: 999, Message: fail'), + 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); + }); }); });