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
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ interface UsePaymentCheckoutConfig {
googlePayEnvironment?: "TEST" | "PRODUCTION";
googlePayGateway?: string;
googlePayGatewayMerchantId?: string;
// Charge the token while the sheet waits (see "Charging before the sheet closes")
onAuthorize?: (token: PaymentToken) => Promise<PaymentAuthorizationResult>;
}
```

Expand Down Expand Up @@ -456,7 +458,7 @@ interface PayServiceStatus {
}
```

##### `startPayment(request: PaymentRequest): Promise<PaymentResult>`
##### `startPayment(request: PaymentRequest, options?: StartPaymentOptions): Promise<PaymentResult>`

Start a payment request.

Expand All @@ -471,6 +473,20 @@ const result = await HybridPaymentHandler.startPayment({
});
```

**Options:**

```typescript
interface StartPaymentOptions {
// Charge the token while the sheet waits (see "Charging before the sheet closes")
onAuthorize?: (token: PaymentToken) => Promise<PaymentAuthorizationResult>;
}

interface PaymentAuthorizationResult {
status: "success" | "failure";
error?: string; // Becomes PaymentResult.error on failure
}
```

**Returns:**

```typescript
Expand Down Expand Up @@ -883,6 +899,34 @@ function CustomPayButton({ onPress }) {
<CustomPayButton onPress={handlePayment} />
```

### Charging Before the Sheet Closes

Pass `onAuthorize` to charge the token while the Apple Pay sheet is still up. The sheet then shows your real outcome instead of an unconditional success:

```typescript
const checkout = usePaymentCheckout({
currencyCode: "USD",
onAuthorize: async (token) => {
const response = await fetch("https://api.yourserver.com/process-payment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, orderId }),
});
return response.ok
? { status: "success" }
: { status: "failure", error: "Payment failed" };
},
});

const result = await checkout.startPayment();
// result.success reflects your server's answer
```

- `onAuthorize` must settle within 25 seconds. Otherwise the payment fails with "Payment authorization timed out", and its later result is ignored. Your server may still have charged the card.
- **Retries on iOS:** after a failure or a timeout the user can retry from the same sheet, and `onAuthorize` runs again with a new token. Make your server charge idempotent per order ID, not per token.
- A rejected promise counts as a failure.
- **Android:** Google Pay closes its sheet before returning the token, so `onAuthorize` runs afterwards and only decides the `PaymentResult`.

### Server-Side Processing

After receiving the payment token, process it on your server:
Expand Down
19 changes: 17 additions & 2 deletions docs/docs/api/hybrid-payment-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const status = HybridPaymentHandler.payServiceStatus()

---

### startPayment(request)
### startPayment(request, options?)

Starts the native payment flow with the given request. Returns a promise that resolves with the payment result.

Expand All @@ -51,11 +51,26 @@ interface PaymentResult {
}
```

**options.onAuthorize** charges the token before the Apple Pay sheet closes. The result follows your callback:

```ts
const result = await HybridPaymentHandler.startPayment(request, {
onAuthorize: async (token) => {
const charged = await chargeOnServer(token)
return charged
? { status: 'success' }
: { status: 'failure', error: 'Payment failed' }
},
})
```

