@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
text/typescript
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,
},
};
}