import { useCallback, useEffect, useState } from 'react'; import { Platform, type ViewStyle } from 'react-native'; import { type IPostGooglePayTokenRequest } from '@boltpay/tokenizer'; import NativeGooglePay from '../native/NativeGooglePay'; import { useBolt } from '../client/useBolt'; import { useTkClient } from '../client/useTkClient'; import type { GooglePayResult, GooglePayConfig, GooglePayButtonType, GooglePayButtonTheme, GooglePayAPMConfigResponse, GooglePayAPMConfig, GooglePayBillingAddress, } from './types'; import { recordEvent, startSpan, SpanStatusCode } from '../telemetry/tracer'; import { BoltAttributes } from '../telemetry/attributes'; import { logger } from '../telemetry/logger'; import { fetchGooglePayAPMConfig } from './googlePayApi'; export { fetchGooglePayAPMConfig }; // Conditional require: Metro inlines Platform.OS and eliminates the dead branch at bundle // time, so NativeGooglePayButton (which calls codegenNativeComponent) is never loaded on // iOS — even if the .ios.js stub is bypassed by exports-map resolution. const BoltGooglePayButton = ( Platform.OS === 'android' ? require('../native/NativeGooglePayButton').default : null ) as typeof import('../native/NativeGooglePayButton').default | null; export interface GoogleWalletProps { config: GooglePayConfig; onComplete: (result: GooglePayResult) => void; onError?: (error: Error) => void; style?: ViewStyle; buttonType?: GooglePayButtonType; buttonTheme?: GooglePayButtonTheme; borderRadius?: number; } /** * — renders a native Google Pay button that triggers the * native PaymentsClient payment sheet via the BoltGooglePay TurboModule. * * Merchant/gateway configuration is automatically fetched from Bolt's API * using the publishable key. Only renders on Android when Google Pay is available. */ export const GoogleWallet = ({ config, onComplete, onError, style, buttonType = 'plain', buttonTheme, borderRadius, }: GoogleWalletProps) => { const bolt = useBolt(); const [available, setAvailable] = useState(false); const [apmConfigResponse, setApmConfigResponse] = useState(null); const tkClient = useTkClient(); // Fetch Bolt Google Pay config on mount useEffect(() => { if (Platform.OS !== 'android') return; fetchGooglePayAPMConfig(bolt.apiUrl, bolt.apiHeaders()) .then(setApmConfigResponse) .catch((err) => { onError?.( err instanceof Error ? err : new Error('Failed to fetch Google Pay config') ); }); }, [bolt, onError]); // Check Google Pay readiness once we have the APM config useEffect(() => { if (Platform.OS !== 'android' || !NativeGooglePay || !apmConfigResponse) { setAvailable(false); return; } const nativeConfig = buildNativeConfig( config, apmConfigResponse.bolt_config, bolt.environment ); NativeGooglePay.isReadyToPay(JSON.stringify(nativeConfig)) .then(setAvailable) .catch(() => setAvailable(false)); }, [config, apmConfigResponse, bolt.environment]); const handlePress = useCallback(async () => { if (!NativeGooglePay || !apmConfigResponse) { onError?.(new Error('Google Pay is not available')); return; } recordEvent('bolt.google_pay.button_pressed', { [BoltAttributes.PAYMENT_METHOD]: 'google_pay', [BoltAttributes.PAYMENT_OPERATION]: 'button_pressed', }); const span = startSpan('bolt.google_pay.request_payment', { [BoltAttributes.PAYMENT_METHOD]: 'google_pay', [BoltAttributes.PAYMENT_OPERATION]: 'request_payment', }); // Keep onComplete OUT of the try/catch: a throwing consumer callback is // not an SDK payment failure and should not be routed to onError or logged // as a failed tokenization. let result: GooglePayResult | undefined; try { const nativeConfig = buildNativeConfig( config, apmConfigResponse.bolt_config, bolt.environment ); const rawJson = await NativeGooglePay.requestPayment( JSON.stringify(nativeConfig) ); const raw: { googlePayToken?: IPostGooglePayTokenRequest; billingAddress?: GooglePayBillingAddress; email?: string; } = JSON.parse(rawJson); if (!raw?.googlePayToken) { throw new Error('Native Google Pay response missing googlePayToken'); } const tokenResult = await tkClient.postGooglePayToken(raw.googlePayToken); if (tokenResult instanceof Error) throw tokenResult; result = { token: tokenResult.token, bin: tokenResult.bin, expiration: tokenResult.expiry, last4: tokenResult.last4, network: tokenResult.network, email: raw.email, billingAddress: raw.billingAddress, }; span.addEvent('bolt.google_pay.tokenize_success'); span.setStatus({ code: SpanStatusCode.OK }); span.end(); } catch (err) { const error = err instanceof Error ? err : new Error('Google Pay payment failed'); // GooglePayModule.kt rejects with `code: 'CANCELLED'` on user dismissal // (and unfortunately also generic Google Pay errors; the native side // conflates them). Mirror the Apple Pay and WebView handling and treat // this code path as a silent cancel so merchants don't see a phantom // error for every dismissal. const nativeCode = (err as { code?: string }).code; if (nativeCode === 'CANCELLED') { span.addEvent('bolt.google_pay.cancelled', { [BoltAttributes.PAYMENT_CANCELLED]: true, }); span.setStatus({ code: SpanStatusCode.UNSET }); span.end(); return; } span.addEvent('bolt.google_pay.tokenize_failure', { [BoltAttributes.ERROR_MESSAGE]: error.message, }); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); span.recordException(error); span.end(); onError?.(error); return; } // Guard onComplete separately: a consumer throw is not a payment failure, // so don't route it to onError or surface as an unhandled rejection. try { onComplete(result); } catch (cbErr) { logger.error('google_pay.on_complete_threw', { error: cbErr instanceof Error ? cbErr.message : String(cbErr), }); } }, [ config, apmConfigResponse, bolt.environment, tkClient, onComplete, onError, ]); if (!available || !BoltGooglePayButton) { return null; } return ( ); }; /** * Build the config object sent to the native module by merging * the Bolt API config with the developer's presentation options. */ const buildNativeConfig = ( config: GooglePayConfig, apmConfig: GooglePayAPMConfig, environment: 'production' | 'sandbox' | 'staging' ) => { return { // From Bolt API merchantId: apmConfig.merchant_id, merchantName: apmConfig.merchant_name, tokenizationSpecification: apmConfig.tokenization_specification, countryCode: 'US', //apmConfig.country_code ?? 'US', TODO: add country code to the config from API // From developer currencyCode: config.currencyCode ?? 'USD', totalPrice: config.amount ?? '0.00', totalPriceStatus: 'FINAL', totalPriceLabel: config.label, billingAddressFormat: config.billingAddressCollectionFormat === 'none' ? 'NONE' : 'FULL', // Tells the native module which Google Pay environment to use googlePayEnvironment: environment === 'production' ? 'PRODUCTION' : 'TEST', }; };