# Payment change events

While the payment sheet is open the user can change the shipping address, the shipping option, the payment card
or a coupon code. `PaymentRequest` models these as W3C change events: register listeners **before** calling
`show()` and answer each event with `PaymentRequestUpdateEvent.updateWith()`.

> **Platform support.** On iOS the events are delivered by PassKit: the payment sheet waits for the answer of a
> listener and is completed with the unchanged details whenever there is no listener for the event type, the
> listener fails or the sheet is torn down, so it can never hang. On Android `shippingaddresschange` and
> `shippingoptionchange` are delivered by Google Pay's payment data callbacks (`callbackIntents`) with the same
> answer guarantees — see [Android](#android) below; `paymentmethodchange` and `couponcodechange` never fire there
> because Google Pay has no callback intent for them. On web
> the browser's own `PaymentRequest` is used, so change events there follow the browser implementation. A request
> without listeners shows the same sheet with the same summary items as before — PassKit now asks the app on
> every change and is answered immediately with no change, which is a main thread round trip and no longer a
> purely local update. The end-to-end verification on devices is tracked in
> [#393](https://github.com/rnw-community/rnw-community/issues/393).
>
> iOS only shows the shipping method picker and the coupon code field (iOS 15+) when a `shippingoptionchange` /
> `couponcodechange` listener is registered before `show()` — `details.shippingOptions` are passed to PassKit in
> that case. Android follows the same opt-in: the Google Pay request only declares a callback intent, and only
> shows the shipping option picker, for the shipping listeners registered before `show()`.

## Android

`show()` adds Google Pay `callbackIntents` to the `PaymentDataRequest` for the listeners registered at that moment,
provided `requestShipping` is set in the Android method data (Google Pay only allows the shipping intents together
with `shippingAddressRequired`):

| Registered listener | Request additions |
| --- | --- |
| `shippingaddresschange` | `SHIPPING_ADDRESS` intent |
| `shippingoptionchange` with non-empty `details.shippingOptions` | `SHIPPING_OPTION` intent, `shippingOptionRequired`, `shippingOptionParameters` |
| either of the above | the mandatory `PAYMENT_AUTHORIZATION` intent, `transactionInfo.displayItems` from `details.displayItems` |

Google Pay requires every callback `newTransactionInfo` to repeat the `countryCode` of the original request, so a
request with regular `transactionInfo` and no `countryCode` in its Android method data declares no callback intents
at all — set `countryCode` to receive Android change events. A merchant-initiated request (`deferredTransactionInfo`,
`recurringTransactionInfo` or `automaticReloadTransactionInfo`) already requires `countryCode`.

The package ships a `PaymentsCallbacksService` (`BasePaymentDataCallbacksService`) in its library manifest, merged
into the app by autolinking and by Expo prebuild alike, so there is nothing to declare by hand. Google Pay calls it
with `onPaymentDataChanged` when the sheet opens and on every address or option change. The opening `INITIALIZE`
callback is delivered as `shippingaddresschange` for the default address when that listener is registered, and as
`shippingoptionchange` when only the option listener is — so do not expect an initial address event without a
`shippingaddresschange` listener. The module emits the event, and the `updateWith` answer becomes a
`PaymentDataRequestUpdate`:

| `updateWith` member | `PaymentDataRequestUpdate` |
| --- | --- |
| `total` | `newTransactionInfo.totalPrice` / `totalPriceLabel` (currency, country and price status of the request kept) — regular `transactionInfo` requests only |
| `displayItems` | `newTransactionInfo.displayItems` (`LINE_ITEM`, `PENDING` for a `pending` item) — regular `transactionInfo` requests only |
| `shippingOptions` (address change only) | `newShippingOptionParameters` — `label`, `detail` as `description`, the last `selected` option (else the option the sheet shows, else the first) as `defaultSelectedOptionId` |
| `error` | `error` with the event's `intent` — see [Sheet errors](#sheet-errors) |

A merchant-initiated request carries no `transactionInfo` and Google Pay does not support dynamic price updates
for it, so `total` and `displayItems` of an answer are not sent there — only the shipping options and the error
reach the sheet. Like on iOS, an option change answers totals only, so the picker never snaps back to another
option. Google Pay renders a shipping option from its `label` and `description` only — put the price in one of them.
The address of a change event is redacted by Google Pay to `locality`, `administrativeArea`, `postalCode` and `countryCode`.
`onPaymentAuthorized` is answered with `SUCCESS` right away, so the payment still resolves `show()` as before.

React Native pauses JS timers while the app's activity sits behind another activity, so while a callback-enabled
sheet is open the module keeps a headless JS task (`RNPaymentsChangeEvents`, registered by `show()` itself) running,
which keeps `setTimeout`-based listeners and the change-event timeout working exactly like on iOS. Google Pay fails
the sheet with `CALLBACK_TIMED_OUT` when a callback is not answered within 30 seconds, so the module also answers a
still pending event with the unchanged details after 25 seconds on its own and rejects the late JS answer with
`no_completion` — on Android the effective listener budget is therefore 25 seconds, not 30.

## `PaymentRequest.addEventListener(type, listener)`

Registers the listener for one of `shippingaddresschange`, `shippingoptionchange`, `paymentmethodchange` or
`couponcodechange` (the last one is a PassKit extension, not part of the W3C specification). Several listeners
can be registered for the same event type — they run in registration order and the same function is never
registered twice. Dispatch stops at the first listener that answers with `updateWith`, exactly like the stop
immediate propagation flag of the W3C algorithm. One native subscription is kept per event type no matter how
many listeners are added and removed, and events are scoped to the request they belong to, so concurrent
`PaymentRequest` instances never see each other's events. Listeners are released when `show()` settles and when
`abort()` resolves; registering on a closed request does nothing because a request is single-use — create a new
`PaymentRequest` to show the sheet again.

```ts
paymentRequest.addEventListener('shippingaddresschange', event => {
    event.updateWith({
        total: { label: 'Total', amount: { currency: 'USD', value: '25.00' } },
        displayItems: [{ label: 'Shipping', amount: { currency: 'USD', value: '5.00' } }],
    });
});
```

## `PaymentRequest.removeEventListener(type, listener)`

Removes the passed listener from the event type, matching the `EventTarget` signature. The native subscription
is released once the last listener of the type is gone, and native is told about the remaining event types right
away — also while the payment sheet is open.

```ts
paymentRequest.removeEventListener('shippingaddresschange', onShippingAddressChange);
```

## Event-handler attributes

`onshippingaddresschange`, `onshippingoptionchange`, `onpaymentmethodchange`, `oncouponcodechange` are thin
property alternatives to `addEventListener`/`removeEventListener`, one per event type, matching the
`EventTarget` IDL attribute semantics: assigning a function **replaces** the previously assigned attribute
handler (an implicit `removeEventListener` of the old one followed by `addEventListener` of the new one);
assigning `null` clears it without registering a new listener; reading the property returns the currently
assigned handler, or `null` when none was set. The attribute handler is otherwise an ordinary listener — it
coexists with every listener registered through `addEventListener` for the same type and runs in the order it
was (re-)registered.

```ts
paymentRequest.onshippingaddresschange = event => {
    event.updateWith({
        total: { label: 'Total', amount: { currency: 'USD', value: '25.00' } },
        displayItems: [{ label: 'Shipping', amount: { currency: 'USD', value: '5.00' } }],
    });
};

paymentRequest.onshippingaddresschange = null; // clears it
```

## `PaymentRequestUpdateEvent.updateWith(detailsOrPromise)`

Answers the event with updated `PaymentDetailsUpdate` — `total`, `displayItems`, `shippingOptions` and `error`
are all optional and only the provided members replace the current details. It accepts a promise, so a listener
can await a server call before answering:

```ts
paymentRequest.addEventListener('shippingoptionchange', async event => {
    const quote = await fetch(`https://example.com/quote?option=${paymentRequest.shippingOption}`).then(response =>
        response.json()
    );

    event.updateWith({
        total: { label: 'Total', amount: { currency: 'USD', value: quote.total } },
        shippingOptions: [{ id: 'express', label: 'Express', amount: { currency: 'USD', value: quote.shipping } }],
    });
});
```

Every `PaymentShippingOption` needs an `id`, a `label` and an `amount`, because iOS renders the row from the
label and the amount and reports the selection back by the id. `detail` is optional and is shown by Apple Pay as
the secondary line of the row (`PKShippingMethod.detail`); `amount.currency` is ignored because the sheet is
already bound to the `currencyCode` of the method data. The initial `details.shippingOptions` and the ones
answered with `updateWith` go through the same conversion, so the same option always renders the same row.

> `selected` is part of the W3C dictionary but is **silently ignored on iOS**: PassKit has no preselection
> support and always shows its shipping-method picker with the first option of the array highlighted. Put the
> option you want preselected first in `shippingOptions` instead of relying on `selected`.

```ts
const shippingOptions = [
    { id: 'express', label: 'Express', detail: 'Next business day', amount: { currency: 'USD', value: '5.00' } },
    { id: 'ground', label: 'Ground', detail: '3-5 business days', amount: { currency: 'USD', value: '0.00' } },
];
```

Calling `updateWith` twice, or calling it once the event was already answered or the request is no longer
showing, throws a `DOMException` with `InvalidStateError`. A listener that throws, rejects, sends invalid
details, never calls `updateWith` or leaves its promise pending for more than 30 seconds is logged and answered
with the unchanged details, so the payment sheet never stalls. Updated details go through the same validation as
the ones passed to the constructor — the total, the display items and the shipping options all have to carry a
valid decimal monetary value, and a shipping option also has to carry an id and a label — so a malformed amount
is reported to the console and never reaches the sheet.

## Sheet errors

`error` is either a plain string or a field-level error that Apple Pay renders inline, next to the offending row
of the sheet, instead of as a generic banner. A string keeps the previous behaviour: an unserviceable shipping
address for `shippingaddresschange`, an invalid coupon code for `couponcodechange` (iOS 15+) and a generic
payment error everywhere else. `shippingoptionchange` has no error slot in PassKit, so an error answered there is
ignored.

`error` has three possible shapes, all discriminated by `type`: a `shippingAddressField` or `contactField` error
carries the offending field (`key`/`field`) and the message shown to the user; a `couponCode` error carries no
field — instead an optional `expired` flag and the message:

```ts
import {
    PaymentAddressFieldEnum,
    PaymentContactFieldEnum,
    PaymentUpdateErrorTypeEnum,
} from '@rnw-community/react-native-payments';