See [Charge before the sheet closes](/docs/guides/server-processing#charge-before-the-sheet-closes) for the timeout and Android behavior.

On iOS, the merchant ID is resolved from the app entitlements by default. Use `applePayMerchantIdentifier` only when you need to override the configured value at runtime.

Build the request yourself or use [createPaymentRequest](/docs/api/utils#createpaymentrequestoptions) for a single line item with defaults.

Only one payment can be in flight at a time. A call made while another payment is still in progress, including one started by `usePaymentCheckout`, resolves immediately with `{ success: false, error: 'A payment is already in progress' }` and does not affect the payment in flight. This applies on iOS and Android.
Only one payment can be in flight at a time. A call made while another payment is still in progress, including one started by `usePaymentCheckout`, resolves immediately with `{ success: false, error: 'A payment is already in progress' }` and does not affect the payment in flight. This applies on iOS and Android. With `onAuthorize`, the payment stays in flight until your callback settles.

---

Expand Down
19 changes: 19 additions & 0 deletions docs/docs/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,25 @@ Send `token` (and optionally `transactionId`) to your backend to complete the ch

---

## StartPaymentOptions

Optional second argument of `HybridPaymentHandler.startPayment()`. The hook takes `onAuthorize` in its config instead.

```ts
interface StartPaymentOptions {
onAuthorize?: (token: PaymentToken) => Promise<PaymentAuthorizationResult>
}

interface PaymentAuthorizationResult {
status: 'success' | 'failure'
error?: string // becomes PaymentResult.error on failure
}
```

See [Charge before the sheet closes](/docs/guides/server-processing#charge-before-the-sheet-closes).

---

## PaymentToken

The token you send to your server. **Do not log or expose; treat as sensitive.**
Expand Down
5 changes: 5 additions & 0 deletions docs/docs/api/use-payment-checkout.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,13 @@ interface UsePaymentCheckoutConfig {
googlePayEnvironment?: 'TEST' | 'PRODUCTION'
googlePayGateway?: string
googlePayGatewayMerchantId?: string
// Charge the token before the sheet closes
onAuthorize?: (token: PaymentToken) => Promise<PaymentAuthorizationResult>
}
```

- **onAuthorize** — Called with the token after the user authorizes the payment. Charge it on your server and resolve with `{ status: 'success' }` or `{ status: 'failure', error }`. See [Charge before the sheet closes](/docs/guides/server-processing#charge-before-the-sheet-closes).

## Return value

```ts
Expand Down Expand Up @@ -68,6 +72,7 @@ interface UsePaymentCheckoutReturn {
- Returns `null` and sets `error` when the cart is empty.
- 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`.
- Returns `{ success: false, error: 'A payment is already in progress' }` without opening a second sheet if a payment is still in flight:
- If the payment in flight was started by this hook, the hook state is not changed.
- If it was started elsewhere (`HybridPaymentHandler` or another `usePaymentCheckout`), the native handler rejects the call and the hook records it like any failed payment, in `result` and `error`.
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/guides/manual-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const handlePayment = async () => {
}
```

To charge the token before the Apple Pay sheet closes, pass `startPayment(paymentRequest, { onAuthorize })` and charge inside the callback instead of calling `sendTokenToServer` afterwards, so the token isn't charged twice. See [Charge before the sheet closes](/docs/guides/server-processing#charge-before-the-sheet-closes).

## 4. Render the button

Use the platform-specific button and, if needed, `callback()` for Nitro:
Expand Down
37 changes: 37 additions & 0 deletions docs/docs/guides/server-processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,43 @@ if (result?.success && result.token) {
}
```

## Charge before the sheet closes

Without `onAuthorize`, Apple Pay shows its success checkmark as soon as the user authorizes, before your server has charged anything. If the charge then fails, the user has already seen "Done".

Pass `onAuthorize` to charge the token while the sheet waits. Resolve with the outcome, and the sheet shows success or failure:

```ts
const { startPayment } = usePaymentCheckout({
currencyCode: 'USD',
onAuthorize: async (token) => {
const response = await fetch('https://api.yourserver.com/process-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, amount: total, currency: 'USD', orderId }),
})
if (!response.ok) {
const { error } = await response.json()
return { status: 'failure', error }
}
return { status: 'success' }
},
})

const result = await startPayment()
// result.success is true only when onAuthorize returned 'success'.
// The token is already charged, so don't send it to your server again.
```

With `HybridPaymentHandler`, pass the same callback as `startPayment(request, { onAuthorize })`.

How it behaves:

- **Failure:** `{ status: 'failure', error }` makes the sheet show the failure state. `startPayment` resolves with `success: false` and your `error`. A rejected promise is treated the same way.
- **Retries on iOS:** if the user retries from the sheet after a failure or a timeout, `onAuthorize` runs again with the new token. The result reflects the last attempt. A retry can start while a timed-out charge is still running, so key idempotency on your order ID, not on the token.
- **Timeout:** `onAuthorize` must settle within 25 seconds (Apple Pay itself gives up after about 30). Otherwise the payment fails with "Payment authorization timed out" and the late result is ignored. Your server may still have charged the card, so make the charge idempotent and reconcile by order ID.
- **Android:** Google Pay closes its sheet before it returns the token, and its basic flow has no step that waits for the merchant. `onAuthorize` still runs, with the same timeout, after the sheet closes, and its outcome decides the `PaymentResult`. The same callback works on both platforms.

## Example: Node.js (conceptual, gateway-specific)

Your backend receives the token and calls your gateway SDK/API. The exact request body and field mapping depend on your gateway + tokenization mode. Treat this example as shape guidance, not drop-in code.
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/payment-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ sequenceDiagram

## PaymentResult outcomes

With [`onAuthorize`](/docs/guides/server-processing#charge-before-the-sheet-closes), your backend charges the token before `startPayment` resolves: on iOS while the Apple Pay sheet is still up, on Android after the Google Pay sheet has closed. The result below already reflects the charge, so don't send `result.token` to your backend again.

### 1) Success

- `result.success === true`
Expand Down
34 changes: 34 additions & 0 deletions example/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from "react";
import {
StyleSheet,
Alert,
Expand All @@ -15,8 +16,13 @@ import {
} from "@gmisoftware/react-native-pay";
import { callback } from "react-native-nitro-modules";

const SIMULATED_SERVER_MODES = ["off", "approve", "decline"] as const;
type SimulatedServerMode = (typeof SIMULATED_SERVER_MODES)[number];

export default function TabOneScreen() {
const paymentServiceName = Platform.OS === "ios" ? "Apple Pay" : "Google Pay";
const [simulatedServer, setSimulatedServer] =
useState<SimulatedServerMode>("off");

// Initialize checkout hook
const {
Expand All @@ -36,8 +42,25 @@ export default function TabOneScreen() {
} = usePaymentCheckout({
countryCode: "US",
currencyCode: "USD",
// Stands in for your backend charging the token while the sheet waits.
onAuthorize:
simulatedServer === "off"
? undefined
: async () => {
await new Promise((resolve) => setTimeout(resolve, 1500));
return simulatedServer === "approve"
? { status: "success" }
: { status: "failure", error: "Declined by the test server" };
},
});

const handleCycleSimulatedServer = () => {
const next =
(SIMULATED_SERVER_MODES.indexOf(simulatedServer) + 1) %
SIMULATED_SERVER_MODES.length;
setSimulatedServer(SIMULATED_SERVER_MODES[next]);
};

// Show status info
const handleCheckStatus = () => {
if (canMakePayments) {
Expand Down Expand Up @@ -153,6 +176,17 @@ export default function TabOneScreen() {
</View>
</View>

{/* Simulated Server Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Simulated Server (onAuthorize)</Text>
<TouchableOpacity
style={styles.button}
onPress={handleCycleSimulatedServer}
>
<Text style={styles.buttonText}>Mode: {simulatedServer}</Text>
</TouchableOpacity>
</View>

{/* Quick Add Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Quick Add</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ object PaymentConstants {
const val DISPLAY_ITEM_STATUS_FINAL = "FINAL"
const val DISPLAY_ITEM_STATUS_PENDING = "PENDING"

// Merchant authorization (`StartPaymentOptions.onAuthorize`), same limit as iOS
const val AUTHORIZATION_TIMEOUT_MS = 25_000L
const val ERROR_AUTHORIZATION_FAILED = "Payment authorization failed"
const val ERROR_AUTHORIZATION_TIMED_OUT = "Payment authorization timed out"

// Supported networks (lowercase for comparison)
val SUPPORTED_NETWORKS = listOf("visa", "mastercard", "amex", "discover")

Expand Down
Loading
Loading