From 5d7be79e7a54d090c94823071361cb5c79130363 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Mon, 19 Oct 2026 20:53:58 +0200 Subject: [PATCH 1/2] fix(android): send valid Google Pay display items and estimated totals Pending cart items were sent as DisplayItem.type "PENDING", which is not a valid type, and the total was always FINAL. Every item is now a LINE_ITEM with status FINAL or PENDING, and any pending item makes totalPriceStatus ESTIMATED. On iOS the synthesised total becomes .pending in the same case. merchantName no longer falls back to "Example Merchant". Android uses the app label instead, and omits the field if that is blank too. Adds JVM unit tests for GooglePayRequestBuilder and keeps them out of the npm tarball. Closes #12 --- README.md | 3 + docs/docs/api/types.md | 4 + docs/docs/guides/dynamic-cart.md | 1 + docs/docs/setup/android-google-pay.md | 3 + package/android/build.gradle | 4 + .../java/com/margelo/nitro/pay/Constants.kt | 7 +- .../nitro/pay/GooglePayRequestBuilder.kt | 38 +++- .../margelo/nitro/pay/HybridPaymentHandler.kt | 12 +- .../nitro/pay/GooglePayRequestBuilderTest.kt | 213 ++++++++++++++++++ package/ios/HybridPaymentHandler.swift | 3 +- package/package.json | 1 + 11 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 package/android/src/test/java/com/margelo/nitro/pay/GooglePayRequestBuilderTest.kt diff --git a/README.md b/README.md index d07c642..e05df7c 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,7 @@ For Google Pay, you need to configure your payment gateway: ```typescript const checkout = usePaymentCheckout({ + merchantName: "Your Store", // shown on the Google Pay sheet currencyCode: "USD", countryCode: "US", // Google Pay specific @@ -333,6 +334,8 @@ const checkout = usePaymentCheckout({ }); ``` +Without `merchantName`, the Google Pay sheet shows your app's label. If any cart item is `pending`, the total is sent as an estimate (`ESTIMATED` on Google Pay, `.pending` on Apple Pay). + #### Supported Payment Gateways - **Stripe** - `googlePayGateway: 'stripe'` diff --git a/docs/docs/api/types.md b/docs/docs/api/types.md index e8be617..63c1c9e 100644 --- a/docs/docs/api/types.md +++ b/docs/docs/api/types.md @@ -19,6 +19,8 @@ interface PaymentItem { - **final** — Confirmed amount (e.g. product price, tax). - **pending** — Estimate (e.g. shipping that may change). +If any item is `pending`, the total is an estimate too: Google Pay receives `totalPriceStatus: 'ESTIMATED'` and Apple Pay marks the total summary item as `.pending`. Every item is sent to Google Pay as a `LINE_ITEM` display item, with `status` set to `FINAL` or `PENDING`. + --- ## PaymentRequest @@ -48,6 +50,8 @@ interface PaymentRequest { On iOS, `applePayMerchantIdentifier` is optional and only needed when you want to override the Merchant ID configured in the native app entitlements. +`merchantName` is the name shown on the Google Pay sheet and the label of the Apple Pay total (when the cart has more than one item). If it is missing or blank, Android uses the app's label from the manifest; iOS labels the total "Total". + --- ## PaymentResult diff --git a/docs/docs/guides/dynamic-cart.md b/docs/docs/guides/dynamic-cart.md index a264616..81d8482 100644 --- a/docs/docs/guides/dynamic-cart.md +++ b/docs/docs/guides/dynamic-cart.md @@ -63,3 +63,4 @@ const handleClearCart = () => { - **At least one item** is required before `startPayment()`. Otherwise the hook sets an error and returns `null`. - **total** is derived from `items`; you don’t set it manually. - Use **type: 'final'** for confirmed amounts (e.g. tax, total); use **'pending'** for estimates (e.g. shipping) if your flow supports it. +- **One pending item makes the total an estimate.** Google Pay shows the total as estimated (`totalPriceStatus: 'ESTIMATED'`) and Apple Pay marks the total as pending. Charge the final amount on your server once it is known. diff --git a/docs/docs/setup/android-google-pay.md b/docs/docs/setup/android-google-pay.md index ac60784..83f8c95 100644 --- a/docs/docs/setup/android-google-pay.md +++ b/docs/docs/setup/android-google-pay.md @@ -31,6 +31,7 @@ Google Pay needs a **Google Pay merchant ID** for production, plus your **paymen ```ts usePaymentCheckout({ + merchantName: 'Your Store', // shown on the Google Pay sheet currencyCode: 'USD', countryCode: 'US', googlePayMerchantId: 'your_google_pay_merchant_id', @@ -40,6 +41,8 @@ usePaymentCheckout({ }) ``` +If you omit `merchantName`, the Google Pay sheet shows your app's label (`android:label` in the manifest). + Supported gateways include: `stripe`, `braintree`, `square`, `adyen`, `authorizenet`, `checkoutltd`. See [Google Pay Gateway Tokens](https://developers.google.com/pay/api/android/reference/request-objects#gateway) for the full list and exact identifiers. ## 4. Prebuild and run diff --git a/package/android/build.gradle b/package/android/build.gradle index eab7b2f..907f7d8 100644 --- a/package/android/build.gradle +++ b/package/android/build.gradle @@ -147,5 +147,9 @@ dependencies { // Google Pay dependencies implementation 'com.google.android.gms:play-services-wallet:19.3.0' + + testImplementation 'junit:junit:4.13.2' + // android.jar only ships org.json stubs; JVM tests need the real implementation. + testImplementation 'org.json:json:20250517' } diff --git a/package/android/src/main/java/com/margelo/nitro/pay/Constants.kt b/package/android/src/main/java/com/margelo/nitro/pay/Constants.kt index c7e5dcf..c81db8c 100644 --- a/package/android/src/main/java/com/margelo/nitro/pay/Constants.kt +++ b/package/android/src/main/java/com/margelo/nitro/pay/Constants.kt @@ -31,12 +31,11 @@ object PaymentConstants { // Transaction const val TOTAL_PRICE_STATUS_FINAL = "FINAL" + const val TOTAL_PRICE_STATUS_ESTIMATED = "ESTIMATED" const val TOTAL_PRICE_LABEL_DEFAULT = "Total" const val LINE_ITEM_TYPE = "LINE_ITEM" - const val PENDING_TYPE = "PENDING" - - // Merchant - const val DEFAULT_MERCHANT_NAME = "Example Merchant" + const val DISPLAY_ITEM_STATUS_FINAL = "FINAL" + const val DISPLAY_ITEM_STATUS_PENDING = "PENDING" // Supported networks (lowercase for comparison) val SUPPORTED_NETWORKS = listOf("visa", "mastercard", "amex", "discover") diff --git a/package/android/src/main/java/com/margelo/nitro/pay/GooglePayRequestBuilder.kt b/package/android/src/main/java/com/margelo/nitro/pay/GooglePayRequestBuilder.kt index 93fdd00..90a4da0 100644 --- a/package/android/src/main/java/com/margelo/nitro/pay/GooglePayRequestBuilder.kt +++ b/package/android/src/main/java/com/margelo/nitro/pay/GooglePayRequestBuilder.kt @@ -29,17 +29,21 @@ object GooglePayRequestBuilder { /** * Creates a complete payment data request + * + * @param fallbackMerchantName shown when the request has no merchantName, + * typically the app label. When both are blank, merchantName is omitted. */ fun createPaymentDataRequest( request: PaymentRequest, - environment: Int + environment: Int, + fallbackMerchantName: String? = null ): JSONObject { validatePaymentRequest(request, environment) return JSONObject().apply { put("apiVersion", PaymentConstants.API_VERSION) put("apiVersionMinor", PaymentConstants.API_VERSION_MINOR) - put("merchantInfo", createMerchantInfo(request, environment)) + put("merchantInfo", createMerchantInfo(request, environment, fallbackMerchantName)) put("allowedPaymentMethods", JSONArray().apply { put(createCardPaymentMethod(request, environment)) }) @@ -52,10 +56,14 @@ object GooglePayRequestBuilder { */ private fun createMerchantInfo( request: PaymentRequest, - environment: Int + environment: Int, + fallbackMerchantName: String? ): JSONObject { + val merchantName = request.merchantName?.trim().takeUnless { it.isNullOrEmpty() } + ?: fallbackMerchantName?.trim().takeUnless { it.isNullOrEmpty() } + return JSONObject().apply { - put("merchantName", request.merchantName ?: PaymentConstants.DEFAULT_MERCHANT_NAME) + merchantName?.let { put("merchantName", it) } // Add merchant ID only for PRODUCTION environment if (environment == WalletConstants.ENVIRONMENT_PRODUCTION) { put("merchantId", request.googlePayMerchantId) @@ -137,9 +145,16 @@ object GooglePayRequestBuilder { */ private fun createTransactionInfo(request: PaymentRequest): JSONObject { val totalAmount = request.paymentItems.sumOf { it.amount } + val hasPendingItems = request.paymentItems.any { it.type == PaymentItemType.PENDING } return JSONObject().apply { - put("totalPriceStatus", PaymentConstants.TOTAL_PRICE_STATUS_FINAL) + put( + "totalPriceStatus", + if (hasPendingItems) + PaymentConstants.TOTAL_PRICE_STATUS_ESTIMATED + else + PaymentConstants.TOTAL_PRICE_STATUS_FINAL + ) put("totalPrice", formatPrice(totalAmount)) put("totalPriceLabel", PaymentConstants.TOTAL_PRICE_LABEL_DEFAULT) put("currencyCode", request.currencyCode) @@ -160,14 +175,15 @@ object GooglePayRequestBuilder { items.forEach { item -> put(JSONObject().apply { put("label", item.label) + put("type", PaymentConstants.LINE_ITEM_TYPE) + put("price", formatPrice(item.amount)) put( - "type", - if (item.type == PaymentItemType.FINAL) - PaymentConstants.LINE_ITEM_TYPE - else - PaymentConstants.PENDING_TYPE + "status", + if (item.type == PaymentItemType.PENDING) + PaymentConstants.DISPLAY_ITEM_STATUS_PENDING + else + PaymentConstants.DISPLAY_ITEM_STATUS_FINAL ) - put("price", formatPrice(item.amount)) }) } } diff --git a/package/android/src/main/java/com/margelo/nitro/pay/HybridPaymentHandler.kt b/package/android/src/main/java/com/margelo/nitro/pay/HybridPaymentHandler.kt index 8956a28..6f5c475 100644 --- a/package/android/src/main/java/com/margelo/nitro/pay/HybridPaymentHandler.kt +++ b/package/android/src/main/java/com/margelo/nitro/pay/HybridPaymentHandler.kt @@ -79,7 +79,8 @@ class HybridPaymentHandler : HybridPaymentHandlerSpec(), ActivityEventListener { val paymentDataRequest = GooglePayRequestBuilder.createPaymentDataRequest( request, - environment + environment, + fallbackMerchantName = appLabel() ) logPaymentRequest(paymentDataRequest, environment) @@ -147,6 +148,15 @@ class HybridPaymentHandler : HybridPaymentHandlerSpec(), ActivityEventListener { return Wallet.getPaymentsClient(reactContext, walletOptions) } + private fun appLabel(): String? { + return try { + reactContext.applicationInfo.loadLabel(reactContext.packageManager).toString() + } catch (e: Exception) { + Log.w(PaymentConstants.TAG_PAYMENT_HANDLER, "Unable to read app label", e) + null + } + } + private fun determineEnvironment(envConfig: GooglePayEnvironment?): Int { return when (envConfig) { GooglePayEnvironment.PRODUCTION -> WalletConstants.ENVIRONMENT_PRODUCTION diff --git a/package/android/src/test/java/com/margelo/nitro/pay/GooglePayRequestBuilderTest.kt b/package/android/src/test/java/com/margelo/nitro/pay/GooglePayRequestBuilderTest.kt new file mode 100644 index 0000000..e4b3f0a --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/pay/GooglePayRequestBuilderTest.kt @@ -0,0 +1,213 @@ +package com.margelo.nitro.pay + +import com.google.android.gms.wallet.WalletConstants +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class GooglePayRequestBuilderTest { + + private fun item(label: String, amount: Double, type: PaymentItemType) = + PaymentItem(label = label, amount = amount, type = type) + + private fun paymentRequest( + items: Array, + merchantName: String? = null, + environment: GooglePayEnvironment? = null, + googlePayMerchantId: String? = null, + googlePayGateway: String? = null, + googlePayGatewayMerchantId: String? = null + ) = PaymentRequest( + applePayMerchantIdentifier = null, + merchantName = merchantName, + countryCode = "US", + currencyCode = "USD", + paymentItems = items, + merchantCapabilities = arrayOf("3DS"), + supportedNetworks = arrayOf("visa"), + shippingType = null, + shippingMethods = null, + billingContactRequired = null, + shippingContactRequired = null, + googlePayMerchantId = googlePayMerchantId, + googlePayEnvironment = environment, + googlePayGateway = googlePayGateway, + googlePayGatewayMerchantId = googlePayGatewayMerchantId + ) + + private fun build( + request: PaymentRequest, + environment: Int = WalletConstants.ENVIRONMENT_TEST, + fallbackMerchantName: String? = null + ): JSONObject = GooglePayRequestBuilder.createPaymentDataRequest( + request, + environment, + fallbackMerchantName + ) + + private fun JSONObject.displayItems(): List { + val items = getJSONObject("transactionInfo").getJSONArray("displayItems") + return (0 until items.length()).map { items.getJSONObject(it) } + } + + private fun JSONObject.totalPriceStatus(): String = + getJSONObject("transactionInfo").getString("totalPriceStatus") + + // Display items and total status + + @Test + fun finalOnlyCart_sendsLineItemsAndFinalTotal() { + val json = build( + paymentRequest( + arrayOf( + item("Coffee", 4.99, PaymentItemType.FINAL), + item("Tax", 0.40, PaymentItemType.FINAL) + ) + ) + ) + + val items = json.displayItems() + assertEquals(listOf("LINE_ITEM", "LINE_ITEM"), items.map { it.getString("type") }) + assertEquals(listOf("FINAL", "FINAL"), items.map { it.getString("status") }) + assertEquals(listOf("4.99", "0.40"), items.map { it.getString("price") }) + assertEquals("FINAL", json.totalPriceStatus()) + assertEquals("5.39", json.getJSONObject("transactionInfo").getString("totalPrice")) + } + + @Test + fun mixedCart_marksPendingItemsAndEstimatesTotal() { + val json = build( + paymentRequest( + arrayOf( + item("Coffee", 4.99, PaymentItemType.FINAL), + item("Shipping (estimate)", 5.0, PaymentItemType.PENDING) + ) + ) + ) + + val items = json.displayItems() + assertEquals(listOf("LINE_ITEM", "LINE_ITEM"), items.map { it.getString("type") }) + assertEquals(listOf("FINAL", "PENDING"), items.map { it.getString("status") }) + assertEquals("ESTIMATED", json.totalPriceStatus()) + assertEquals("9.99", json.getJSONObject("transactionInfo").getString("totalPrice")) + } + + @Test + fun pendingOnlyCart_marksAllItemsPendingAndEstimatesTotal() { + val json = build( + paymentRequest( + arrayOf( + item("Deposit", 20.0, PaymentItemType.PENDING), + item("Shipping (estimate)", 5.0, PaymentItemType.PENDING) + ) + ) + ) + + val items = json.displayItems() + assertEquals(listOf("LINE_ITEM", "LINE_ITEM"), items.map { it.getString("type") }) + assertEquals(listOf("PENDING", "PENDING"), items.map { it.getString("status") }) + assertEquals("ESTIMATED", json.totalPriceStatus()) + } + + @Test + fun displayItemTypeIsNeverAStatusValue() { + val json = build( + paymentRequest(arrayOf(item("Shipping", 5.0, PaymentItemType.PENDING))) + ) + + assertFalse(json.toString().contains("\"type\":\"PENDING\"")) + } + + // Merchant name + + private fun JSONObject.merchantInfo(): JSONObject = getJSONObject("merchantInfo") + + private val coffee = arrayOf(item("Coffee", 4.99, PaymentItemType.FINAL)) + + @Test + fun merchantName_usesTrimmedRequestValue() { + val json = build( + paymentRequest(coffee, merchantName = " Coffee Shop "), + fallbackMerchantName = "My App" + ) + + assertEquals("Coffee Shop", json.merchantInfo().getString("merchantName")) + } + + @Test + fun merchantName_fallsBackWhenMissing() { + val json = build(paymentRequest(coffee), fallbackMerchantName = "My App") + + assertEquals("My App", json.merchantInfo().getString("merchantName")) + } + + @Test + fun merchantName_fallsBackWhenBlank() { + val json = build( + paymentRequest(coffee, merchantName = " "), + fallbackMerchantName = "My App" + ) + + assertEquals("My App", json.merchantInfo().getString("merchantName")) + } + + @Test + fun merchantName_isOmittedWithoutRequestValueOrFallback() { + val json = build(paymentRequest(coffee), fallbackMerchantName = " ") + + assertFalse(json.merchantInfo().has("merchantName")) + assertFalse(json.toString().contains("Example Merchant")) + } + + @Test + fun production_usesFallbackMerchantNameAndMerchantId() { + val json = build( + paymentRequest( + coffee, + environment = GooglePayEnvironment.PRODUCTION, + googlePayMerchantId = "BCR2DN4T000000", + googlePayGateway = "stripe", + googlePayGatewayMerchantId = "acct_123" + ), + environment = WalletConstants.ENVIRONMENT_PRODUCTION, + fallbackMerchantName = "My App" + ) + + val merchantInfo = json.merchantInfo() + assertEquals("My App", merchantInfo.getString("merchantName")) + assertEquals("BCR2DN4T000000", merchantInfo.getString("merchantId")) + } + + // PRODUCTION validation (regression) + + @Test + fun production_requiresGatewayConfiguration() { + val error = assertThrows(IllegalArgumentException::class.java) { + build( + paymentRequest(coffee, googlePayMerchantId = "BCR2DN4T000000"), + environment = WalletConstants.ENVIRONMENT_PRODUCTION + ) + } + + assertTrue(error.message!!.contains("googlePayGateway")) + } + + @Test + fun production_requiresMerchantId() { + val error = assertThrows(IllegalArgumentException::class.java) { + build( + paymentRequest( + coffee, + googlePayGateway = "stripe", + googlePayGatewayMerchantId = "acct_123" + ), + environment = WalletConstants.ENVIRONMENT_PRODUCTION + ) + } + + assertTrue(error.message!!.contains("googlePayMerchantId")) + } +} diff --git a/package/ios/HybridPaymentHandler.swift b/package/ios/HybridPaymentHandler.swift index 7872fa0..e35204f 100644 --- a/package/ios/HybridPaymentHandler.swift +++ b/package/ios/HybridPaymentHandler.swift @@ -97,7 +97,8 @@ private struct PaymentRequestBuilder { label: totalLabel, amount: NSDecimalNumber(decimal: totalAmount) ) - totalItem.type = .final + // The total is only as final as its least final item. + totalItem.type = request.paymentItems.contains { $0.type == .pending } ? .pending : .final return lineItems + [totalItem] } diff --git a/package/package.json b/package/package.json index 46380ed..f6f5010 100644 --- a/package/package.json +++ b/package/package.json @@ -82,6 +82,7 @@ "android/fix-prefab.gradle", "android/CMakeLists.txt", "android/src", + "!android/src/test", "ios/**/*.h", "ios/**/*.m", "ios/**/*.mm", From 1f6107740fa60e0b593fb67a449e658437ee148d Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Mon, 19 Oct 2026 21:07:31 +0200 Subject: [PATCH 2/2] docs: don't suggest charging a different amount than the estimate Google Pay requires the charged amount to match totalPrice; an ESTIMATED total does not lift that. Point to a new payment with the final amount, or a gateway that supports adjusting it. --- docs/docs/guides/dynamic-cart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/guides/dynamic-cart.md b/docs/docs/guides/dynamic-cart.md index 81d8482..81a7476 100644 --- a/docs/docs/guides/dynamic-cart.md +++ b/docs/docs/guides/dynamic-cart.md @@ -63,4 +63,4 @@ const handleClearCart = () => { - **At least one item** is required before `startPayment()`. Otherwise the hook sets an error and returns `null`. - **total** is derived from `items`; you don’t set it manually. - Use **type: 'final'** for confirmed amounts (e.g. tax, total); use **'pending'** for estimates (e.g. shipping) if your flow supports it. -- **One pending item makes the total an estimate.** Google Pay shows the total as estimated (`totalPriceStatus: 'ESTIMATED'`) and Apple Pay marks the total as pending. Charge the final amount on your server once it is known. +- **One pending item makes the total an estimate.** Google Pay shows the total as estimated (`totalPriceStatus: 'ESTIMATED'`) and Apple Pay marks the total as pending. An estimate does not let you charge a different amount: Google Pay requires the amount you authorize and charge to match the `totalPrice` shown on the sheet, and a mismatch can be declined. If the final amount differs from the estimate, start a new payment with the final amount, unless your gateway explicitly supports adjusting it (for example, incremental authorization).