import { useCallback, useEffect, useState } from 'react'; import { Platform, type ViewStyle } from 'react-native'; import { type IPostApplePayTokenRequest } from '@boltpay/tokenizer'; import NativeApplePay from '../native/NativeApplePay'; import { useTkClient } from '../client/useTkClient'; import type { ApplePayResult, ApplePayConfig, ApplePayButtonType, ApplePayBillingContact, } from './types'; import { recordEvent, startSpan, SpanStatusCode } from '../telemetry/tracer'; import { BoltAttributes } from '../telemetry/attributes'; import { logger } from '../telemetry/logger'; import { ApplePayWebView } from './ApplePayWebView'; // Conditional require: Metro inlines Platform.OS and eliminates the dead branch at bundle // time, so NativeApplePayButton (which calls codegenNativeComponent) is never loaded on // Android — even if the .android.js stub is bypassed by exports-map resolution. const BoltApplePayButton = ( Platform.OS === 'ios' ? require('../native/NativeApplePayButton').default : null ) as typeof import('../native/NativeApplePayButton').default | null; export interface ApplePayProps { config: ApplePayConfig; onComplete: (result: ApplePayResult) => void; onError?: (error: Error) => void; style?: ViewStyle; buttonStyle?: 'black' | 'white' | 'whiteOutline'; buttonType?: ApplePayButtonType; /** * Payment mode. Defaults to `'webview'` which uses the Bolt-hosted Apple Pay * iframe (no entitlement or merchant certificate required). Set to `'native'` * to use the native PKPaymentButton + PassKit sheet — requires an Apple Pay * entitlement and a registered merchant identifier in your app. */ mode?: 'webview' | 'native'; /** * Your merchant website URL (webview mode only). Must match a domain * registered in both your Bolt merchant account and with Apple for * Apple Pay. Required because React Native WebView has no browser referrer. */ referrer?: string; } /** * — renders an Apple Pay button. * * In `mode='webview'` (default): loads the Bolt-hosted add-card-from-apple-wallet * iframe. No entitlement or merchant certificate setup required. * * In `mode='native'`: renders a native PKPaymentButton and triggers the PassKit * payment sheet. Requires Apple Pay entitlement + registered merchant identifier. * * Only renders on iOS when Apple Pay is available. */ export const ApplePay = ({ config, onComplete, onError, style, buttonStyle = 'black', buttonType = 'plain', mode = 'webview', referrer, }: ApplePayProps) => { // Split native vs. webview rendering before calling native-only hooks so // webview consumers don't pay the eager TkClient construction cost // (public-key fetch + tweetnacl keypair) that useTkClient triggers. if (mode !== 'native') { return ( ); } return ( ); }; interface ApplePayNativeProps { config: ApplePayConfig; onComplete: (result: ApplePayResult) => void; onError?: (error: Error) => void; style?: ViewStyle; buttonStyle?: 'black' | 'white' | 'whiteOutline'; buttonType?: ApplePayButtonType; } const ApplePayNative = ({ config, onComplete, onError, style, buttonStyle = 'black', buttonType = 'plain', }: ApplePayNativeProps) => { const tkClient = useTkClient(); const [available, setAvailable] = useState(false); useEffect(() => { if (Platform.OS !== 'ios' || !NativeApplePay) { setAvailable(false); return; } NativeApplePay.canMakePayments() .then(setAvailable) .catch(() => setAvailable(false)); }, []); const handlePress = useCallback(async () => { if (!NativeApplePay) { onError?.(new Error('Apple Pay is not available')); return; } recordEvent('bolt.apple_pay.button_pressed', { [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', [BoltAttributes.PAYMENT_OPERATION]: 'button_pressed', }); const span = startSpan('bolt.apple_pay.request_payment', { [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', [BoltAttributes.PAYMENT_OPERATION]: 'request_payment', }); // PassKit keeps the Apple Pay sheet in a processing state between // `requestPayment` resolving and `reportAuthorizationResult` being called. // Track the outcome so the `finally` block reports it regardless of how we // exit the try. Treat `requestPayment` rejections (user cancel, early // native errors) as "nothing pending" — the sheet is already dismissed. let reachedAuthorization = false; let success = false; let lastError: Error | undefined; let result: ApplePayResult | undefined; try { const rawJson = await NativeApplePay.requestPayment( JSON.stringify(config) ); reachedAuthorization = true; const raw: { applePayToken?: IPostApplePayTokenRequest; billingContact?: ApplePayBillingContact; } = JSON.parse(rawJson); if (!raw?.applePayToken) { throw new Error('Native Apple Pay response missing applePayToken'); } const tokenResult = await tkClient.postApplePayToken(raw.applePayToken); if (tokenResult instanceof Error) throw tokenResult; result = { token: tokenResult.token, bin: tokenResult.bin, expiration: tokenResult.expiry, network: tokenResult.network, billingContact: raw.billingContact, }; success = true; span.addEvent('bolt.apple_pay.tokenize_success'); span.setStatus({ code: SpanStatusCode.OK }); span.end(); } catch (err) { lastError = err instanceof Error ? err : new Error('Apple Pay payment failed'); // The native module rejects with `code: 'CANCELLED'` on user dismissal. // Match the WebView mode's behavior and treat cancel as a silent, // consumer-invisible outcome — don't route through onError, which would // surface a "User cancelled" error to merchant-level error handlers. const nativeCode = (err as { code?: string }).code; if (nativeCode === 'CANCELLED') { span.addEvent('bolt.apple_pay.cancelled', { [BoltAttributes.PAYMENT_CANCELLED]: true, }); span.setStatus({ code: SpanStatusCode.UNSET }); span.end(); return; } span.addEvent('bolt.apple_pay.tokenize_failure', { [BoltAttributes.ERROR_MESSAGE]: lastError.message, }); span.setStatus({ code: SpanStatusCode.ERROR, message: lastError.message, }); span.recordException(lastError); span.end(); onError?.(lastError); } finally { if (reachedAuthorization) { // Invoke the retained PassKit completion so the sheet transitions to // success/failure. `success` reflects tokenization outcome and is // unaffected by any later consumer-callback throw — the sheet lock-in // happens here, before onComplete runs. NativeApplePay.reportAuthorizationResult( success, lastError?.message ?? null ).catch((reportErr) => { // The native method resolves even on internal failure today, so a // rejection here means a bridge/contract change — log so we can // catch it rather than silently dropping the signal. logger.error('apple_pay.report_authorization_result_failed', { error: reportErr instanceof Error ? reportErr.message : String(reportErr), }); }); } } // onComplete runs outside the try/catch so a throwing consumer callback // stays with the consumer — it must not be recorded to telemetry as a // payment error or routed to onError. Guard with a log so it doesn't // surface as an unhandled rejection from this async handler. if (result) { try { onComplete(result); } catch (cbErr) { logger.error('apple_pay.on_complete_threw', { error: cbErr instanceof Error ? cbErr.message : String(cbErr), }); } } }, [config, tkClient, onComplete, onError]); if (!available || !BoltApplePayButton) { return null; } return ( ); };