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)}
))}
@@ -386,8 +387,8 @@ interface UsePaymentCheckoutReturn {
// Cart management
items: PaymentItem[]; // Current cart items
- total: number; // Total amount
- addItem: (label: string, amount: number, type?: "final" | "pending") => void;
+ total: number; // Exact decimal total (NaN if an amount is invalid)
+ addItem: (label: string, amount: number | string, type?: "final" | "pending") => void;
addItems: (items: Array<{ label; amount; type? }>) => void;
removeItem: (index: number) => void;
updateItem: (index: number, updates: Partial) => void;
@@ -463,14 +464,17 @@ interface PayServiceStatus {
Start a payment request.
```typescript
-const result = await HybridPaymentHandler.startPayment({
- countryCode: "US",
- currencyCode: "USD",
- merchantCapabilities: ["3DS"],
- supportedNetworks: ["visa", "mastercard", "amex"],
- paymentItems: [{ label: "Total", amount: 29.99, type: "final" }],
- googlePayMerchantId: "your_google_pay_merchant_id",
-});
+const result = await HybridPaymentHandler.startPayment(
+ // Validates amounts before the sheet opens; see normalizePaymentRequest
+ normalizePaymentRequest({
+ countryCode: "US",
+ currencyCode: "USD",
+ merchantCapabilities: ["3DS"],
+ supportedNetworks: ["visa", "mastercard", "amex"],
+ paymentItems: [{ label: "Total", amount: "29.99", type: "final" }],
+ googlePayMerchantId: "your_google_pay_merchant_id",
+ })
+);
```
**Options:**
@@ -617,6 +621,20 @@ const request = createPaymentRequest({
googlePayGateway: "stripe",
googlePayGatewayMerchantId: "your_merchant_id",
});
+// request.paymentItems[0].amount === "29.99"
+```
+
+Throws if the amount is invalid for the currency (see [Amounts](#amounts)).
+
+#### `normalizePaymentRequest(request)`
+
+Validate every amount in a request you built yourself and convert it to an exact decimal string. `createPaymentRequest` and `usePaymentCheckout` call it for you.
+
+```typescript
+import { normalizePaymentRequest } from "@gmisoftware/react-native-pay";
+
+normalizePaymentRequest({ ...request, paymentItems: [{ label: "Book", amount: 19.9, type: "final" }] });
+// paymentItems: [{ label: 'Book', amount: '19.90', type: 'final' }]
```
#### `createPaymentItem(label, amount, type)`
@@ -626,13 +644,13 @@ Create a payment item.
```typescript
import { createPaymentItem } from "@gmisoftware/react-native-pay";
-const item = createPaymentItem("Subscription", 29.99, "final");
-// { label: 'Subscription', amount: 29.99, type: 'final' }
+const item = createPaymentItem("Subscription", "29.99", "final");
+// { label: 'Subscription', amount: '29.99', type: 'final' }
```
#### `calculateTotal(items)`
-Calculate total from payment items.
+Calculate the exact decimal total of payment items.
```typescript
import { calculateTotal } from "@gmisoftware/react-native-pay";
@@ -664,10 +682,13 @@ formatNetworkName("amex"); // "American Express"
### Core Types
```typescript
+// Amount in the currency's major unit: "19.99" or 19.99 (see Amounts below)
+type PaymentAmount = number | string;
+
// Payment Item
interface PaymentItem {
label: string;
- amount: number;
+ amount: PaymentAmount;
type: "final" | "pending";
}
@@ -733,6 +754,16 @@ type PaymentNetwork =
type PaymentMethodType = "unknown" | "debit" | "credit" | "prepaid" | "store";
```
+### Amounts
+
+Amounts are numbers (`19.99`) or decimal strings (`"19.99"`) in the currency's major unit. Before a payment starts, the library validates them and sends them to native code as exact decimal strings (`NSDecimalNumber` on iOS, `BigDecimal` on Android), never through `Double`. The payment does not start when:
+
+- an amount is not a finite number or a plain decimal string (`NaN`, `"abc"`, `"1e3"`);
+- an amount has more decimal places than the currency allows (ISO 4217: `1.005` in USD, `"1200.5"` in JPY). This includes unrounded float results such as `0.1 + 0.2`; round them with `formatAmount(value, currencyCode)`;
+- the total is negative. Negative line items (discounts) are allowed.
+
+Google Pay accepts at most two decimal places, so three-decimal amounts (`"1.234"` KWD) fail on Android.
+
### Contact Types
```typescript
@@ -854,14 +885,14 @@ const handleAddToCart = (product) => {
const handleUpdateQuantity = (index, quantity) => {
const item = checkout.items[index];
checkout.updateItem(index, {
- amount: item.amount * quantity,
+ amount: formatAmount(Number(item.amount) * quantity),
});
};
// Apply discount
const handleApplyDiscount = (discountPercent) => {
const discountAmount = checkout.total * (discountPercent / 100);
- checkout.addItem("Discount", -discountAmount, "final");
+ checkout.addItem("Discount", formatAmount(-discountAmount), "final");
};
// Clear cart
diff --git a/docs/docs/api/hybrid-payment-handler.md b/docs/docs/api/hybrid-payment-handler.md
index da85c7d..96b1a41 100644
--- a/docs/docs/api/hybrid-payment-handler.md
+++ b/docs/docs/api/hybrid-payment-handler.md
@@ -28,15 +28,21 @@ const status = HybridPaymentHandler.payServiceStatus()
Starts the native payment flow with the given request. Returns a promise that resolves with the payment result.
+Pass the request through `normalizePaymentRequest` (or build it with `createPaymentRequest`) so amounts are validated before the sheet opens. See [`PaymentAmount`](/docs/api/types#paymentamount).
+
```ts
-const result = await HybridPaymentHandler.startPayment({
- countryCode: 'US',
- currencyCode: 'USD',
- merchantCapabilities: ['3DS'],
- supportedNetworks: ['visa', 'mastercard', 'amex'],
- paymentItems: [{ label: 'Total', amount: 29.99, type: 'final' }],
- googlePayMerchantId: 'your_google_pay_merchant_id',
-})
+import { HybridPaymentHandler, normalizePaymentRequest } from '@gmisoftware/react-native-pay'
+
+const result = await HybridPaymentHandler.startPayment(
+ normalizePaymentRequest({
+ countryCode: 'US',
+ currencyCode: 'USD',
+ merchantCapabilities: ['3DS'],
+ supportedNetworks: ['visa', 'mastercard', 'amex'],
+ paymentItems: [{ label: 'Total', amount: '29.99', type: 'final' }],
+ googlePayMerchantId: 'your_google_pay_merchant_id',
+ })
+)
// result: PaymentResult
```
diff --git a/docs/docs/api/types.md b/docs/docs/api/types.md
index 804adf1..8429e1c 100644
--- a/docs/docs/api/types.md
+++ b/docs/docs/api/types.md
@@ -2,6 +2,27 @@
TypeScript types exported from `@gmisoftware/react-native-pay`. Use them for request/result handling and server integration.
+## PaymentAmount
+
+A monetary amount in the currency's major unit (dollars, not cents).
+
+```ts
+type PaymentAmount = number | string
+```
+
+- **Decimal string** (`'19.99'`): exact. Must match `^-?\d+(\.\d+)?$`; no exponents, spaces, or thousands separators.
+- **Number** (`19.99`): read as the shortest decimal that round-trips, so `19.99` stays exactly `19.99`. Results of float arithmetic such as `0.1 + 0.2` (`0.30000000000000004`) fail validation instead of being rounded.
+
+Before a payment starts, every amount is validated and sent to native code as a decimal string with the currency's number of decimal places (ISO 4217: 2 for USD, 0 for JPY, 3 for KWD). Native code converts it with `NSDecimalNumber` (iOS) and `BigDecimal` (Android), never through `Double`. A payment does not start when:
+
+- an amount is not a finite number or a plain decimal string (`NaN`, `Infinity`, `'abc'`);
+- an amount has more decimal places than the currency allows (`1.005` in USD, `'1200.5'` in JPY);
+- the total of `paymentItems` is negative. Negative line items (discounts) are allowed.
+
+Google Pay accepts at most two decimal places, so on Android a three-decimal amount such as `'1.234'` KWD fails with an error.
+
+---
+
## PaymentItem
A single line item (label, amount, type).
@@ -9,7 +30,7 @@ A single line item (label, amount, type).
```ts
interface PaymentItem {
label: string
- amount: number
+ amount: PaymentAmount // number | string
type: 'final' | 'pending'
}
```
diff --git a/docs/docs/api/use-payment-checkout.md b/docs/docs/api/use-payment-checkout.md
index bd49688..3580f23 100644
--- a/docs/docs/api/use-payment-checkout.md
+++ b/docs/docs/api/use-payment-checkout.md
@@ -46,8 +46,8 @@ interface UsePaymentCheckoutReturn {
// Cart
items: PaymentItem[]
total: number
- addItem: (label: string, amount: number, type?: 'final' | 'pending') => void
- addItems: (items: Array<{ label: string; amount: number; type?: 'final' | 'pending' }>) => void
+ addItem: (label: string, amount: PaymentAmount, type?: 'final' | 'pending') => void
+ addItems: (items: Array<{ label: string; amount: PaymentAmount; type?: 'final' | 'pending' }>) => void
removeItem: (index: number) => void
updateItem: (index: number, updates: Partial) => void
clearItems: () => void
@@ -67,9 +67,10 @@ interface UsePaymentCheckoutReturn {
- **canMakePayments** — Whether the user can pay with Apple Pay / Google Pay on this device.
- **canSetupCards** — Whether the user can add or manage cards (e.g. in Wallet).
- **isCheckingStatus** — `true` while availability is being checked on mount.
-- **items** / **total** — Current cart and computed total.
+- **items** / **total** — Current cart and its exact decimal total, as a number (`NaN` if an amount is invalid). Amounts are `number | string` ([`PaymentAmount`](/docs/api/types#paymentamount)); use `formatAmount(item.amount, currencyCode)` to display them.
- **startPayment()** — Opens the native sheet. Returns `Promise`.
- Returns `null` and sets `error` when the cart is empty.
+ - Returns `null` and sets `error`, without opening the sheet, when an amount is invalid or the total is negative (see [`PaymentAmount`](/docs/api/types#paymentamount)).
- Returns `PaymentResult` (including `{ success: false, error }`) when the native flow completes with a failure result.
- Returns `null` if an exception is thrown while starting/processing the payment.
- With `onAuthorize`, resolves after your callback settles. A `'failure'` result becomes `{ success: false, error }` and sets `error`.
@@ -104,10 +105,10 @@ const {
// Single item
addItem('Product', 29.99)
-// Multiple items
+// Multiple items; amounts can be numbers or decimal strings
addItems([
{ label: 'Product', amount: 29.99 },
- { label: 'Shipping', amount: 5 },
+ { label: 'Shipping', amount: '5.00' },
{ label: 'Tax', amount: 2.8, type: 'final' },
])
diff --git a/docs/docs/api/utils.md b/docs/docs/api/utils.md
index c4ee03e..c1323a9 100644
--- a/docs/docs/api/utils.md
+++ b/docs/docs/api/utils.md
@@ -16,24 +16,55 @@ Builds a full `PaymentRequest` with one line item and sensible defaults.
**Defaults:** `countryCode: 'US'`, `currencyCode: 'USD'`, `supportedNetworks: [CommonNetworks]`, `merchantCapabilities: ['3DS']`.
+The amount is validated with [`normalizePaymentRequest`](#normalizepaymentrequestrequest), so `createPaymentRequest` throws for an invalid amount, and the returned request holds it as a decimal string.
+
```ts
import { createPaymentRequest } from '@gmisoftware/react-native-pay'
const request = createPaymentRequest({
- amount: 29.99,
+ amount: 29.99, // or '29.99'
label: 'Coffee Subscription',
countryCode: 'US',
currencyCode: 'USD',
// Optional: applePayMerchantIdentifier, googlePayMerchantId,
// googlePayEnvironment, googlePayGateway, googlePayGatewayMerchantId, etc.
})
-// request.paymentItems is a single item: [{ label: 'Coffee Subscription', amount: 29.99, type: 'final' }]
+// request.paymentItems is a single item: [{ label: 'Coffee Subscription', amount: '29.99', type: 'final' }]
```
Use with `HybridPaymentHandler.startPayment(request)` for a manual flow.
---
+## normalizePaymentRequest(request)
+
+Validates every amount in `paymentItems` and `shippingMethods` and returns a copy in which each amount is a decimal string with the currency's number of decimal places. See [`PaymentAmount`](./types.md#paymentamount) for the rules.
+
+Call it before passing a request you built yourself to `HybridPaymentHandler.startPayment`. `createPaymentRequest` and `usePaymentCheckout` already call it.
+
+```ts
+import { HybridPaymentHandler, normalizePaymentRequest } from '@gmisoftware/react-native-pay'
+
+const request = normalizePaymentRequest({
+ countryCode: 'US',
+ currencyCode: 'USD',
+ paymentItems: [
+ { label: 'Book', amount: 19.9, type: 'final' },
+ { label: 'Discount', amount: '-5', type: 'final' },
+ ],
+ supportedNetworks: ['visa', 'mastercard'],
+ merchantCapabilities: ['3DS'],
+})
+// request.paymentItems amounts: '19.90', '-5.00'
+
+await HybridPaymentHandler.startPayment(request)
+
+normalizePaymentRequest({ ...request, paymentItems: [{ label: 'Fee', amount: 1.005, type: 'final' }] })
+// throws: Amount for "Fee" has more than 2 decimal places, which USD allows, got 1.005
+```
+
+---
+
## createPaymentItem(label, amount, type?)
Creates a single `PaymentItem`.
@@ -42,37 +73,43 @@ Creates a single `PaymentItem`.
import { createPaymentItem } from '@gmisoftware/react-native-pay'
createPaymentItem('Subscription', 29.99, 'final')
-createPaymentItem('Shipping', 5, 'pending')
+createPaymentItem('Shipping', '5.00', 'pending')
// type defaults to 'final'
```
+The amount is stored as given and validated when the payment starts.
+
---
## calculateTotal(items)
-Sums the `amount` of all items.
+Sums the `amount` of all items with exact decimal arithmetic, the same as native code, and returns the sum as a number. Returns `NaN` if an amount is invalid.
```ts
import { calculateTotal, createPaymentItem } from '@gmisoftware/react-native-pay'
const items = [
createPaymentItem('Product', 29.99),
- createPaymentItem('Tax', 2.4),
+ createPaymentItem('Tax', '2.40'),
]
calculateTotal(items) // 32.39
+
+calculateTotal([createPaymentItem('A', 0.1), createPaymentItem('B', 0.2)]) // 0.3, not 0.30000000000000004
```
---
-## formatAmount(amount)
+## formatAmount(amount, currencyCode?)
-Formats a number to two decimal places.
+Formats a number or decimal string with the currency's number of decimal places (two when `currencyCode` is omitted), rounding half away from zero. Returns `'NaN'` for an invalid amount.
```ts
import { formatAmount } from '@gmisoftware/react-native-pay'
-formatAmount(29.9) // "29.90"
-formatAmount(100) // "100.00"
+formatAmount(29.9) // "29.90"
+formatAmount('100') // "100.00"
+formatAmount(1.005) // "1.01"
+formatAmount(1500, 'JPY') // "1500"
```
---
diff --git a/docs/docs/guides/dynamic-cart.md b/docs/docs/guides/dynamic-cart.md
index 81a7476..cba7025 100644
--- a/docs/docs/guides/dynamic-cart.md
+++ b/docs/docs/guides/dynamic-cart.md
@@ -39,17 +39,19 @@ clearItems() // empty the cart
## Example: quantity and discount
```ts
+import { formatAmount } from '@gmisoftware/react-native-pay'
+
const { items, addItem, updateItem, removeItem, total } = usePaymentCheckout({ ... })
// Add product with quantity
const handleAddProduct = (name: string, unitPrice: number, quantity: number) => {
- addItem(`${name} (×${quantity})`, unitPrice * quantity)
+ // 1.1 * 3 is 3.3000000000000003; round it to the currency's decimals
+ addItem(`${name} (×${quantity})`, formatAmount(unitPrice * quantity))
}
-// Apply percentage discount
+// Apply percentage discount (a negative line item)
const handleApplyDiscount = (percent: number) => {
- const discount = -total * (percent / 100)
- addItem('Discount', discount, 'final')
+ addItem('Discount', formatAmount(-total * (percent / 100)), 'final')
}
// Clear cart
@@ -61,6 +63,8 @@ const handleClearCart = () => {
## Rules
- **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.
+- **total** is derived from `items`; you don’t set it manually. It is an exact decimal sum.
+- **Round computed amounts** with `formatAmount(value, currencyCode)`. `startPayment()` rejects amounts with more decimal places than the currency allows, which includes unrounded float results such as `1.1 * 3`. See [`PaymentAmount`](/docs/api/types#paymentamount).
+- **Discounts** can be negative line items, as long as the total is not negative.
- 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. 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).
diff --git a/docs/docs/guides/manual-flow.md b/docs/docs/guides/manual-flow.md
index f057d56..55593a7 100644
--- a/docs/docs/guides/manual-flow.md
+++ b/docs/docs/guides/manual-flow.md
@@ -33,7 +33,7 @@ const paymentRequest = createPaymentRequest({
})
```
-For multiple line items, build `paymentItems` yourself and pass the full `PaymentRequest`. On iOS, the Merchant ID is read from the app entitlements unless you explicitly pass `applePayMerchantIdentifier`.
+`createPaymentRequest` throws if the amount is invalid (see [`PaymentAmount`](/docs/api/types#paymentamount)). For multiple line items, build `paymentItems` yourself and pass the full `PaymentRequest` through `normalizePaymentRequest`, which applies the same checks. On iOS, the Merchant ID is read from the app entitlements unless you explicitly pass `applePayMerchantIdentifier`.
## 3. Handle the button press
diff --git a/docs/docs/quick-start.md b/docs/docs/quick-start.md
index 02c3c30..25b460a 100644
--- a/docs/docs/quick-start.md
+++ b/docs/docs/quick-start.md
@@ -50,6 +50,7 @@ import {
usePaymentCheckout,
ApplePayButton,
GooglePayButton,
+ formatAmount,
} from '@gmisoftware/react-native-pay'
import { Platform, View, Text, Button, ActivityIndicator } from 'react-native'
@@ -98,7 +99,7 @@ function CheckoutScreen() {