@cardql/react-native-tap
Version:
CardQL SDK for React Native tap-to-pay for secure in-person payments
497 lines (432 loc) • 14.8 kB
text/typescript
import { Platform } from "react-native";
import type {
NFCCapabilities,
DeviceCapabilities,
CardType,
PaymentMethod,
TapToPayError,
TapToPayErrorCode,
StripeTerminalConfig,
StripeReaderConfig,
TapTransactionData,
} from "../types";
// Import Stripe Terminal SDK with fallback for development
let StripeTerminal: any;
let useStripeTerminal: any;
try {
const stripeTerminal = require("@stripe/stripe-terminal-react-native");
StripeTerminal = stripeTerminal.StripeTerminal;
useStripeTerminal = stripeTerminal.useStripeTerminal;
} catch (error) {
console.warn("Stripe Terminal SDK not found, using mock implementation");
// Mock implementation for development
StripeTerminal = {
initialize: () => Promise.resolve(),
discoverReaders: () => Promise.resolve({ readers: [] }),
connectLocalMobileReader: () => Promise.resolve({ reader: {} }),
connectBluetoothReader: () => Promise.resolve({ reader: {} }),
disconnectReader: () => Promise.resolve(),
createPaymentIntent: () => Promise.resolve({ paymentIntent: {} }),
collectPaymentMethod: () => Promise.resolve({ paymentIntent: {} }),
confirmPaymentIntent: () => Promise.resolve({ paymentIntent: {} }),
cancelCollectPaymentMethod: () => Promise.resolve(),
isInitialized: () => false,
};
useStripeTerminal = () => ({
initialize: StripeTerminal.initialize,
discoverReaders: StripeTerminal.discoverReaders,
connectReader: StripeTerminal.connectLocalMobileReader,
disconnectReader: StripeTerminal.disconnectReader,
createPaymentIntent: StripeTerminal.createPaymentIntent,
collectPaymentMethod: StripeTerminal.collectPaymentMethod,
confirmPaymentIntent: StripeTerminal.confirmPaymentIntent,
cancelCollectPaymentMethod: StripeTerminal.cancelCollectPaymentMethod,
connectedReader: null,
discoveredReaders: [],
isInitialized: false,
});
}
export class StripeTerminalManager {
private isInitialized = false;
private config?: StripeTerminalConfig;
private connectedReader?: any;
private discoveredReaders: any[] = [];
private currentPaymentIntent?: any;
async initialize(config: StripeTerminalConfig): Promise<void> {
if (this.isInitialized) return;
try {
this.config = config;
// Initialize Stripe Terminal with token provider
await StripeTerminal.initialize({
tokenProvider: config.tokenProvider,
logLevel: config.logLevel || "info",
});
this.isInitialized = true;
} catch (error: any) {
throw this.createError(
"TERMINAL_NOT_INITIALIZED",
`Failed to initialize Stripe Terminal: ${error.message}`,
error
);
}
}
async checkCapabilities(): Promise<NFCCapabilities> {
const isSupported = await this.isTerminalSupported();
return {
isNFCSupported: isSupported,
isNFCEnabled: isSupported,
canProcessPayments: isSupported && this.isInitialized,
supportedCardTypes: this.getSupportedCardTypes(),
maxTransactionAmount: this.getMaxTransactionAmount(),
supportsLocalMobile: Platform.OS === "ios" || Platform.OS === "android",
supportsBluetoothReaders: true,
};
}
async getDeviceCapabilities(): Promise<DeviceCapabilities> {
const nfcCapabilities = await this.checkCapabilities();
return {
nfc: nfcCapabilities,
hasSecureElement: this.hasSecureElement(),
supportsTapToPay: nfcCapabilities.canProcessPayments,
supportsStripeTerminal: await this.isTerminalSupported(),
deviceModel: this.getDeviceModel(),
osVersion: Platform.Version.toString(),
stripeTerminalVersion: this.getStripeTerminalVersion(),
};
}
async discoverReaders(config: StripeReaderConfig): Promise<any[]> {
if (!this.isInitialized) {
throw this.createError(
"TERMINAL_NOT_INITIALIZED",
"Terminal not initialized"
);
}
try {
const { readers, error } = await StripeTerminal.discoverReaders({
discoveryMethod: config.discoveryMethod,
simulated: config.simulated || false,
locationId: config.locationId,
});
if (error) {
throw this.createError(
"READER_DISCOVERY_FAILED",
`Reader discovery failed: ${error.message}`,
error
);
}
this.discoveredReaders = readers || [];
return this.discoveredReaders;
} catch (error: any) {
if (error.code) throw error;
throw this.createError(
"READER_DISCOVERY_FAILED",
`Failed to discover readers: ${error.message}`,
error
);
}
}
async connectReader(reader: any, config?: StripeReaderConfig): Promise<any> {
if (!this.isInitialized) {
throw this.createError(
"TERMINAL_NOT_INITIALIZED",
"Terminal not initialized"
);
}
try {
let result;
if (config?.discoveryMethod === "localMobile") {
result = await StripeTerminal.connectLocalMobileReader({
reader,
locationId: config?.locationId,
});
} else {
result = await StripeTerminal.connectBluetoothReader({
reader,
locationId: config?.locationId,
});
}
if (result.error) {
throw this.createError(
"READER_CONNECTION_FAILED",
`Reader connection failed: ${result.error.message}`,
result.error
);
}
this.connectedReader = result.reader;
return result.reader;
} catch (error: any) {
if (error.code) throw error;
throw this.createError(
"READER_CONNECTION_FAILED",
`Failed to connect reader: ${error.message}`,
error
);
}
}
async disconnectReader(): Promise<void> {
if (!this.connectedReader) return;
try {
await StripeTerminal.disconnectReader();
this.connectedReader = undefined;
} catch (error: any) {
throw this.createError(
"READER_CONNECTION_FAILED",
`Failed to disconnect reader: ${error.message}`,
error
);
}
}
async createPaymentIntent(
amount: number,
currency: string,
metadata?: any
): Promise<any> {
if (!this.isInitialized) {
throw this.createError(
"TERMINAL_NOT_INITIALIZED",
"Terminal not initialized"
);
}
try {
const { paymentIntent, error } = await StripeTerminal.createPaymentIntent(
{
amount,
currency: currency.toLowerCase(),
paymentMethodTypes: ["card_present"],
captureMethod: "automatic",
metadata: metadata || {},
}
);
if (error) {
throw this.createError(
"PAYMENT_INTENT_CREATION_FAILED",
`Payment intent creation failed: ${error.message}`,
error
);
}
this.currentPaymentIntent = paymentIntent;
return paymentIntent;
} catch (error: any) {
if (error.code) throw error;
throw this.createError(
"PAYMENT_INTENT_CREATION_FAILED",
`Failed to create payment intent: ${error.message}`,
error
);
}
}
async collectPaymentMethod(paymentIntent: any): Promise<any> {
if (!this.connectedReader) {
throw this.createError("READER_CONNECTION_FAILED", "No reader connected");
}
try {
const { paymentIntent: updatedPaymentIntent, error } =
await StripeTerminal.collectPaymentMethod({
paymentIntent,
});
if (error) {
throw this.createError(
"PAYMENT_COLLECTION_FAILED",
`Payment collection failed: ${error.message}`,
error
);
}
return updatedPaymentIntent;
} catch (error: any) {
if (error.code) throw error;
throw this.createError(
"PAYMENT_COLLECTION_FAILED",
`Failed to collect payment method: ${error.message}`,
error
);
}
}
async confirmPaymentIntent(paymentIntent: any): Promise<any> {
try {
const { paymentIntent: confirmedPaymentIntent, error } =
await StripeTerminal.confirmPaymentIntent({
paymentIntent,
});
if (error) {
throw this.createError(
"PAYMENT_CONFIRMATION_FAILED",
`Payment confirmation failed: ${error.message}`,
error
);
}
return confirmedPaymentIntent;
} catch (error: any) {
if (error.code) throw error;
throw this.createError(
"PAYMENT_CONFIRMATION_FAILED",
`Failed to confirm payment intent: ${error.message}`,
error
);
}
}
async cancelPayment(): Promise<void> {
try {
await StripeTerminal.cancelCollectPaymentMethod();
} catch (error: any) {
throw this.createError(
"PAYMENT_COLLECTION_FAILED",
`Failed to cancel payment: ${error.message}`,
error
);
}
}
getConnectedReader(): any {
return this.connectedReader;
}
getDiscoveredReaders(): any[] {
return this.discoveredReaders;
}
getCurrentPaymentIntent(): any {
return this.currentPaymentIntent;
}
// Private helper methods
private async isTerminalSupported(): Promise<boolean> {
try {
// Check if device supports Stripe Terminal
return Platform.OS === "ios" || Platform.OS === "android";
} catch {
return false;
}
}
private getSupportedCardTypes(): CardType[] {
// Stripe Terminal supports most major card types
return [
"visa",
"mastercard",
"amex",
"discover",
"diners",
"jcb",
"unionpay",
"contactless",
];
}
private getMaxTransactionAmount(): string {
// This would be configured based on merchant settings
return "999999.99";
}
private hasSecureElement(): boolean {
// Stripe Terminal handles secure element requirements
return Platform.OS === "ios" || Platform.OS === "android";
}
private getDeviceModel(): string {
return Platform.OS === "ios" ? "iPhone" : "Android Device";
}
private getStripeTerminalVersion(): string {
// This would come from the SDK version
return "0.0.1-beta.25";
}
private detectCardType(paymentMethod: any): CardType {
if (!paymentMethod?.card?.brand) return "unknown";
const brand = paymentMethod.card.brand.toLowerCase();
const cardTypeMap: Record<string, CardType> = {
visa: "visa",
mastercard: "mastercard",
amex: "amex",
discover: "discover",
diners: "diners",
jcb: "jcb",
unionpay: "unionpay",
};
return cardTypeMap[brand] || "unknown";
}
private detectPaymentMethod(paymentMethod: any): PaymentMethod {
if (!paymentMethod?.type) return "unknown";
switch (paymentMethod.type) {
case "card_present":
return "card_present";
default:
return "contactless_card";
}
}
private getLastFourDigits(paymentMethod: any): string {
return paymentMethod?.card?.last4 || "0000";
}
private generateTransactionId(): string {
return `stripe_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private createError(
code: TapToPayErrorCode,
message: string,
details?: any
): TapToPayError {
return {
code,
message,
details,
stripeError: details,
userFriendlyMessage: this.getUserFriendlyMessage(code),
canRetry: this.canRetryError(code),
suggestedAction: this.getSuggestedAction(code),
};
}
private getUserFriendlyMessage(code: TapToPayErrorCode): string {
const messages: Record<TapToPayErrorCode, string> = {
TERMINAL_NOT_SUPPORTED: "This device doesn't support tap-to-pay",
TERMINAL_NOT_INITIALIZED: "Payment system is not ready",
READER_CONNECTION_FAILED: "Could not connect to card reader",
READER_DISCOVERY_FAILED: "Could not find card readers",
PAYMENT_INTENT_CREATION_FAILED: "Could not initialize payment",
PAYMENT_COLLECTION_FAILED: "Could not process payment method",
PAYMENT_CONFIRMATION_FAILED: "Could not confirm payment",
CARD_READ_ERROR: "Unable to read the card. Please try again",
CARD_NOT_SUPPORTED: "This card type is not accepted",
TRANSACTION_DECLINED: "The transaction was declined",
TRANSACTION_TIMEOUT: "The transaction timed out. Please try again",
TRANSACTION_CANCELLED: "The transaction was cancelled",
AMOUNT_TOO_HIGH: "The amount exceeds the transaction limit",
AMOUNT_TOO_LOW: "The minimum transaction amount is not met",
DEVICE_NOT_COMPATIBLE: "This device is not compatible with tap-to-pay",
SECURITY_ERROR: "A security error occurred during the transaction",
NETWORK_ERROR: "Network connection is required for this transaction",
VALIDATION_ERROR: "Please check the transaction details",
STRIPE_ERROR: "Payment processing error",
UNKNOWN_ERROR: "An unexpected error occurred",
};
return messages[code] || "An error occurred";
}
private canRetryError(code: TapToPayErrorCode): boolean {
const retryableErrors: TapToPayErrorCode[] = [
"READER_CONNECTION_FAILED",
"READER_DISCOVERY_FAILED",
"PAYMENT_COLLECTION_FAILED",
"CARD_READ_ERROR",
"TRANSACTION_TIMEOUT",
"NETWORK_ERROR",
"STRIPE_ERROR",
"UNKNOWN_ERROR",
];
return retryableErrors.includes(code);
}
private getSuggestedAction(code: TapToPayErrorCode): string {
const actions: Record<TapToPayErrorCode, string> = {
TERMINAL_NOT_SUPPORTED: "Use a compatible device",
TERMINAL_NOT_INITIALIZED: "Restart the app",
READER_CONNECTION_FAILED: "Check reader connection",
READER_DISCOVERY_FAILED: "Ensure reader is nearby and powered on",
PAYMENT_INTENT_CREATION_FAILED: "Check payment settings",
PAYMENT_COLLECTION_FAILED: "Try tapping the card again",
PAYMENT_CONFIRMATION_FAILED: "Contact support",
CARD_READ_ERROR: "Hold the card closer to the reader",
CARD_NOT_SUPPORTED: "Try a different card",
TRANSACTION_DECLINED: "Contact your card issuer",
TRANSACTION_TIMEOUT: "Try the transaction again",
TRANSACTION_CANCELLED: "Start a new transaction",
AMOUNT_TOO_HIGH: "Reduce the transaction amount",
AMOUNT_TOO_LOW: "Increase the transaction amount",
DEVICE_NOT_COMPATIBLE: "Use a compatible device",
SECURITY_ERROR: "Contact support",
NETWORK_ERROR: "Check your internet connection",
VALIDATION_ERROR: "Verify transaction details",
STRIPE_ERROR: "Try again or contact support",
UNKNOWN_ERROR: "Try again or contact support",
};
return actions[code] || "Try again";
}
}
export const stripeTerminalManager = new StripeTerminalManager();