paymentRequest.addEventListener('shippingaddresschange', event => {
    event.updateWith({
        error: {
            type: PaymentUpdateErrorTypeEnum.ShippingAddressField,
            key: PaymentAddressFieldEnum.PostalCode,
            message: 'We do not ship to this postal code',
        },
    });
});

paymentRequest.addEventListener('couponcodechange', event => {
    event.updateWith({
        error: { type: PaymentUpdateErrorTypeEnum.CouponCode, expired: true, message: 'SALE10 expired last week' },
    });
});
```

| `error.type` | Additional member | iOS `PKPaymentErrorDomain` error |
| --- | --- | --- |
| `shippingAddressField` | `key: PaymentAddressFieldEnum` | `paymentShippingAddressInvalidErrorWithKey:` |
| `contactField` | `field: PaymentContactFieldEnum` | `paymentContactInvalidErrorWithContactField:` |
| `couponCode` | `expired?: boolean` | `paymentCouponCodeInvalidError` / `paymentCouponCodeExpiredError` (iOS 15+) |

`PaymentAddressFieldEnum` maps onto the `CNPostalAddress` keys PassKit accepts: `addressLine` (street), `city`,
`country` (ISO country code), `dependentLocality` (sub-locality), `postalCode`, `region` (state) and
`subAdministrativeArea`. `PaymentContactFieldEnum` maps onto `PKContactField`: `email`, `name`, `phone` and
`postalAddress`. An unknown field, an empty message or a coupon error below iOS 15 is dropped and the sheet is
answered with the updated details only.

On Android the error is rendered by Google Pay as a `PaymentDataError` whose `intent` is the one of the answered
event: a string becomes `SHIPPING_ADDRESS_UNSERVICEABLE` for `shippingaddresschange` and `SHIPPING_OPTION_INVALID`
for `shippingoptionchange` (unlike iOS, an option error is shown), a `shippingAddressField` error becomes
`SHIPPING_ADDRESS_INVALID` and every other field error `OTHER_ERROR`, with the message shown in the sheet.

## `PaymentRequestUpdateEvent.isAnswered`

`true` once `updateWith` was called for the event. A listener built from several helpers can check it before
answering a second time:

```ts
paymentRequest.addEventListener('shippingoptionchange', event => {
    applyExpressSurcharge(event);

    if (!event.isAnswered) {
        event.updateWith({ total: { label: 'Total', amount: { currency: 'USD', value: '25.00' } } });
    }
});
```

## `PaymentMethodChangeEvent`

The event delivered for `paymentmethodchange` extends `PaymentRequestUpdateEvent` with the selected method:

```ts
paymentRequest.addEventListener('paymentmethodchange', event => {
    if (event.methodDetails?.['network'] === 'Amex') {
        event.updateWith({ error: 'Amex is not supported for this order' });
    }
});
```

## Changed values on the request

Before a listener runs, the changed value is stored on the request: `paymentRequest.shippingAddress`
(`PaymentResponseAddressInterface`), `paymentRequest.shippingOption` (the selected `PaymentShippingOption` id)
and `paymentRequest.couponCode`. On iOS the shipping address of a change event is **redacted** by PassKit: only
`address2` (city), `address3` (state), `postalCode` and `countryCode` are filled, while the street and the payer
name, email and phone stay empty until the payment is authorized — quote shipping from the postal code and the
country, never from the street. `paymentRequest.updating` is `true` while an event is being processed; a change
event that arrives during that window is answered with the unchanged details and is not dispatched to the
listeners, but its selection is still stored on the request, so these values always describe what the sheet
shows right now.

## References

- [`PaymentRequestUpdateEvent`](https://www.w3.org/TR/payment-request/#dom-paymentrequestupdateevent)
- [`PaymentMethodChangeEvent`](https://www.w3.org/TR/payment-request/#dom-paymentmethodchangeevent)
- [api/payment-request-update-event.md](../api/payment-request-update-event.md)
- [api/events-types.md](../api/events-types.md)
- [Google Pay dynamic price updates](https://developers.google.com/pay/api/android/guides/tutorial#enable-callbacks)
- [Google Pay `PaymentDataRequestUpdate`](https://developers.google.com/pay/api/android/reference/request-objects#PaymentDataRequestUpdate)
