From f9333ca30c1f77f2713418dd9d479aeaf25ae8fa Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 20 Oct 2026 10:34:36 +0200 Subject: [PATCH] fix: send exact decimal amounts to Apple Pay and Google Pay - `PaymentItem.amount` is now `PaymentAmount` (`number | string`). iOS parses amounts with `NSDecimalNumber(string:)` instead of `Decimal(Double)`, so 19.99 reaches PassKit as 19.99, not 19.98999999999999488. Android parses and sums them as `BigDecimal`. Numbers are read through their shortest round-trip form. - New `normalizePaymentRequest`, used by `createPaymentRequest` and the hook's `startPayment`, rejects NaN, malformed strings, more decimals than the currency allows (ISO 4217) and a negative total before any native call, then sends amounts as decimal strings. Negative line items (discounts) stay allowed. - `calculateTotal`, the hook's `total` and `formatAmount` use exact decimal arithmetic; `formatAmount` accepts strings and a currency. - Native code rejects unparseable amounts and a negative total; Android also rejects a third decimal place instead of rounding. - Docs, README, CHANGELOG and the example app use `formatAmount`. Closes #18 --- CHANGELOG.md | 20 ++ README.md | 65 +++-- docs/docs/api/hybrid-payment-handler.md | 22 +- docs/docs/api/types.md | 23 +- docs/docs/api/use-payment-checkout.md | 11 +- docs/docs/api/utils.md | 55 +++- docs/docs/guides/dynamic-cart.md | 14 +- docs/docs/guides/manual-flow.md | 2 +- docs/docs/quick-start.md | 3 +- example/app/(tabs)/index.tsx | 3 +- .../nitro/pay/GooglePayRequestBuilder.kt | 27 +- .../com/margelo/nitro/pay/PaymentAmounts.kt | 32 +++ .../nitro/pay/GooglePayRequestBuilderTest.kt | 72 ++++- .../margelo/nitro/pay/PaymentAmountsTest.kt | 57 ++++ package/ios/HybridPaymentHandler.swift | 49 +++- package/ios/PaymentAmount+Decimal.swift | 28 ++ .../android/NitroPay+autolinking.cmake | 1 + .../android/c++/JHybridPaymentHandlerSpec.cpp | 2 + .../generated/android/c++/JPaymentAmount.cpp | 26 ++ .../generated/android/c++/JPaymentAmount.hpp | 68 +++++ .../generated/android/c++/JPaymentItem.hpp | 12 +- .../generated/android/c++/JPaymentRequest.hpp | 2 + .../com/margelo/nitro/pay/PaymentAmount.kt | 62 +++++ .../com/margelo/nitro/pay/PaymentItem.kt | 4 +- .../ios/NitroPay-Swift-Cxx-Bridge.hpp | 30 +++ .../ios/NitroPay-Swift-Cxx-Umbrella.hpp | 1 + .../ios/c++/HybridPaymentHandlerSpecSwift.hpp | 1 + .../generated/ios/swift/PaymentAmount.swift | 30 +++ .../generated/ios/swift/PaymentItem.swift | 27 +- .../generated/shared/c++/PaymentItem.hpp | 11 +- .../usePaymentCheckout.integration.test.ts | 111 +++++++- package/src/hooks/usePaymentCheckout.ts | 35 ++- package/src/types/Payment.ts | 11 +- package/src/utils/__tests__/amounts.test.ts | 147 ++++++++++ .../utils/__tests__/paymentHelpers.test.ts | 49 +++- package/src/utils/amounts.ts | 250 ++++++++++++++++++ package/src/utils/index.ts | 1 + package/src/utils/paymentHelpers.ts | 65 +++-- 38 files changed, 1313 insertions(+), 116 deletions(-) create mode 100644 package/android/src/main/java/com/margelo/nitro/pay/PaymentAmounts.kt create mode 100644 package/android/src/test/java/com/margelo/nitro/pay/PaymentAmountsTest.kt create mode 100644 package/ios/PaymentAmount+Decimal.swift create mode 100644 package/nitrogen/generated/android/c++/JPaymentAmount.cpp create mode 100644 package/nitrogen/generated/android/c++/JPaymentAmount.hpp create mode 100644 package/nitrogen/generated/android/kotlin/com/margelo/nitro/pay/PaymentAmount.kt create mode 100644 package/nitrogen/generated/ios/swift/PaymentAmount.swift create mode 100644 package/src/utils/__tests__/amounts.test.ts create mode 100644 package/src/utils/amounts.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 88066c6..828282e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,28 @@ All notable changes to this project are documented in this file. ## Unreleased +### Changed + +- **Amounts can be decimal strings, and `PaymentItem.amount` is now `number | string`** + (`PaymentAmount`). Code that reads `item.amount` as a number no longer type-checks; display + amounts with `formatAmount(item.amount, currencyCode)`, which now accepts strings and an optional + currency code. Requests from `createPaymentRequest` hold their amount as a decimal string. +- **Payments with invalid amounts no longer start.** `usePaymentCheckout().startPayment()` sets + `error` and returns `null`, and `createPaymentRequest` throws, when an amount is `NaN` or + infinite, has more decimal places than the currency allows (ISO 4217: `1.005` USD, `1200.5` + JPY, including float results such as `0.1 + 0.2`), or the total is negative. Negative line items + (discounts) are still allowed. For requests built by hand, call the new + `normalizePaymentRequest` before `HybridPaymentHandler.startPayment`. + ### Fixed +- **Amounts reach Apple Pay and Google Pay exactly.** iOS converted amounts with `Decimal(Double)`, + so `19.99` reached PassKit as `19.98999999999999488`. Amounts now cross into native code as + decimal strings, iOS parses them with `NSDecimalNumber` and Android with `BigDecimal`, and both + sum them exactly. `calculateTotal` and the hook's `total` use the same exact arithmetic. Google + Pay prices for zero-decimal currencies validated by `normalizePaymentRequest` are sent without + decimals (`"1500"` instead of `"1500.00"`), and Android rejects a negative total or a third + decimal place instead of rounding. - **Installing the package no longer runs `tsc` in your project.** 0.0.17 and earlier shipped a `postinstall` script (`tsc || exit 0`). The package does not include its own `tsconfig.json`, so `tsc` picked up the app's `tsconfig.json` and type-checked the whole app on every install; with a diff --git a/README.md b/README.md index 5b90856..6fba0f9 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ import { usePaymentCheckout, ApplePayButton, GooglePayButton, + formatAmount, } from '@gmisoftware/react-native-pay' import { Platform, View, Text, Button, ActivityIndicator } from 'react-native' @@ -258,7 +259,7 @@ function CheckoutScreen() { {items.map((item, index) => ( - {item.label}: ${item.amount.toFixed(2)} + {item.label}: ${formatAmount(item.amount)}