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
2 changes: 2 additions & 0 deletions docs/docs/api/hybrid-payment-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ On iOS, the merchant ID is resolved from the app entitlements by default. Use `a

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.

---

### canMakePayments(usingNetworks)
Expand Down
5 changes: 4 additions & 1 deletion docs/docs/api/use-payment-checkout.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ 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.
- **reset()** — Clears `isProcessing`, `result`, and `error` (does not clear cart).
- 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`.
- **reset()** — Clears `result` and `error` (does not clear cart). It does not clear `isProcessing` while a payment is in flight; `isProcessing` becomes `false` when that payment settles.

## Example

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ object PaymentConstants {
// Supported networks (lowercase for comparison)
val SUPPORTED_NETWORKS = listOf("visa", "mastercard", "amex", "discover")

// Errors (keep in sync with iOS `ErrorMessage`)
const val ERROR_PAYMENT_IN_PROGRESS = "A payment is already in progress"

// Logging tags
const val TAG_PAYMENT_HANDLER = "HybridPaymentHandler"
const val TAG_GOOGLE_PAY_BUTTON = "HybridGooglePayButton"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class HybridPaymentHandler : HybridPaymentHandlerSpec(), ActivityEventListener {
?: throw IllegalStateException("Application context is not ReactApplicationContext")
}

private var paymentPromise: Promise<PaymentResult>? = null
private val pendingPayment = PendingPayment<PaymentResult>()
private var currentEnvironment: Int = WalletConstants.ENVIRONMENT_TEST

init {
Expand Down Expand Up @@ -71,7 +71,14 @@ class HybridPaymentHandler : HybridPaymentHandlerSpec(), ActivityEventListener {

override fun startPayment(request: PaymentRequest): Promise<PaymentResult> {
val promise = Promise<PaymentResult>()
paymentPromise = promise

// Reject overlaps without touching the in-flight payment, so its
// promise still settles and no second Google Pay sheet is requested.
if (!pendingPayment.tryBegin { result -> promise.resolve(result) }) {
Log.w(PaymentConstants.TAG_PAYMENT_HANDLER, PaymentConstants.ERROR_PAYMENT_IN_PROGRESS)
promise.resolve(PaymentMapper.createErrorResult(PaymentConstants.ERROR_PAYMENT_IN_PROGRESS))
return promise
}

try {
val environment = determineEnvironment(request.googlePayEnvironment)
Expand Down Expand Up @@ -193,14 +200,12 @@ class HybridPaymentHandler : HybridPaymentHandlerSpec(), ActivityEventListener {
private fun handlePaymentSuccess(paymentData: PaymentData) {
Log.d(PaymentConstants.TAG_PAYMENT_HANDLER, "Payment successful")
val result = PaymentMapper.mapPaymentDataToResult(paymentData)
paymentPromise?.resolve(result)
paymentPromise = null
pendingPayment.settle(result)
}

private fun handlePaymentError(errorMessage: String) {
val result = PaymentMapper.createErrorResult(errorMessage)
paymentPromise?.resolve(result)
paymentPromise = null
pendingPayment.settle(result)
}

private fun logPaymentRequest(request: org.json.JSONObject, environment: Int) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.margelo.nitro.pay

import java.util.concurrent.atomic.AtomicReference

/**
* Holds the settle callback of the single in-flight payment.
*
* `startPayment` runs on the JS thread while `onActivityResult` runs on the
* main thread, so claiming and settling go through one atomic slot.
*/
internal class PendingPayment<T> {
private val settleCallback = AtomicReference<((T) -> Unit)?>(null)

/**
* Claims the slot for a new payment.
* Returns `false` without touching the in-flight payment if one exists.
*/
fun tryBegin(settle: (T) -> Unit): Boolean {
return settleCallback.compareAndSet(null, settle)
}

/**
* Settles the in-flight payment once and frees the slot.
* Returns `false` if there was nothing to settle.
*/
fun settle(result: T): Boolean {
val settle = settleCallback.getAndSet(null) ?: return false
settle(result)
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.margelo.nitro.pay

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicInteger

class PendingPaymentTest {

@Test
fun settlesTheClaimedPayment() {
val pending = PendingPayment<String>()
val settled = mutableListOf<String>()

assertTrue(pending.tryBegin { settled.add(it) })
assertTrue(pending.settle("done"))

assertEquals(listOf("done"), settled)
}

@Test
fun rejectsOverlapWithoutReplacingTheInFlightPayment() {
val pending = PendingPayment<String>()
val first = mutableListOf<String>()
val second = mutableListOf<String>()

assertTrue(pending.tryBegin { first.add(it) })
assertFalse(pending.tryBegin { second.add(it) })
pending.settle("done")

assertEquals(listOf("done"), first)
assertEquals(emptyList<String>(), second)
}

@Test
fun settlesOnlyOnce() {
val pending = PendingPayment<String>()
val settled = mutableListOf<String>()

pending.tryBegin { settled.add(it) }

assertTrue(pending.settle("first"))
assertFalse(pending.settle("second"))
assertEquals(listOf("first"), settled)
}

@Test
fun settleWithoutPaymentIsNoOp() {
val pending = PendingPayment<String>()

assertFalse(pending.settle("orphan"))
}

@Test
fun acceptsNewPaymentAfterSettle() {
val pending = PendingPayment<String>()
val settled = mutableListOf<String>()

pending.tryBegin { settled.add("a:$it") }
pending.settle("1")

assertTrue(pending.tryBegin { settled.add("b:$it") })
pending.settle("2")

assertEquals(listOf("a:1", "b:2"), settled)
}

@Test
fun concurrentBeginsClaimTheSlotOnce() {
val pending = PendingPayment<Int>()
val threads = 16
val gate = CountDownLatch(1)
val claims = AtomicInteger(0)

val workers = (0 until threads).map {
Thread {
gate.await()
if (pending.tryBegin { }) claims.incrementAndGet()
}.apply { start() }
}
gate.countDown()
workers.forEach { it.join() }

assertEquals(1, claims.get())
}
}
110 changes: 110 additions & 0 deletions package/src/__tests__/paymentHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { act, renderHook } from '@testing-library/react-native'

const IN_PROGRESS_RESULT = {
success: false,
error: 'A payment is already in progress',
}

const mockInstances: object[] = []
const mockPresentations: Array<(result: any) => void> = []

jest.mock('react-native-nitro-modules', () => ({
NitroModules: {
createHybridObject: () => {
// Mirrors the per-instance in-progress guard of the native handlers.
let inFlight = false
const instance = {
payServiceStatus: () => ({
canMakePayments: true,
canSetupCards: true,
}),
startPayment: () => {
if (inFlight) {
return Promise.resolve({
success: false,
error: 'A payment is already in progress',
})
}
inFlight = true
return new Promise((resolve) => {
mockPresentations.push((result) => {
inFlight = false
resolve(result)
})
})
},
}
mockInstances.push(instance)
return instance
},
},
getHostComponent: () => 'HostComponent',
}))

// Required after the mock state above exists: importing the package creates
// the handler immediately.
const { HybridPaymentHandler, usePaymentCheckout, createPaymentRequest } =
require('../index') as typeof import('../index')

const settlePresentations = () =>
mockPresentations.splice(0).forEach((settle) => settle({ success: true }))

describe('shared PaymentHandler instance', () => {
const directRequest = createPaymentRequest({ amount: 10, label: 'Direct' })

const renderCheckoutWithItem = () => {
const rendered = renderHook(() => usePaymentCheckout({}))
act(() => {
rendered.result.current.addItem('Order', 5)
})
return rendered
}

afterEach(settlePresentations)

it('uses one native handler for the hook and HybridPaymentHandler', () => {
renderHook(() => usePaymentCheckout({}))

expect(mockInstances).toHaveLength(1)
expect(mockInstances[0]).toBe(HybridPaymentHandler)
})

it('rejects a direct call while the hook payment is in flight', async () => {
const { result } = renderCheckoutWithItem()

let hookPayment!: Promise<any>
let directResult: any
await act(async () => {
hookPayment = result.current.startPayment()
directResult = await HybridPaymentHandler.startPayment(directRequest)
})

expect(mockPresentations).toHaveLength(1)
expect(directResult).toEqual(IN_PROGRESS_RESULT)

await act(async () => {
settlePresentations()
await hookPayment
})

await expect(hookPayment).resolves.toEqual({ success: true })
})

it('records the native in-progress result when a direct payment is in flight', async () => {
const { result } = renderCheckoutWithItem()

const directPayment = HybridPaymentHandler.startPayment(directRequest)
let hookResult: any
await act(async () => {
hookResult = await result.current.startPayment()
})

expect(mockPresentations).toHaveLength(1)
expect(hookResult).toEqual(IN_PROGRESS_RESULT)
expect(result.current.result).toEqual(IN_PROGRESS_RESULT)
expect(result.current.error?.message).toBe(IN_PROGRESS_RESULT.error)

settlePresentations()
await expect(directPayment).resolves.toEqual({ success: true })
})
})
Loading
Loading