Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 14 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# @boltpay/react-native

[![npm version](https://img.shields.io/npm/v/@boltpay/react-native)](https://www.npmjs.com/package/@boltpay/react-native)
[![build](https://img.shields.io/github/actions/workflow/status/BoltApp/bolt-react-native-sdk/ci.yml?branch=main)](https://github.com/BoltApp/bolt-react-native-sdk/actions/workflows/ci.yml)
[![license](https://img.shields.io/npm/l/@boltpay/react-native)](LICENSE)

Bolt React Native SDK for payments. Provides Credit Card tokenization, 3D Secure verification, Apple Pay, and Google Pay — all integrated with the Bolt payment platform.

## Architecture
Expand Down Expand Up @@ -214,16 +218,7 @@ Then re-run `expo prebuild` and rebuild.

### 5. Google Pay (Android)

#### Prerequisites

Google Pay requires two merchant identifiers:

- **`gatewayMerchantId`** — Your Bolt merchant ID from the Bolt dashboard. This is used in the tokenization specification to route the payment through Bolt's gateway.
- **`googleMerchantId`** (optional) — Your Google-assigned merchant ID from the [Google Pay Business Console](https://pay.google.com/business/console/) (format: `BCR2DN...`). Required for production. In the test environment this can be omitted.

> **Common mistake:** Using your Android application ID (e.g., `com.example.myapp`) for either of these fields will cause an `OR_BIBED_06` error. The `gatewayMerchantId` must be your Bolt merchant ID and the `googleMerchantId` must be the ID from Google's console.

#### Usage
Merchant and gateway configuration (tokenization spec, merchant IDs) is automatically fetched from Bolt's API using your publishable key — you only need to provide presentation options like currency and amount.

```typescript
import { GoogleWallet } from '@boltpay/react-native/payments';
Expand All @@ -232,17 +227,15 @@ function CheckoutScreen() {
return (
<GoogleWallet
config={{
gatewayMerchantId: 'YOUR_BOLT_MERCHANT_ID',
googleMerchantId: 'BCR2DN...', // from Google Pay Business Console
merchantName: 'Your Store',
countryCode: 'US',
currencyCode: 'USD',
totalPrice: '9.99',
amount: '9.99',
label: 'Your Store',
billingAddressCollectionFormat: 'full',
}}
buttonType="buy"
borderRadius={8}
onComplete={(result) => {
// result: { token, bin?, expiration?, billingAddress?, boltReference? }
// result: { token, bin?, expiration?, email?, billingAddress?, boltReference? }
}}
onError={(error) => console.error(error)}
/>
Expand Down Expand Up @@ -322,10 +315,11 @@ cc.setStyles({

| Prop | Type | Default | Description |
| -------------- | --------------------------- | --------- | ----------------------------------------------------------------------------------------------------- |
| `config` | `GooglePayConfig` | required | Gateway/Google merchant IDs, merchant name, country/currency, and total price |
| `config` | `GooglePayConfig` | required | Presentation options: currency, amount, label, billing address format. Merchant config is auto-fetched from Bolt. |
| `onComplete` | `(GooglePayResult) => void` | required | Called with token, bin, expiration, and billing address on success |
| `onError` | `(Error) => void` | — | Called on payment failure or cancellation |
| `buttonType` | `GooglePayButtonType` | `'plain'` | Maps to Google Pay `ButtonConstants.ButtonType`. Button text is rendered natively and auto-localized. |
| `buttonTheme` | `GooglePayButtonTheme` | `'dark'` | Button color theme: `'dark'` or `'light'`. Maps to `ButtonConstants.ButtonTheme`. |
| `borderRadius` | `number` | — | Corner radius in dp applied to the Google Pay button |
| `style` | `ViewStyle` | — | Container style overrides (height, margin, etc.) |

Expand All @@ -350,7 +344,9 @@ cc.setStyles({
- `ApplePayButtonType` — Apple-approved button label variants (`'plain'`, `'buy'`, `'checkout'`, `'book'`, `'subscribe'`, `'donate'`, `'order'`, `'setUp'`, `'inStore'`, `'reload'`, `'addMoney'`, `'topUp'`, `'rent'`, `'support'`, `'contribute'`, `'tip'`)
- `GooglePayResult` — `{ token, bin?, expiration?, email?, billingAddress?, boltReference? }`
- `GooglePayButtonType` — Google-approved button label variants (`'plain'`, `'buy'`, `'pay'`, `'checkout'`, `'subscribe'`, `'donate'`, `'order'`, `'book'`)
- `ApplePayConfig`, `GooglePayConfig` — Configuration for wallet buttons
- `GooglePayButtonTheme` — Button color theme (`'dark'`, `'light'`)
- `ApplePayConfig` — Apple Pay configuration (merchant ID, country/currency, total)
- `GooglePayConfig` — `{ billingAddressCollectionFormat?, currencyCode?, label?, amount? }` (merchant/gateway config auto-fetched from Bolt)

### Error Codes (`ThreeDSError`)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.google.android.gms.wallet.button.PayButton
class GooglePayButtonView(context: Context) : FrameLayout(context) {

private var currentButtonType: String = "plain"
private var currentButtonTheme: String = "dark"
private var cornerRadiusPx: Float = 0f

init {
Expand Down Expand Up @@ -46,12 +47,19 @@ class GooglePayButtonView(context: Context) : FrameLayout(context) {
rebuildButton()
}

fun updateButtonTheme(theme: String) {
if (theme == currentButtonTheme) return
currentButtonTheme = theme
rebuildButton()
}

private fun rebuildButton() {
removeAllViews()

val button = PayButton(context)
val options = ButtonOptions.newBuilder()
.setButtonType(mapButtonType(currentButtonType))
.setButtonTheme(mapButtonTheme(currentButtonTheme))
.setAllowedPaymentMethods(ALLOWED_PAYMENT_METHODS)
.build()
button.initialize(options)
Expand Down Expand Up @@ -85,6 +93,11 @@ class GooglePayButtonView(context: Context) : FrameLayout(context) {
else -> ButtonConstants.ButtonType.PLAIN
}

fun mapButtonTheme(theme: String): Int = when (theme) {
"light" -> ButtonConstants.ButtonTheme.LIGHT
else -> ButtonConstants.ButtonTheme.DARK
}

// Minimal allowed payment methods JSON required by PayButton.initialize()
private const val ALLOWED_PAYMENT_METHODS = """
[{"type":"CARD","parameters":{"allowedAuthMethods":["PAN_ONLY","CRYPTOGRAM_3DS"],"allowedCardNetworks":["VISA","MASTERCARD","AMEX","DISCOVER"]}}]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class GooglePayButtonViewManager :
view.updateButtonType(type ?: "plain")
}

@ReactProp(name = "buttonTheme")
override fun setButtonTheme(view: GooglePayButtonView, theme: String?) {
view.updateButtonTheme(theme ?: "dark")
}

@ReactProp(name = "borderRadius", defaultFloat = 0f)
override fun setBorderRadius(view: GooglePayButtonView, borderRadius: Float) {
view.updateBorderRadius(PixelUtil.toPixelFromDIP(borderRadius))
Expand Down
46 changes: 31 additions & 15 deletions android/src/main/java/com/boltreactnativesdk/GooglePayModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import java.net.URL
* 1. Checking Google Pay readiness
* 2. Presenting the Google Pay payment sheet
* 3. Tokenizing the result via Bolt's tokenizer API
*
* The merchant/gateway configuration (tokenization spec, merchant ID, etc.)
* is fetched from Bolt's /v1/apm_config/googlepay endpoint on the JS side
* and passed down in the config JSON.
*/
class GooglePayModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
Expand Down Expand Up @@ -174,18 +178,31 @@ class GooglePayModule(reactContext: ReactApplicationContext) :
val cardParams = JSONObject()
cardParams.put("allowedAuthMethods", JSONArray(listOf("PAN_ONLY", "CRYPTOGRAM_3DS")))
cardParams.put("allowedCardNetworks", JSONArray(listOf("VISA", "MASTERCARD", "AMEX", "DISCOVER")))
cardParams.put("billingAddressRequired", true)
val billingAddressParams = JSONObject()
billingAddressParams.put("format", "FULL")
billingAddressParams.put("phoneNumberRequired", true)
cardParams.put("billingAddressParameters", billingAddressParams)

val tokenSpec = JSONObject()
tokenSpec.put("type", "PAYMENT_GATEWAY")
val tokenParams = JSONObject()
tokenParams.put("gateway", "bolt")
tokenParams.put("gatewayMerchantId", config.optString("gatewayMerchantId", ""))
tokenSpec.put("parameters", tokenParams)

// Billing address
val billingFormat = config.optString("billingAddressFormat", "FULL")
if (billingFormat != "NONE") {
cardParams.put("billingAddressRequired", true)
val billingAddressParams = JSONObject()
billingAddressParams.put("format", billingFormat)
billingAddressParams.put("phoneNumberRequired", true)
cardParams.put("billingAddressParameters", billingAddressParams)
}

// Tokenization spec from Bolt API config
val tokenSpecConfig = config.optJSONObject("tokenizationSpecification")
val tokenSpec = if (tokenSpecConfig != null) {
// Use the tokenization spec from Bolt's apm_config API
tokenSpecConfig
} else {
// Fallback: shouldn't happen in normal flow
val spec = JSONObject()
spec.put("type", "PAYMENT_GATEWAY")
val tokenParams = JSONObject()
tokenParams.put("gateway", "bolt")
spec.put("parameters", tokenParams)
spec
}

val cardMethod = JSONObject()
cardMethod.put("type", "CARD")
Expand All @@ -198,13 +215,12 @@ class GooglePayModule(reactContext: ReactApplicationContext) :
val transactionInfo = JSONObject()
transactionInfo.put("totalPrice", config.optString("totalPrice", "0.00"))
transactionInfo.put("totalPriceStatus", config.optString("totalPriceStatus", "FINAL"))
transactionInfo.put("countryCode", config.optString("countryCode", "US"))
transactionInfo.put("currencyCode", config.optString("currencyCode", "USD"))
params.put("transactionInfo", transactionInfo)

// Merchant info
// Merchant info from Bolt API config
val merchantInfo = JSONObject()
merchantInfo.put("merchantId", config.optString("googleMerchantId", ""))
merchantInfo.put("merchantId", config.optString("merchantId", ""))
merchantInfo.put("merchantName", config.optString("merchantName", ""))
params.put("merchantInfo", merchantInfo)
params.put("emailRequired", true)
Expand Down
8 changes: 2 additions & 6 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,9 @@ const AddCardScreen = () => {
{Platform.OS === 'android' && (
<GoogleWallet
config={{
gatewayMerchantId: 'BOLT_MERCHANT_ID',
googleMerchantId: 'BCR2DN6T7654321',
merchantName: 'Demo Store',
countryCode: 'US',
currencyCode: 'USD',
totalPrice: '0.00',
totalPriceStatus: 'ESTIMATED',
amount: '0.00',
label: 'Card Verification',
}}
onComplete={handleGooglePayComplete}
onError={handleWalletError}
Expand Down
39 changes: 33 additions & 6 deletions src/__tests__/GoogleWallet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { GooglePayConfig, GooglePayButtonType } from '../payments/types';
* - Payment request flow: config serialization → native call → result parsing
* - Error handling when requestPayment rejects
* - buttonType prop defaults
* - APM config fetch from Bolt API
*/

const mockIsReadyToPay = jest.fn<Promise<boolean>, [string]>();
Expand All @@ -34,6 +35,7 @@ jest.mock('../client/useBolt', () => ({
useBolt: () => ({
publishableKey: 'pk_test_123',
baseUrl: 'https://connect.bolt.com',
apiUrl: 'https://api.bolt.com',
}),
}));

Expand All @@ -49,13 +51,25 @@ jest.mock('../telemetry/tracer', () => ({
}));

const baseConfig: GooglePayConfig = {
gatewayMerchantId: 'BOLT_MERCHANT_ID',
googleMerchantId: 'BCR2DN6T7654321',
merchantName: 'Demo Store',
countryCode: 'US',
currencyCode: 'USD',
totalPrice: '10.00',
totalPriceStatus: 'FINAL',
amount: '10.00',
label: 'Test Purchase',
billingAddressCollectionFormat: 'full',
};

const mockAPMConfig = {
bolt_config: {
credit_card_processor: 'bolt',
tokenization_specification: {
type: 'PAYMENT_GATEWAY',
parameters: {
gateway: 'bolt',
gatewayMerchantId: 'BOLT_MERCHANT_ID',
},
},
merchant_id: 'BCR2DN6T7654321',
merchant_name: 'Demo Store',
},
};

describe('GoogleWallet', () => {
Expand Down Expand Up @@ -152,6 +166,19 @@ describe('GoogleWallet', () => {
});
});

describe('APM config', () => {
it('should have the expected bolt_config shape', () => {
const config = mockAPMConfig.bolt_config;
expect(config.merchant_id).toBe('BCR2DN6T7654321');
expect(config.merchant_name).toBe('Demo Store');
expect(config.tokenization_specification.type).toBe('PAYMENT_GATEWAY');
expect(config.tokenization_specification.parameters).toEqual({
gateway: 'bolt',
gatewayMerchantId: 'BOLT_MERCHANT_ID',
});
});
});

describe('buttonType defaults', () => {
it('should accept all valid GooglePayButtonType values', () => {
const validTypes: GooglePayButtonType[] = [
Expand Down
28 changes: 17 additions & 11 deletions src/__tests__/WalletTypes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,20 +98,26 @@ describe('GooglePay types', () => {
expect(result.boltReference).toBeUndefined();
});

it('GooglePayConfig should require gatewayMerchantId, merchantName, countryCode, currencyCode, totalPrice', () => {
it('GooglePayConfig should accept presentation options only', () => {
const config: GooglePayConfig = {
gatewayMerchantId: 'BOLT_MERCHANT_ID',
googleMerchantId: 'BCR2DN6T7654321',
merchantName: 'Demo Store',
countryCode: 'US',
currencyCode: 'USD',
totalPrice: '0.00',
totalPriceStatus: 'ESTIMATED',
amount: '0.00',
label: 'Card Verification',
billingAddressCollectionFormat: 'full',
};

expect(config.gatewayMerchantId).toBe('BOLT_MERCHANT_ID');
expect(config.googleMerchantId).toBe('BCR2DN6T7654321');
expect(config.totalPrice).toBe('0.00');
expect(config.totalPriceStatus).toBe('ESTIMATED');
expect(config.currencyCode).toBe('USD');
expect(config.amount).toBe('0.00');
expect(config.label).toBe('Card Verification');
expect(config.billingAddressCollectionFormat).toBe('full');
});

it('GooglePayConfig should allow all fields to be optional', () => {
const config: GooglePayConfig = {};

expect(config.currencyCode).toBeUndefined();
expect(config.amount).toBeUndefined();
expect(config.label).toBeUndefined();
expect(config.billingAddressCollectionFormat).toBeUndefined();
});
});
13 changes: 10 additions & 3 deletions src/client/Bolt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@ const ENVIRONMENT_URLS: Record<string, string> = {
staging: 'https://connect-staging.bolt.com',
};

const API_URLS: Record<string, string> = {
production: 'https://api.bolt.com',
sandbox: 'https://api-sandbox.bolt.com',
staging: 'https://api-staging.bolt.com',
};

export class Bolt {
public readonly publishableKey: string;
public readonly baseUrl: string;
public readonly apiUrl: string;
public readonly language: string;
private onPageStyles?: Styles;

Expand All @@ -25,10 +32,10 @@ export class Bolt {
throw new Error('Bolt: publishableKey is required');
}

const env = config.environment ?? 'production';
this.publishableKey = config.publishableKey;
this.baseUrl =
ENVIRONMENT_URLS[config.environment ?? 'production'] ??
ENVIRONMENT_URLS.production!;
this.baseUrl = ENVIRONMENT_URLS[env] ?? ENVIRONMENT_URLS.production!;
this.apiUrl = API_URLS[env] ?? API_URLS.production!;
this.language = config.language ?? 'en';

initTelemetry(config);
Expand Down
1 change: 1 addition & 0 deletions src/native/NativeGooglePayButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { codegenNativeComponent } from 'react-native';

interface NativeProps extends ViewProps {
buttonType: string;
buttonTheme?: string;
borderRadius?: Float;
onPress: BubblingEventHandler<{}>;
}
Expand Down
Loading
Loading