Skip to content

Commit 2da6a60

Browse files
committed
feat: fetch Google Pay config from Bolt API, add button theme support
1 parent 2aaefca commit 2da6a60

12 files changed

Lines changed: 252 additions & 82 deletions

File tree

README.md

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# @boltpay/react-native
22

3+
[![npm version](https://img.shields.io/npm/v/@boltpay/react-native)](https://www.npmjs.com/package/@boltpay/react-native)
4+
[![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)
5+
[![license](https://img.shields.io/npm/l/@boltpay/react-native)](LICENSE)
6+
37
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.
48

59
## Architecture
@@ -214,16 +218,7 @@ Then re-run `expo prebuild` and rebuild.
214218

215219
### 5. Google Pay (Android)
216220

217-
#### Prerequisites
218-
219-
Google Pay requires two merchant identifiers:
220-
221-
- **`gatewayMerchantId`** — Your Bolt merchant ID from the Bolt dashboard. This is used in the tokenization specification to route the payment through Bolt's gateway.
222-
- **`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.
223-
224-
> **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.
225-
226-
#### Usage
221+
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.
227222

228223
```typescript
229224
import { GoogleWallet } from '@boltpay/react-native/payments';
@@ -232,17 +227,15 @@ function CheckoutScreen() {
232227
return (
233228
<GoogleWallet
234229
config={{
235-
gatewayMerchantId: 'YOUR_BOLT_MERCHANT_ID',
236-
googleMerchantId: 'BCR2DN...', // from Google Pay Business Console
237-
merchantName: 'Your Store',
238-
countryCode: 'US',
239230
currencyCode: 'USD',
240-
totalPrice: '9.99',
231+
amount: '9.99',
232+
label: 'Your Store',
233+
billingAddressCollectionFormat: 'full',
241234
}}
242235
buttonType="buy"
243236
borderRadius={8}
244237
onComplete={(result) => {
245-
// result: { token, bin?, expiration?, billingAddress?, boltReference? }
238+
// result: { token, bin?, expiration?, email?, billingAddress?, boltReference? }
246239
}}
247240
onError={(error) => console.error(error)}
248241
/>
@@ -322,10 +315,11 @@ cc.setStyles({
322315

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

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

355351
### Error Codes (`ThreeDSError`)
356352

android/src/main/java/com/boltreactnativesdk/GooglePayButtonView.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.google.android.gms.wallet.button.PayButton
1919
class GooglePayButtonView(context: Context) : FrameLayout(context) {
2020

2121
private var currentButtonType: String = "plain"
22+
private var currentButtonTheme: String = "dark"
2223
private var cornerRadiusPx: Float = 0f
2324

2425
init {
@@ -46,12 +47,19 @@ class GooglePayButtonView(context: Context) : FrameLayout(context) {
4647
rebuildButton()
4748
}
4849

50+
fun updateButtonTheme(theme: String) {
51+
if (theme == currentButtonTheme) return
52+
currentButtonTheme = theme
53+
rebuildButton()
54+
}
55+
4956
private fun rebuildButton() {
5057
removeAllViews()
5158

5259
val button = PayButton(context)
5360
val options = ButtonOptions.newBuilder()
5461
.setButtonType(mapButtonType(currentButtonType))
62+
.setButtonTheme(mapButtonTheme(currentButtonTheme))
5563
.setAllowedPaymentMethods(ALLOWED_PAYMENT_METHODS)
5664
.build()
5765
button.initialize(options)
@@ -85,6 +93,11 @@ class GooglePayButtonView(context: Context) : FrameLayout(context) {
8593
else -> ButtonConstants.ButtonType.PLAIN
8694
}
8795

96+
fun mapButtonTheme(theme: String): Int = when (theme) {
97+
"light" -> ButtonConstants.ButtonTheme.LIGHT
98+
else -> ButtonConstants.ButtonTheme.DARK
99+
}
100+
88101
// Minimal allowed payment methods JSON required by PayButton.initialize()
89102
private const val ALLOWED_PAYMENT_METHODS = """
90103
[{"type":"CARD","parameters":{"allowedAuthMethods":["PAN_ONLY","CRYPTOGRAM_3DS"],"allowedCardNetworks":["VISA","MASTERCARD","AMEX","DISCOVER"]}}]

android/src/main/java/com/boltreactnativesdk/GooglePayButtonViewManager.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ class GooglePayButtonViewManager :
3737
view.updateButtonType(type ?: "plain")
3838
}
3939

40+
@ReactProp(name = "buttonTheme")
41+
override fun setButtonTheme(view: GooglePayButtonView, theme: String?) {
42+
view.updateButtonTheme(theme ?: "dark")
43+
}
44+
4045
@ReactProp(name = "borderRadius", defaultFloat = 0f)
4146
override fun setBorderRadius(view: GooglePayButtonView, borderRadius: Float) {
4247
view.updateBorderRadius(PixelUtil.toPixelFromDIP(borderRadius))

android/src/main/java/com/boltreactnativesdk/GooglePayModule.kt

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import java.net.URL
2121
* 1. Checking Google Pay readiness
2222
* 2. Presenting the Google Pay payment sheet
2323
* 3. Tokenizing the result via Bolt's tokenizer API
24+
*
25+
* The merchant/gateway configuration (tokenization spec, merchant ID, etc.)
26+
* is fetched from Bolt's /v1/apm_config/googlepay endpoint on the JS side
27+
* and passed down in the config JSON.
2428
*/
2529
class GooglePayModule(reactContext: ReactApplicationContext) :
2630
ReactContextBaseJavaModule(reactContext) {
@@ -174,18 +178,31 @@ class GooglePayModule(reactContext: ReactApplicationContext) :
174178
val cardParams = JSONObject()
175179
cardParams.put("allowedAuthMethods", JSONArray(listOf("PAN_ONLY", "CRYPTOGRAM_3DS")))
176180
cardParams.put("allowedCardNetworks", JSONArray(listOf("VISA", "MASTERCARD", "AMEX", "DISCOVER")))
177-
cardParams.put("billingAddressRequired", true)
178-
val billingAddressParams = JSONObject()
179-
billingAddressParams.put("format", "FULL")
180-
billingAddressParams.put("phoneNumberRequired", true)
181-
cardParams.put("billingAddressParameters", billingAddressParams)
182-
183-
val tokenSpec = JSONObject()
184-
tokenSpec.put("type", "PAYMENT_GATEWAY")
185-
val tokenParams = JSONObject()
186-
tokenParams.put("gateway", "bolt")
187-
tokenParams.put("gatewayMerchantId", config.optString("gatewayMerchantId", ""))
188-
tokenSpec.put("parameters", tokenParams)
181+
182+
// Billing address
183+
val billingFormat = config.optString("billingAddressFormat", "FULL")
184+
if (billingFormat != "NONE") {
185+
cardParams.put("billingAddressRequired", true)
186+
val billingAddressParams = JSONObject()
187+
billingAddressParams.put("format", billingFormat)
188+
billingAddressParams.put("phoneNumberRequired", true)
189+
cardParams.put("billingAddressParameters", billingAddressParams)
190+
}
191+
192+
// Tokenization spec from Bolt API config
193+
val tokenSpecConfig = config.optJSONObject("tokenizationSpecification")
194+
val tokenSpec = if (tokenSpecConfig != null) {
195+
// Use the tokenization spec from Bolt's apm_config API
196+
tokenSpecConfig
197+
} else {
198+
// Fallback: shouldn't happen in normal flow
199+
val spec = JSONObject()
200+
spec.put("type", "PAYMENT_GATEWAY")
201+
val tokenParams = JSONObject()
202+
tokenParams.put("gateway", "bolt")
203+
spec.put("parameters", tokenParams)
204+
spec
205+
}
189206

190207
val cardMethod = JSONObject()
191208
cardMethod.put("type", "CARD")
@@ -198,13 +215,12 @@ class GooglePayModule(reactContext: ReactApplicationContext) :
198215
val transactionInfo = JSONObject()
199216
transactionInfo.put("totalPrice", config.optString("totalPrice", "0.00"))
200217
transactionInfo.put("totalPriceStatus", config.optString("totalPriceStatus", "FINAL"))
201-
transactionInfo.put("countryCode", config.optString("countryCode", "US"))
202218
transactionInfo.put("currencyCode", config.optString("currencyCode", "USD"))
203219
params.put("transactionInfo", transactionInfo)
204220

205-
// Merchant info
221+
// Merchant info from Bolt API config
206222
val merchantInfo = JSONObject()
207-
merchantInfo.put("merchantId", config.optString("googleMerchantId", ""))
223+
merchantInfo.put("merchantId", config.optString("merchantId", ""))
208224
merchantInfo.put("merchantName", config.optString("merchantName", ""))
209225
params.put("merchantInfo", merchantInfo)
210226
params.put("emailRequired", true)

example/src/App.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,13 +237,9 @@ const AddCardScreen = () => {
237237
{Platform.OS === 'android' && (
238238
<GoogleWallet
239239
config={{
240-
gatewayMerchantId: 'BOLT_MERCHANT_ID',
241-
googleMerchantId: 'BCR2DN6T7654321',
242-
merchantName: 'Demo Store',
243-
countryCode: 'US',
244240
currencyCode: 'USD',
245-
totalPrice: '0.00',
246-
totalPriceStatus: 'ESTIMATED',
241+
amount: '0.00',
242+
label: 'Card Verification',
247243
}}
248244
onComplete={handleGooglePayComplete}
249245
onError={handleWalletError}

src/__tests__/GoogleWallet.test.tsx

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { GooglePayConfig, GooglePayButtonType } from '../payments/types';
1010
* - Payment request flow: config serialization → native call → result parsing
1111
* - Error handling when requestPayment rejects
1212
* - buttonType prop defaults
13+
* - APM config fetch from Bolt API
1314
*/
1415

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

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

5153
const baseConfig: GooglePayConfig = {
52-
gatewayMerchantId: 'BOLT_MERCHANT_ID',
53-
googleMerchantId: 'BCR2DN6T7654321',
54-
merchantName: 'Demo Store',
55-
countryCode: 'US',
5654
currencyCode: 'USD',
57-
totalPrice: '10.00',
58-
totalPriceStatus: 'FINAL',
55+
amount: '10.00',
56+
label: 'Test Purchase',
57+
billingAddressCollectionFormat: 'full',
58+
};
59+
60+
const mockAPMConfig = {
61+
bolt_config: {
62+
credit_card_processor: 'bolt',
63+
tokenization_specification: {
64+
type: 'PAYMENT_GATEWAY',
65+
parameters: {
66+
gateway: 'bolt',
67+
gatewayMerchantId: 'BOLT_MERCHANT_ID',
68+
},
69+
},
70+
merchant_id: 'BCR2DN6T7654321',
71+
merchant_name: 'Demo Store',
72+
},
5973
};
6074

6175
describe('GoogleWallet', () => {
@@ -152,6 +166,19 @@ describe('GoogleWallet', () => {
152166
});
153167
});
154168

169+
describe('APM config', () => {
170+
it('should have the expected bolt_config shape', () => {
171+
const config = mockAPMConfig.bolt_config;
172+
expect(config.merchant_id).toBe('BCR2DN6T7654321');
173+
expect(config.merchant_name).toBe('Demo Store');
174+
expect(config.tokenization_specification.type).toBe('PAYMENT_GATEWAY');
175+
expect(config.tokenization_specification.parameters).toEqual({
176+
gateway: 'bolt',
177+
gatewayMerchantId: 'BOLT_MERCHANT_ID',
178+
});
179+
});
180+
});
181+
155182
describe('buttonType defaults', () => {
156183
it('should accept all valid GooglePayButtonType values', () => {
157184
const validTypes: GooglePayButtonType[] = [

src/__tests__/WalletTypes.test.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,26 @@ describe('GooglePay types', () => {
9898
expect(result.boltReference).toBeUndefined();
9999
});
100100

101-
it('GooglePayConfig should require gatewayMerchantId, merchantName, countryCode, currencyCode, totalPrice', () => {
101+
it('GooglePayConfig should accept presentation options only', () => {
102102
const config: GooglePayConfig = {
103-
gatewayMerchantId: 'BOLT_MERCHANT_ID',
104-
googleMerchantId: 'BCR2DN6T7654321',
105-
merchantName: 'Demo Store',
106-
countryCode: 'US',
107103
currencyCode: 'USD',
108-
totalPrice: '0.00',
109-
totalPriceStatus: 'ESTIMATED',
104+
amount: '0.00',
105+
label: 'Card Verification',
106+
billingAddressCollectionFormat: 'full',
110107
};
111108

112-
expect(config.gatewayMerchantId).toBe('BOLT_MERCHANT_ID');
113-
expect(config.googleMerchantId).toBe('BCR2DN6T7654321');
114-
expect(config.totalPrice).toBe('0.00');
115-
expect(config.totalPriceStatus).toBe('ESTIMATED');
109+
expect(config.currencyCode).toBe('USD');
110+
expect(config.amount).toBe('0.00');
111+
expect(config.label).toBe('Card Verification');
112+
expect(config.billingAddressCollectionFormat).toBe('full');
113+
});
114+
115+
it('GooglePayConfig should allow all fields to be optional', () => {
116+
const config: GooglePayConfig = {};
117+
118+
expect(config.currencyCode).toBeUndefined();
119+
expect(config.amount).toBeUndefined();
120+
expect(config.label).toBeUndefined();
121+
expect(config.billingAddressCollectionFormat).toBeUndefined();
116122
});
117123
});

src/client/Bolt.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ const ENVIRONMENT_URLS: Record<string, string> = {
1414
staging: 'https://connect-staging.bolt.com',
1515
};
1616

17+
const API_URLS: Record<string, string> = {
18+
production: 'https://api.bolt.com',
19+
sandbox: 'https://api-sandbox.bolt.com',
20+
staging: 'https://api-staging.bolt.com',
21+
};
22+
1723
export class Bolt {
1824
public readonly publishableKey: string;
1925
public readonly baseUrl: string;
26+
public readonly apiUrl: string;
2027
public readonly language: string;
2128
private onPageStyles?: Styles;
2229

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

35+
const env = config.environment ?? 'production';
2836
this.publishableKey = config.publishableKey;
29-
this.baseUrl =
30-
ENVIRONMENT_URLS[config.environment ?? 'production'] ??
31-
ENVIRONMENT_URLS.production!;
37+
this.baseUrl = ENVIRONMENT_URLS[env] ?? ENVIRONMENT_URLS.production!;
38+
this.apiUrl = API_URLS[env] ?? API_URLS.production!;
3239
this.language = config.language ?? 'en';
3340

3441
initTelemetry(config);

src/native/NativeGooglePayButton.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { codegenNativeComponent } from 'react-native';
77

88
interface NativeProps extends ViewProps {
99
buttonType: string;
10+
buttonTheme?: string;
1011
borderRadius?: Float;
1112
onPress: BubblingEventHandler<{}>;
1213
}

0 commit comments

Comments
 (0)