UNPKG

@cardql/react-native-tap

Version:

CardQL SDK for React Native tap-to-pay for secure in-person payments

689 lines (602 loc) 19.7 kB
import { useState, useCallback, useEffect, useRef } from "react"; import { useCardQLClient } from "@cardql/react-native"; import { stripeTerminalManager } from "../utils/nfcManager"; import type { UseTapToPayOptions, UseTapToPayResult, TapTransactionState, TapTransactionData, TapToPayConfig, CardReaderConfig, CreateTapPaymentInput, TapPaymentResult, TapToPayError, NFCCapabilities, DeviceCapabilities, ReceiptData, CardType, PaymentMethod, StripeReaderConfig, } from "../types"; // Import Stripe Terminal hook with fallback let useStripeTerminal: any; try { useStripeTerminal = require("@stripe/stripe-terminal-react-native").useStripeTerminal; } catch (error) { // Mock hook for development useStripeTerminal = () => ({ initialize: () => Promise.resolve(), discoverReaders: () => Promise.resolve({ readers: [] }), connectReader: () => Promise.resolve({ reader: {} }), disconnectReader: () => Promise.resolve(), createPaymentIntent: () => Promise.resolve({ paymentIntent: {} }), collectPaymentMethod: () => Promise.resolve({ paymentIntent: {} }), confirmPaymentIntent: () => Promise.resolve({ paymentIntent: {} }), cancelCollectPaymentMethod: () => Promise.resolve(), connectedReader: null, discoveredReaders: [], isInitialized: false, }); } const DEFAULT_CONFIG: TapToPayConfig = { merchantID: "", currency: "USD", timeout: 30000, acceptedCardTypes: ["visa", "mastercard", "amex", "discover"], requireSignature: false, requirePin: false, enableTips: false, enablePartialPayments: false, skipReceipt: false, stripeConfig: { tokenProvider: async () => { throw new Error("Token provider must be implemented"); }, logLevel: "info", simulated: false, }, captureMethod: "automatic", }; const DEFAULT_READER_CONFIG: CardReaderConfig = { discoveryMethod: "localMobile", simulated: false, autoConnect: false, autoStart: false, vibrationEnabled: true, soundEnabled: true, animationEnabled: true, showAmount: true, showInstructions: true, instructionText: "Hold your card near the device", successMessage: "Payment successful", errorMessage: "Payment failed", enableTipping: false, skipTipping: true, }; export function useTapToPay( options: UseTapToPayOptions = {} ): UseTapToPayResult { const { config = DEFAULT_CONFIG, readerConfig = DEFAULT_READER_CONFIG, events = {}, autoInit = false, autoDiscoverReaders = false, autoConnectReader = false, } = options; const cardql = useCardQLClient(); const [isSupported, setIsSupported] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const [isDiscoveringReaders, setIsDiscoveringReaders] = useState(false); const [discoveredReaders, setDiscoveredReaders] = useState<any[]>([]); const [connectedReader, setConnectedReader] = useState<any>(); const [isCollectingPayment, setIsCollectingPayment] = useState(false); const [currentTransaction, setCurrentTransaction] = useState< TapTransactionData | undefined >(); const [deviceCapabilities, setDeviceCapabilities] = useState< DeviceCapabilities | undefined >(); const [lastError, setLastError] = useState<TapToPayError | undefined>(); const timeoutRef = useRef<number>(); const transactionIdRef = useRef<string>(); const currentPaymentIntentRef = useRef<any>(); // Initialize capabilities on mount useEffect(() => { const initCapabilities = async () => { try { const capabilities = await stripeTerminalManager.getDeviceCapabilities(); setDeviceCapabilities(capabilities); setIsSupported(capabilities.supportsStripeTerminal); if (autoInit && capabilities.supportsStripeTerminal) { await initialize(); } } catch (error: any) { setLastError(error); } }; initCapabilities(); }, [autoInit]); // Auto-discover readers useEffect(() => { if ( autoDiscoverReaders && isInitialized && !isDiscoveringReaders && discoveredReaders.length === 0 ) { discoverReaders(); } }, [ autoDiscoverReaders, isInitialized, isDiscoveringReaders, discoveredReaders.length, ]); // Auto-connect reader useEffect(() => { if (autoConnectReader && discoveredReaders.length > 0 && !connectedReader) { connectReader(discoveredReaders[0]); } }, [autoConnectReader, discoveredReaders, connectedReader]); // Cleanup on unmount useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } cancelPayment(); }; }, []); const initialize = useCallback(async (): Promise<void> => { if (isInitialized) return; try { await stripeTerminalManager.initialize(config.stripeConfig); setIsInitialized(true); setLastError(undefined); } catch (error: any) { setLastError(error); throw error; } }, [isInitialized, config.stripeConfig]); const discoverReaders = useCallback( async (discoveryConfig?: StripeReaderConfig): Promise<void> => { if (!isInitialized) { await initialize(); } if (isDiscoveringReaders) { return; } try { setIsDiscoveringReaders(true); setLastError(undefined); const readers = await stripeTerminalManager.discoverReaders( discoveryConfig || readerConfig ); setDiscoveredReaders(readers); events.onReaderDiscovered?.(readers); } catch (error: any) { setLastError(error); events.onError?.(error); throw error; } finally { setIsDiscoveringReaders(false); } }, [isInitialized, isDiscoveringReaders, readerConfig, events, initialize] ); const connectReader = useCallback( async (reader: any): Promise<void> => { if (!isInitialized) { await initialize(); } try { setLastError(undefined); const connectedReader = await stripeTerminalManager.connectReader( reader, readerConfig ); setConnectedReader(connectedReader); events.onReaderConnected?.(connectedReader); } catch (error: any) { setLastError(error); events.onError?.(error); throw error; } }, [isInitialized, readerConfig, events, initialize] ); const disconnectReader = useCallback(async (): Promise<void> => { try { await stripeTerminalManager.disconnectReader(); setConnectedReader(undefined); events.onReaderDisconnected?.(); } catch (error: any) { setLastError(error); events.onError?.(error); throw error; } }, [events]); const createPaymentIntent = useCallback( async (input: CreateTapPaymentInput): Promise<any> => { if (!isInitialized) { throw new Error("Terminal not initialized"); } try { const amount = Math.round(parseFloat(input.amount) * 100); // Convert to cents const paymentIntent = await stripeTerminalManager.createPaymentIntent( amount, input.currency, { merchant_id: input.merchantID, user_id: input.userID, description: input.description, ...input.metadata, } ); currentPaymentIntentRef.current = paymentIntent; events.onPaymentIntentCreated?.(paymentIntent); return paymentIntent; } catch (error: any) { setLastError(error); events.onError?.(error); throw error; } }, [isInitialized, events] ); const collectPaymentMethod = useCallback( async (paymentIntent: any): Promise<any> => { if (!connectedReader) { throw new Error("No reader connected"); } try { setIsCollectingPayment(true); // Update transaction state if (currentTransaction) { const collectingTransaction = { ...currentTransaction, state: "processing_payment_method" as TapTransactionState, }; setCurrentTransaction(collectingTransaction); events.onTransactionStateChange?.( "processing_payment_method", collectingTransaction ); } const updatedPaymentIntent = await stripeTerminalManager.collectPaymentMethod(paymentIntent); // Extract payment method information const paymentMethod = updatedPaymentIntent.charges?.data?.[0]?.payment_method; if (paymentMethod) { events.onPaymentMethodCollected?.(paymentMethod); } events.onPaymentIntentUpdated?.(updatedPaymentIntent); return updatedPaymentIntent; } catch (error: any) { setLastError(error); events.onError?.(error); throw error; } finally { setIsCollectingPayment(false); } }, [connectedReader, currentTransaction, events] ); const confirmPayment = useCallback( async (paymentIntent: any): Promise<TapPaymentResult> => { try { // Update transaction state if (currentTransaction) { const confirmingTransaction = { ...currentTransaction, state: "capturing_payment" as TapTransactionState, }; setCurrentTransaction(confirmingTransaction); events.onTransactionStateChange?.( "capturing_payment", confirmingTransaction ); } const confirmedPaymentIntent = await stripeTerminalManager.confirmPaymentIntent(paymentIntent); // Create CardQL payment record const payment = await cardql.api.createPayment({ amount: (confirmedPaymentIntent.amount / 100).toString(), currency: confirmedPaymentIntent.currency.toUpperCase(), merchantID: config.merchantID, description: `Stripe Terminal payment - ${confirmedPaymentIntent.id}`, metadata: { ...confirmedPaymentIntent.metadata, stripe_payment_intent_id: confirmedPaymentIntent.id, stripe_charge_id: confirmedPaymentIntent.charges?.data?.[0]?.id, payment_method_id: confirmedPaymentIntent.charges?.data?.[0]?.payment_method, reader_id: connectedReader?.id, }, }); if (currentTransaction) { const successTransaction = { ...currentTransaction, state: "success" as TapTransactionState, stripePaymentIntentId: confirmedPaymentIntent.id, stripePaymentIntent: confirmedPaymentIntent, }; setCurrentTransaction(successTransaction); const receiptData = generateReceiptData( payment, successTransaction, confirmedPaymentIntent ); const result: TapPaymentResult = { success: true, payment, transactionData: successTransaction, stripePaymentIntent: confirmedPaymentIntent, receiptData, }; events.onTransactionStateChange?.("success", successTransaction); events.onSuccess?.(result); return result; } return result; } catch (error: any) { const failedTransaction = { ...currentTransaction, state: "failed" as TapTransactionState, }; setCurrentTransaction(failedTransaction); const tapError: TapToPayError = { code: "PAYMENT_CONFIRMATION_FAILED", message: error.message || "Payment confirmation failed", details: error, stripeError: error, userFriendlyMessage: "The payment could not be completed", canRetry: true, suggestedAction: "Try the payment again", }; const result: TapPaymentResult = { success: false, error: tapError, transactionData: failedTransaction, }; events.onTransactionStateChange?.("failed", failedTransaction); events.onError?.(tapError); setLastError(tapError); return result; } }, [currentTransaction, cardql, config.merchantID, connectedReader, events] ); const processPayment = useCallback( async (input: CreateTapPaymentInput): Promise<TapPaymentResult> => { if (!connectedReader) { throw new Error("No reader connected"); } try { // Generate transaction ID transactionIdRef.current = generateTransactionId(); // Create initial transaction data const transactionData: TapTransactionData = { id: transactionIdRef.current, state: "processing_payment", amount: input.amount, currency: input.currency, timestamp: new Date(), }; setCurrentTransaction(transactionData); events.onTransactionStateChange?.( "processing_payment", transactionData ); // Set up timeout if (config.timeout) { timeoutRef.current = setTimeout(() => { handleTimeout(); }, config.timeout) as any; } // Step 1: Create payment intent const paymentIntent = await createPaymentIntent(input); // Step 2: Collect payment method const updatedPaymentIntent = await collectPaymentMethod(paymentIntent); // Step 3: Confirm payment const result = await confirmPayment(updatedPaymentIntent); // Clear timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = undefined; } return result; } catch (error: any) { // Clear timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = undefined; } const failedTransaction = { ...currentTransaction, state: "failed" as TapTransactionState, }; setCurrentTransaction(failedTransaction); const tapError: TapToPayError = { code: "PAYMENT_COLLECTION_FAILED", message: error.message || "Payment processing failed", details: error, stripeError: error, userFriendlyMessage: "The payment could not be processed", canRetry: true, suggestedAction: "Try the payment again", }; const result: TapPaymentResult = { success: false, error: tapError, transactionData: failedTransaction, }; events.onTransactionStateChange?.("failed", failedTransaction); events.onError?.(tapError); setLastError(tapError); return result; } }, [ connectedReader, config.timeout, events, createPaymentIntent, collectPaymentMethod, confirmPayment, currentTransaction, ] ); const cancelPayment = useCallback(async (): Promise<void> => { try { await stripeTerminalManager.cancelPayment(); if (currentTransaction) { const cancelledTransaction = { ...currentTransaction, state: "cancelled" as TapTransactionState, }; setCurrentTransaction(cancelledTransaction); events.onTransactionStateChange?.("cancelled", cancelledTransaction); } setIsCollectingPayment(false); if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = undefined; } events.onCancel?.(); } catch (error: any) { setLastError(error); events.onError?.(error); throw error; } }, [currentTransaction, events]); const checkTerminalStatus = useCallback(async (): Promise<DeviceCapabilities> => { return await stripeTerminalManager.getDeviceCapabilities(); }, []); const formatReceipt = useCallback((receiptData: ReceiptData): string => { return ` RECEIPT ${receiptData.merchantName} ${receiptData.merchantAddress || ""} Transaction ID: ${receiptData.transactionId} ${ receiptData.stripePaymentIntentId ? `Stripe ID: ${receiptData.stripePaymentIntentId}` : "" } Amount: ${receiptData.currency} ${receiptData.amount} ${ receiptData.tipAmount ? `Tip: ${receiptData.currency} ${receiptData.tipAmount}` : "" } Total: ${receiptData.currency} ${receiptData.totalAmount} ${receiptData.cardType?.toUpperCase()} ****${receiptData.lastFourDigits} ${receiptData.paymentMethod} Auth Code: ${receiptData.authorizationCode} ${ receiptData.readerSerialNumber ? `Reader: ${receiptData.readerSerialNumber}` : "" } ${receiptData.timestamp.toLocaleString()} Thank you for your business! `.trim(); }, []); const getConnectionStatus = useCallback((): any => { return connectedReader ? "connected" : "notConnected"; }, [connectedReader]); const clearError = useCallback((): void => { setLastError(undefined); }, []); // Helper functions const handleTimeout = useCallback(() => { const timeoutError: TapToPayError = { code: "TRANSACTION_TIMEOUT", message: "Transaction timed out", userFriendlyMessage: "The transaction timed out. Please try again.", canRetry: true, suggestedAction: "Start a new transaction", }; if (currentTransaction) { const timeoutTransaction = { ...currentTransaction, state: "timeout" as TapTransactionState, }; setCurrentTransaction(timeoutTransaction); events.onTransactionStateChange?.("timeout", timeoutTransaction); } setIsCollectingPayment(false); events.onTimeout?.(); events.onError?.(timeoutError); setLastError(timeoutError); }, [currentTransaction, events]); return { // State isSupported, isInitialized, isDiscoveringReaders, discoveredReaders, connectedReader, isCollectingPayment, currentTransaction, deviceCapabilities, // Actions initialize, discoverReaders, connectReader, disconnectReader, createPaymentIntent, collectPaymentMethod, confirmPayment, processPayment, cancelPayment, // Utilities checkTerminalStatus, formatReceipt, getConnectionStatus, // Error handling lastError, clearError, }; } // Helper functions function generateTransactionId(): string { return `stripe_tap_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } function generateReceiptData( payment: any, transaction: TapTransactionData, stripePaymentIntent: any ): ReceiptData { const charge = stripePaymentIntent.charges?.data?.[0]; const paymentMethod = charge?.payment_method_details?.card_present; return { transactionId: transaction.id, stripePaymentIntentId: stripePaymentIntent.id, merchantName: "CardQL Merchant", // This would come from config amount: transaction.amount, currency: transaction.currency, totalAmount: transaction.amount, cardType: paymentMethod?.brand as CardType, lastFourDigits: paymentMethod?.last4 || "0000", paymentMethod: "card_present", authorizationCode: charge?.authorization_code, timestamp: transaction.timestamp, readerSerialNumber: charge?.payment_method_details?.card_present?.reader?.serial_number, applicationCryptogram: charge?.payment_method_details?.card_present?.emv_auth_data, applicationPreferredName: paymentMethod?.brand?.toUpperCase(), additionalInfo: { stripe_charge_id: charge?.id, network: paymentMethod?.network, funding: paymentMethod?.funding, }, }; }