Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 48 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ import {
usePaymentCheckout,
ApplePayButton,
GooglePayButton,
formatAmount,
} from '@gmisoftware/react-native-pay'
import { Platform, View, Text, Button, ActivityIndicator } from 'react-native'

Expand Down Expand Up @@ -258,7 +259,7 @@ function CheckoutScreen() {

{items.map((item, index) => (
<View key={index} style={{ flexDirection: 'row', padding: 10 }}>
<Text>{item.label}: ${item.amount.toFixed(2)}</Text>
<Text>{item.label}: ${formatAmount(item.amount)}</Text>
<Button title="Remove" onPress={() => removeItem(index)} />
</View>
))}
Expand Down Expand Up @@ -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<PaymentItem>) => void;
Expand Down Expand Up @@ -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:**
Expand Down Expand Up @@ -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)`
Expand All @@ -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";
Expand Down Expand Up @@ -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";
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 14 additions & 8 deletions docs/docs/api/hybrid-payment-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
23 changes: 22 additions & 1 deletion docs/docs/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,35 @@

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).

```ts
interface PaymentItem {
label: string
amount: number
amount: PaymentAmount // number | string
type: 'final' | 'pending'
}
```
Expand Down
11 changes: 6 additions & 5 deletions docs/docs/api/use-payment-checkout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaymentItem>) => void
clearItems: () => void
Expand All @@ -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<PaymentResult | null>`.
- 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`.
Expand Down Expand Up @@ -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' },
])

Expand Down
55 changes: 46 additions & 9 deletions docs/docs/api/utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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"
```

---
Expand Down
Loading
Loading