> ## Documentation Index
> Fetch the complete documentation index at: https://waffo.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 收银台集成 - Element 集成

> 使用 Payment Element 将 Waffo 收银台以组件形式嵌入你自己的结账页。

Payment Element 把 Waffo 收银台的支付方式列表、卡表单和钱包按钮，以组件的形式嵌入你自己的结账页。用户不离开你的页面就能完成支付；卡号、钱包报文、3DS 验证都在 Waffo 的 iframe 内处理，不经过你的代码。

> [!NOTE]
>
> 本页包含**服务端**职责（创建支付 session、接收 Webhook）与**前端**职责（挂载组件、提交支付、处理结果）。全部属性、方法、事件和取值见 [Element API 参考](https://waffo.com/docs/zh/developer-docs/integration/checkout/element-reference)。

## 适用场景

| 集成方式 | 你负责 | Waffo 负责 | 适合 |
| - | - | - | - |
| [收银台跳转](https://waffo.com/docs/zh/developer-docs/integration/checkout/steps.md) | 创建订单、跳转 | 完整收银台页面 | 最快上线，不需要自建结账页 |
| **Payment Element** | 结账页布局、卡支付的**支付**按钮 | 支付方式列表、卡表单、钱包、3DS 与付款指引 | 想保留自己的结账页，又不想处理卡数据 |
| [绑卡与 Token](https://waffo.com/docs/zh/developer-docs/integration/tokenization/overview.md) | 完整卡表单 | 卡数据加密与 Token 化 | 需要完全自定义卡表单 |

## 工作流程

```mermaid theme={null}
sequenceDiagram
    participant S as 商户服务端
    participant F as 商户前端
    participant W as Waffo

    S->>W: 1. 创建支付 session
    W-->>S: 2. 返回 sessionId
    S->>F: 3. 下发 sessionId
    F->>W: 4. 挂载 Payment Element
    F->>W: 5. 用户选择并提交支付
    W-->>F: 6. payment 事件返回结果
    W-->>S: 7. Webhook 通知最终结果
```

**1. 服务端创建支付 session**

商户服务端向 Waffo 创建支付 session，拿到 `sessionId`。

**2. 前端挂载组件**

用 `sessionId` 创建 Elements 实例并挂载 Payment Element。

**3. 用户支付**

钱包等支付方式由组件内按钮直接提交；卡等支付方式由你的**支付**按钮调用 `elements.confirmPayment()` 提交。

**4. 处理结果**

前端在 `payment` 事件里更新界面，服务端以 Webhook 为准完成发货。

## 前置条件

* 已开通 Waffo 商户账号，并获取前端使用的 **Client API Key**。它只能用于浏览器侧，不是服务端签名密钥。
* 结账页通过 HTTPS 提供。本地开发可以使用 `http://localhost` 或 `http://127.0.0.1`，其他 HTTP 页面会导致组件无法启动，并返回 `SDK_CONNECTION_FAILED`。
* 安装 `@waffo/payment-sdk`，版本需包含 Payment Element。

```bash theme={null}
npm install @waffo/payment-sdk
```

## Step 1: 服务端创建支付 session

在商户服务端为本次支付创建 session，把返回的 `sessionId` 交给前端。金额、币种、商户订单号等信息都在这一步确定，前端拿不到也改不了。session 中可用的支付方式决定了组件能展示哪些支付方式。

> [!WARNING]
>
> 创建支付 session 的服务端接口尚未在 API 参考中公开，接入前请联系 Waffo TAM 获取接口说明。

## Step 2: 初始化 SDK 与 Elements

```javascript theme={null}
import WaffoSDK from '@waffo/payment-sdk';

const sdk = new WaffoSDK('your-client-api-key', {
  env: 'prod',                // 'prod' | 'sandbox' | 'testing'
  locale: 'zh-Hans',
  preloadTokenization: false, // 只用 Payment Element 时关闭绑卡 iframe 预加载
});

const elements = sdk.createElements(sessionId, {
  appearance: {
    variables: { colorPrimary: '#1957ff', borderRadius: '8px' },
    layout: { type: 'vertical' },
  },
});
```

* `env` 必须与 `sessionId`、Client API Key 所属环境一致。
* 在 React、Next.js 等框架中，请在客户端挂载后初始化，不要在服务端渲染期间执行。
* 一个结账流程只创建一个 Elements 实例，它下面的所有组件共享同一个 session 和同一把提交锁。

## Step 3: 选择接入方式并挂载

Payment Element 提供四种接入方式。它们只是界面组织方式不同，不会增加 session 本身没有的支付方式。

| 接入方式 | 调用 | 适合 |
| - | - | - |
| 整体接入 | `elements.create('payment')` | 一个组件展示所有可用支付方式，接入成本最低 |
| 分组接入 | `elements.create(['express'])` | 按快捷支付、信息收集类支付、卡分区摆放 |
| 原子化接入 | `elements.create(['WECHATPAY'])` | 你决定每个支付方式的位置，输入框和按钮仍由 Waffo 渲染 |
| 自定义渲染 | `await elements.create('WECHATPAY', { customRender: true })` | 你自己渲染字段和按钮，Waffo 只负责提交与后续动作 |

> [tab] 整体接入
>
> ```html theme={null}
> <div id="payment-element"></div>
> <button id="pay" hidden>支付</button>
> <p id="message" role="alert"></p>
> ```
>
> ```javascript theme={null}
> const element = elements.create('payment', {
>   paymentMethodOrder: ['APPLEPAY', 'GOOGLEPAY', 'CARD'],
> });
>
> const payButton = document.getElementById('pay');
>
> // 先订阅，再挂载
> element.on('change', (state) => {
>   payButton.hidden = !state.ready || state.confirmTrigger !== 'merchant';
>   payButton.disabled = !['ready', 'error'].includes(state.phase);
> });
>
> element.mount('#payment-element');
> ```
>
> 组件高度随内容自动调整，不需要给容器设固定高度。`state` 的全部字段见 [element.on('change')](#change-事件)。

> [tab] 分组接入
>
> 分组名使用小写，可以组合：
>
> | 分组 | 包含 |
> | - | - |
> | `express` | Apple Pay、Google Pay 和点击即跳转的支付方式 |
> | `collection` | 需要用户填写信息的非卡支付方式 |
> | `card` | 卡表单 |
>
> ```javascript theme={null}
> const express = elements.create(['express']);
> const form = elements.create(['collection', 'card'], {
>   paymentMethodOrder: ['WECHATPAY', 'CARD'],
> });
>
> express.mount('#express');
> form.mount('#payment-form');
> ```

> [tab] 原子化接入
>
> 传入大写的支付方式名称（`payMethodName`），每个组件只展示指定的支付方式。卡建议使用 `card` 分组：
>
> ```javascript theme={null}
> elements.create(['card']).mount('#card');
> elements.create(['APPLEPAY']).mount('#apple-pay');
> elements.create(['GOOGLEPAY']).mount('#google-pay');
> elements.create(['WECHATPAY']).mount('#wechat-pay');
> ```
>
> 原子化接入不需要传 `paymentMethodOrder`。session 中没有、或当前浏览器不支持的支付方式不会展示。

### 需要用户填写的信息

有些支付方式要求用户填写额外信息，如证件号、地址、收款银行。组件会按 session 中支付方式所属的国家，套用和 Waffo 收银台相同的规则，你不需要额外配置：

* **证件号**：按国家的格式校验并自动排版，如巴西 CPF 显示为 `123.456.789-09`。
* **巴西地址**：邮编填满 8 位后自动查询并填入州、城市和街道；再次修改邮编时，查到的地址只作为提示，由用户点**填写**覆盖。邮编填好之前，地址栏先不显示。
* **收款银行**：印度显示为下拉框，其他国家显示为带银行 logo 的单选列表。

### 多个组件并存的规则

* 同一个 session 下的组件展示内容不能重叠，否则 `mount()` 抛出 `SDK_IFRAME_CREATE_FAILED`。
* 分组和支付方式名称不能写在同一个数组里；跨组件时，只有 `['card']` 分组可以与支付方式名称组件同时挂载。
* 相同的选择会返回同一个实例：`['card', 'express']` 与 `['express', 'card']` 相同。数组顺序不影响展示顺序，展示顺序请用 `paymentMethodOrder`。
* 同时挂载了多个需要你提交的组件时，调用 `confirmPayment(element)` 必须指定目标组件。

### 自定义渲染

自定义渲染适合你想用自己的设计系统绘制按钮和输入框的场景。SDK 在页面中维护一个隐藏的 iframe，负责通信、提交和后续动作，**不需要调用 `mount()`**。

仅支持单个非卡、非 Apple Pay、非 Google Pay 的支付方式。session 中没有该支付方式时，`create()` 返回的 Promise 会 reject。

```html theme={null}
<form id="wechat-form"></form>
```

```javascript theme={null}
const wechat = await elements.create('WECHATPAY', { customRender: true });

const form = document.getElementById('wechat-form');
const fields = document.createElement('div');
const pay = document.createElement('button');
pay.type = 'submit';
form.replaceChildren(fields, pay);

let inputs = [];
let schemaKey = '';

function render(state) {
  const method = state?.payMethod;
  // 仅在字段定义变化时重建输入框，避免清空用户已输入的内容
  const key = JSON.stringify(method?.requiredFields ?? []);
  if (key !== schemaKey) {
    schemaKey = key;
    fields.replaceChildren();
    // options 非空时渲染下拉框：显示 name，提交 code
    inputs = (method?.requiredFields ?? []).map((field) => {
      const control = document.createElement(field.options?.length ? 'select' : 'input');
      control.name = field.field;
      control.required = field.required;
      for (const option of field.options ?? []) control.add(new Option(option.name, option.code));
      fields.append(control);
      return control;
    });
  }
  pay.hidden = !state?.ready || !method;
  pay.disabled = !['ready', 'error'].includes(state?.phase);
  pay.textContent = '微信支付';
}

wechat.on('change', render);
render(wechat.getState()); // 读取一次当前状态，避免错过初始化期间的 change

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const userFields = Object.fromEntries(inputs.map((c) => [c.name, c.value]));
  try {
    await elements.confirmPayment(wechat, {
      payMethodName: wechat.payMethod.payMethodName,
      userFields,
    });
  } catch (err) {
    message.textContent = err.message;
  }
});
```

`wechat.payMethod` 与 `state.payMethod` 的结构：

| 字段 | 类型 | 说明 |
| - | - | - |
| `payMethodName` | `string` | 支付方式名称，如 `WECHATPAY`，提交时作为 `payMethodName` 传回 |
| `payMethodType` | `string` | 支付方式类别 |
| `userAmount` | `string \| number \| null` | 可选。用户支付金额，缺失时不要自行推算 |
| `userCurrency` | `string \| null` | 可选。用户支付币种 |
| `requiredFields[].field` | `string` | 字段路径，如 `userInfo.userEmail`，同时作为 `userFields` 的 key |
| `requiredFields[].required` | `boolean` | 是否必填 |
| `requiredFields[].options` | `{ name, code }[] \| null` | 可选。非空时渲染为下拉框，显示 `name`，提交 `code` |

* `userFields` 是扁平的键值对，key 直接使用 `requiredFields[].field`（如 `userInfo.userEmail`），不要展开成嵌套对象。
* 字段校验失败时，`confirmPayment()` 以 `INVALID_USER_FIELD` 拒绝，`field` 指向出错字段；`state.errors` 中也会出现同样的错误。
* `requiredFields` 中也可能包含 `required: false` 的选填字段。
* 示例中的 `message` 是你页面上的错误提示元素。

## Step 4: 提交支付

不同支付方式由不同的入口提交。组件通过 `state.confirmTrigger` 告诉你当前选中的方式由谁提交，不要根据支付方式名称自行判断：

| 当前选中的支付方式 | `confirmTrigger` | 谁来提交 | 是否调用 `confirmPayment()` |
| - | - | - | - |
| 卡、需要填写信息的支付方式 | `'merchant'` | 你的**支付**按钮 | **是** |
| Apple Pay、Google Pay、点击即跳转的支付方式 | `'element'` | 组件内的按钮 | **否** |

```javascript theme={null}
payButton.addEventListener('click', async () => {
  try {
    await elements.confirmPayment(); // 结果同样会触发 payment 事件
  } catch (err) {
    message.textContent = err.message; // err.code 一定存在，处理方式见「错误处理」
  }
});
```

> [!WARNING]
>
> 不要根据 `state.complete` 禁用支付按钮。初始状态下没有选中任何支付方式，`complete` 为 `false`；按钮一旦被禁用，用户就无法点击触发校验，也就看不到缺少哪些信息。可以根据 `state.phase` 和 `state.loading` 防止重复提交。

### confirmPayment 的参数与返回值

```typescript theme={null}
elements.confirmPayment(element?: PaymentElement, options?: ConfirmPaymentOptions): Promise<PaymentResult>
```

**element** `PaymentElement`

要提交的组件，即 `elements.create()` 返回的对象。页面上只有一个需要你提交的组件时可省略；同时挂载了多个时必须传。

**options** `object`

仅自定义渲染组件使用，普通组件传入会 reject。

**属性**

**payMethodName** `string`

自定义渲染时必填。必须与组件的支付方式一致，通常直接用 `customElement.payMethod.payMethodName`。

**userFields** `Record\<string, string\>`

用户填写的字段。key 为 `requiredFields[].field`，value 为输入值或选中项的 `code`；扁平结构，不展开成嵌套对象；多余的 key 不会提交。

**返回值** `Promise\<PaymentResult\>`

resolve 的值与 `payment` 事件的 `result` 相同，字段见 [elements.on('payment')](#payment-事件)。返回的是第一个结果：遇到付款指引时，用户关闭指引就 resolve 为 `processing`，最终结果只会通过 `payment` 事件送达。

以下情况会 reject：

| 情况 | 抛出 |
| - | - |
| 当前选中的支付方式由组件自己提交 | `SDK_ELEMENT_NOT_CONFIRMABLE` |
| 同时挂载了多个需要你提交的组件，但未传 `element` | `SDK_ELEMENT_NOT_CONFIRMABLE` |
| 传入的 `element` 尚未挂载或已卸载 | `SDK_ELEMENT_NOT_MOUNTED` |
| 正在加载、更新 session、提交中，或支付已进入后续流程（`phase` 不是 `ready` / `error`） | `SDK_ELEMENTS_BUSY` |
| Elements 已销毁，或提交过程中 session 被替换 | `SDK_ELEMENTS_DESTROYED` |
| 表单未填完或格式不对 | `SDK_ELEMENT_INCOMPLETE`，`field` 指向第一个出错的收集字段；卡号等卡字段出错时不带 `field` |
| 订阅 session 未同意协议 | `TERMS_NOT_AGREED` |
| `options` 用错：普通组件传了 `options`，或 `payMethodName` 与自定义组件不一致 | `SDK_INVALID_CONFIRM_OPTIONS` |
| 自定义渲染时 `userFields` 校验不过 | `INVALID_USER_FIELD`，带 `field` |
| 提交时接口返回错误 | 接口错误码，如 `A0068`，`message` 为本地化文案 |
| 3DS / 付款指引失败、超时或被关闭 | `SDK_PAYMENT_ACTION_FAILED` / `SDK_PAYMENT_ACTION_TIMEOUT` / `SDK_PAYMENT_ACTION_CANCELED` |

```javascript 单个组件 theme={null}
// 页面上只有一个需要你提交的组件：不传参数
await elements.confirmPayment();
```

```javascript 多个组件 theme={null}
// 同时挂载了多个组件（如钱包组件 + 卡组件）：指定要提交的组件
const card = elements.create(['card']);
card.mount('#card');
await elements.confirmPayment(card);
```

```javascript 自定义渲染 theme={null}
// 传入组件、支付方式和用户填写的字段
const wechat = await elements.create('WECHATPAY', { customRender: true });
await elements.confirmPayment(wechat, {
  payMethodName: wechat.payMethod.payMethodName,
  userFields: {
    'userInfo.userEmail': 'user@example.com',
  },
});
```

### 订阅 session

当 session 为订阅类型且使用整体接入时，组件会展示订阅协议，用户勾选后才能提交。未勾选就提交时，`confirmPayment()` 会 reject，`error` 事件返回 `TERMS_NOT_AGREED`。分组接入、原子化接入和自定义渲染不展示协议，请在你的页面上自行展示并校验。

## Step 5: 处理支付结果

无论用户从哪个入口提交，结果都通过 `elements.on('payment')` 返回；所有错误都通过 `elements.on('error')` 返回。请只在 `payment` 事件里处理结果，`confirmPayment()` 的 Promise 只用于捕获异常，避免同一个结果处理两次。

```javascript theme={null}
elements.on('payment', (result) => {
  const data = result.data;

  if (data && 'sessionType' in data) {
    // 订阅 session：读取 data.subscription
  } else {
    console.log('Waffo 订单号', data?.acquiringOrderId);
  }

  if (result.status === 'succeeded') {
    location.href = '/success';
  } else if (result.status === 'processing') {
    message.textContent = '支付处理中，请等待最终结果';
  } else if (result.status === 'failed') {
    message.textContent = result.message ?? '支付失败';
  }
});

elements.on('error', (err) => {
  message.textContent = err.message;
});
```

| `status` | 含义 | 建议处理 |
| - | - | - |
| `succeeded` | 支付成功 | 展示成功页 |
| `processing` | 等待最终结果（例如用户关闭了银行转账指引） | 展示处理中状态，保留 Elements 实例等待后续结果，并以 Webhook 为准 |
| `failed` | 支付失败 | 展示 `message`，服务端新建 session 后让用户重试 |
| `requires_action` | 需要你自行完成后续动作，仅在 `handleNextAction: false` 时出现 | 见 [3DS 与后续动作](#3ds-与后续动作) |

`processing` 之后同一次支付还可能再收到一次 `succeeded` 或 `failed`，`payment` 事件不是只触发一次。`result` 的完整字段见 [elements.on('payment')](#payment-事件)，`err` 的字段见 [elements.on('error')](#error-事件)。

> [!NOTE]
>
> 前端结果只用于更新界面。请以 [Webhook](https://waffo.com/docs/zh/developer-docs/webhook/overview.md) 或订单查询结果作为发货依据。

## Step 6: 服务端接收 Webhook

Payment Element 产生的订单与其他集成方式一样，通过 Webhook 通知最终结果。处理方式见 [Webhook 概览](https://waffo.com/docs/zh/developer-docs/webhook/overview.md)。

## 会话状态

组件每次读取 session 时，都会先看它的支付状态，只有还能付的 session 才显示支付表单。已经付过、处理中或已结束的 session，组件会换成一块状态提示，并且不能再提交：

| session 状态 | 组件显示 | 你收到的事件 |
| - | - | - |
| 可以支付 | 支付表单 | 无 |
| 等待后续动作（如未完成的付款指引、3DS） | 「您有一笔正在支付的订单」和**继续**按钮 | 无；用户点**继续**后，SDK 按 [3DS 与后续动作](#3ds-与后续动作) 的方式打开那笔订单，之后照常触发 `payment` |
| 处理中 | 处理中提示 | 无 |
| 已支付或已订阅 | 支付成功提示 | `payment` 事件，`status: 'succeeded'` |
| 已结束 | 「该支付会话已使用过，请重新发起支付」 | `payment` 事件，`status: 'failed'` |

不能付的 session 下，`state.availablePayMethods` 为空，`confirmTrigger` 为 `'element'`，所以按 `confirmTrigger` 显示的**支付**按钮会自动隐藏。

这意味着：

* **挂载时也可能触发 `payment` 事件**。用已经付过的 `sessionId` 挂载组件时，你会马上收到 `succeeded`，可以直接按成功处理，例如跳到成功页。
* **整页跳转回来后可以恢复结果**。在 `redirect` 事件里保存 `sessionId`，用户回到你的页面后，用它重新创建 Elements 并挂载组件，已支付或已结束的结果会通过 `payment` 事件送达。处理中的 session 不会触发事件，请以 Webhook 或订单查询为准。
* **重复提交不会被当成支付失败**。用户在已经付过的 session 上再次提交时，组件会重新读取 session，按它的状态返回结果：已支付返回 `succeeded`，已结束返回 `failed`，等待后续动作时直接打开那笔订单。

## 监听事件

Payment Element 的事件分两层：组件（`PaymentElement`）上的事件反映单个组件的状态，Elements 上的事件反映整次支付的结果。

| 对象 | 事件 | 回调参数 | 触发时机 |
| - | - | - | - |
| `element` | [`ready`](#ready-事件) | `state` | 组件渲染完成；更新 session 后会再次触发 |
| `element` | [`change`](#change-事件) | `state` | 选中的支付方式、表单、加载状态或生命周期变化 |
| `element` | [`error`](#组件-error-事件) | `err` | 该组件出错 |
| `elements` | [`payment`](#payment-事件) | `result` | 产生支付结果，包括钱包支付和 `confirmPayment()` 提交的支付 |
| `elements` | [`redirect`](#redirect-事件) | `{ url }` | SDK 即将整页跳转到外部支付页面 |
| `elements` | [`error`](#error-事件) | `err` | 任意组件或 SDK 出错，是所有错误的统一入口 |

### 订阅与取消订阅

```typescript theme={null}
on(event: string, handler: (payload) => void): () => void
off(event: string, handler: (payload) => void): void
```

| 方法 | 参数 | 返回值 |
| - | - | - |
| `on(event, handler)` | `event`：事件名；`handler`：回调函数，接收一个参数 | 取消订阅函数 `() => void`，调用后不再接收该事件 |
| `off(event, handler)` | 与 `on` 相同；`handler` 必须是订阅时的同一个函数引用 | 无 |

```javascript theme={null}
const handleChange = (state) => { /* ... */ };

// 方式一：用 on 的返回值取消
const unsubscribe = element.on('change', handleChange);
unsubscribe();

// 方式二：用 off 取消，需传入同一个函数引用
element.on('change', handleChange);
element.off('change', handleChange);
```

* 回调函数的返回值会被忽略，不需要返回任何内容；`async` 函数也不会被等待。
* 回调中抛出的异常只会打印到控制台，不影响其他回调和支付流程。
* `elements.destroy()` 会清除所有订阅。
* 请在 `mount()` 之前订阅 `element` 的事件，避免错过首次 `ready`。

### ready 事件

`element.on('ready', (state) => {})`

组件第一次渲染完成、可以交互时触发。`updateSessionId()` 或组件自动刷新 session 后会再次触发。它不代表支付成功。

```javascript theme={null}
element.on('ready', (state) => {
  console.log('可用支付方式', state.availablePayMethods);
});
```

回调参数 `state` 与 [element.on('change')](#change-事件) 相同。

### change 事件

`element.on('change', (state) => {})`

组件状态变化时触发，例如用户切换支付方式、填写表单、开始提交、进入 `processing`。状态没有实际变化时不会重复触发。通常用它控制你的**支付**按钮。

```javascript theme={null}
element.on('change', (state) => {
  payButton.hidden = !state.ready || state.confirmTrigger !== 'merchant';
  payButton.disabled = !['ready', 'error'].includes(state.phase);
  payButton.textContent = state.loading ? '处理中…' : '支付';
});
```

回调参数 `state`（`element.getState()` 返回同样的结构，组件就绪前返回 `null`）：

| 字段 | 类型 | 说明 |
| - | - | - |
| `ready` | `boolean` | 组件是否已渲染且可交互。建议在 `ready` 之前隐藏你的**支付**按钮 |
| `phase` | `string` | 整次支付的生命周期：`idle`、`loading`、`ready`、`updating`、`submitting`、`awaiting_action`、`processing`、`succeeded`、`failed`、`error`、`destroyed`。只有 `ready` 或 `error` 时可以调用 `confirmPayment()`，各取值含义见 [Element API 参考](https://waffo.com/docs/zh/developer-docs/integration/checkout/element-reference#phase-取值) |
| `loading` | `boolean` | `loading`、`updating`、`submitting`、`awaiting_action` 期间为 `true`，可用于加载提示 |
| `confirmTrigger` | `'merchant' \| 'element'` | 当前选中的支付方式由谁提交：`merchant` 由你调用 `confirmPayment()`，`element` 由组件内按钮提交 |
| `payMethodName` | `string \| null` | 当前选中的支付方式，未选中时为 `null`；卡为 `CARD` |
| `availablePayMethods` | `string[]` | 当前可用的支付方式，由 session、组件选择和浏览器能力共同决定 |
| `complete` | `boolean` | 当前选中的支付方式是否已填完。仅供参考，不要据此禁用**支付**按钮；自定义渲染时始终为 `false` |
| `errors` | `{ code, message, field? }[]` | 可选。仅自定义渲染时出现，`code` 为 `INVALID_USER_FIELD` |
| `customRender` | `boolean` | 可选。是否为自定义渲染组件 |
| `payMethod` | `object \| null` | 可选。仅自定义渲染时出现，结构见 [自定义渲染](#自定义渲染) |

一个整体接入组件在用户选中卡之后的 `state` 示例：

```json theme={null}
{
  "phase": "ready",
  "loading": false,
  "ready": true,
  "complete": false,
  "payMethodName": "CARD",
  "availablePayMethods": ["APPLEPAY", "GOOGLEPAY", "CARD"],
  "confirmTrigger": "merchant"
}
```

### 组件 error 事件

`element.on('error', (err) => {})`

该组件出错时触发。同一个错误也会出现在 `elements.on('error')`，两个都监听会收到两次，一般只需监听 `elements.on('error')`；需要区分是哪个组件出错时才使用它。

```javascript theme={null}
card.on('error', (err) => {
  cardMessage.textContent = err.message;
});
```

回调参数 `err` 与 [elements.on('error')](#error-事件) 相同。

### payment 事件

`elements.on('payment', (result) => {})`

产生支付结果时触发。钱包等组件自己提交的支付，和你调用 `confirmPayment()` 提交的支付，结果都从这里返回。同一次支付可能触发多次：先收到 `processing`，之后再收到 `succeeded` 或 `failed`。挂载时 session 已经支付或已结束，也会立即触发一次，见 [会话状态](#会话状态)。

```javascript theme={null}
elements.on('payment', (result) => {
  const data = result.data;
  const isSubscription = data && 'sessionType' in data;

  switch (result.status) {
    case 'succeeded':
      location.href = isSubscription
        ? `/success?subscription=${data.subscription?.subscriptionId}`
        : `/success?order=${data?.acquiringOrderId}`;
      break;
    case 'processing':
      message.textContent = '支付处理中，请等待最终结果';
      break;
    case 'failed':
      message.textContent = result.message ?? '支付失败';
      break;
    case 'requires_action':
      // 仅 handleNextAction: false 时出现，见「3DS 与后续动作」
      break;
  }
});
```

回调参数 `result`（与 `confirmPayment()` 的返回值结构相同）：

| 字段 | 类型 | 说明 |
| - | - | - |
| `status` | `'succeeded' \| 'processing' \| 'failed' \| 'requires_action'` | 支付结果状态，含义见 [Step 5](#step-5-处理支付结果) |
| `code` | `string` | 可选。失败时的结果码，不保证每次都有 |
| `message` | `string` | 可选。已本地化的结果说明，可直接展示 |
| `data` | `object` | 可选。订单数据或订阅数据，结构见下表；校验失败、连接失败等没有订单的情况下可能为空 |

用 `'sessionType' in data` 区分两种 `data`：

> [tab] 订单数据
>
> 普通支付 session 返回订单数据：
>
> | 字段 | 类型 | 说明 |
> | - | - | - |
> | `paymentRequestId` | `string` | 可选。支付请求 ID |
> | `merchantOrderId` | `string` | 可选。商户订单号 |
> | `acquiringOrderId` | `string` | 可选。Waffo 订单号，用于对账和查询订单 |
> | `orderStatus` | `string` | 可选。订单状态，如 `PAY_IN_PROGRESS`、`AUTHORIZATION_REQUIRED`、`AUTHED_WAITING_CAPTURE`、`PAY_SUCCESS`、`ORDER_CLOSE`。`AUTHED_WAITING_CAPTURE` 表示已授权、尚未请款 |
> | `orderAction` | `string \| null` | 可选。后续动作的 JSON 字符串，仅在自行处理后续动作时使用 |

> [tab] 订阅数据
>
> 订阅 session 返回订阅数据：
>
> | 字段 | 类型 | 说明 |
> | - | - | - |
> | `sessionType` | `'SUBSCRIPTION'` | 固定值，标识订阅 session |
> | `subscription.subscriptionId` | `string \| null` | 可选。订阅 ID |
> | `subscription.subscriptionStatus` | `string \| null` | 可选。订阅状态 |
> | `subscription.subscriptionAction` | `string \| null` | 可选。后续动作的 JSON 字符串，仅在自行处理后续动作时使用 |

缺失的字段 SDK 不会补默认值，读取前请判空。一次成功的订单支付结果示例：

```json theme={null}
{
  "status": "succeeded",
  "data": {
    "paymentRequestId": "req_20261025000001",
    "merchantOrderId": "ORDER_20261025001",
    "acquiringOrderId": "A2026102500000001",
    "orderStatus": "PAY_SUCCESS",
    "orderAction": null
  }
}
```

### redirect 事件

`elements.on('redirect', ({ url }) => {})`

SDK 即将整页跳转到外部支付页面（如银行页面）时触发，当前页面随后会被卸载。此时还没有支付结果，所以不会触发 `payment`。仅在 `handleNextAction` 为 `true`（默认）时出现。

```javascript theme={null}
elements.on('redirect', ({ url }) => {
  // 保存恢复页面所需的信息；不要在这里跳转页面，否则会打断 SDK 发起的跳转
  sessionStorage.setItem('pendingSessionId', elements.sessionId);
});
```

回调参数：

| 字段 | 类型 | 说明 |
| - | - | - |
| `url` | `string` | SDK 即将跳转到的外部支付页面地址 |

### error 事件

`elements.on('error', (err) => {})`

任意组件出错、SDK 处理后续动作失败或接口返回错误时触发，是所有错误的统一入口。

```javascript theme={null}
elements.on('error', (err) => {
  message.textContent = err.message;

  if (['A0063', 'A0064'].includes(err.code)) {
    // session 已不可用：请服务端新建 session，再调用 updateSessionId(newId)
  }
});
```

回调参数 `err`：

| 字段 | 类型 | 说明 |
| - | - | - |
| `code` | `string` | 错误码，可能来自 SDK（`SDK_*`）、组件或 Waffo 接口，见 [错误码](https://waffo.com/docs/zh/developer-docs/integration/checkout/element-reference#错误码) |
| `message` | `string` | 已本地化的错误说明，可直接展示 |
| `field` | `string` | 可选。出错的字段路径，仅自定义渲染的字段校验错误会带 |

## 3DS 与后续动作

默认情况下，SDK 会自动完成支付的后续动作：

| 后续动作 | SDK 的处理 | 你收到的事件 |
| - | - | - |
| 3DS 验证、需要在页面内完成的支付步骤 | 在你的页面上打开全屏弹层 | 完成后触发 `payment` |
| 付款指引（银行转账、二维码等） | 在弹层中展示指引 | 完成后触发 `payment` |
| 跳转到外部支付页面 | 整页跳转 | 跳转前触发 `redirect` |

弹层右上角始终有关闭按钮。用户关闭弹层时，你会收到 `processing`；SDK 在后台继续查询订单，拿到最终结果后再触发一次 `payment`。

> [!NOTE]
>
> 关闭付款指引不等于取消订单；关闭 3DS 后用户无法再完成验证，订单会停在处理中，直到超时或失败。

`confirmPayment()` 返回的是第一个结果：用户关闭弹层时就以 `processing` 返回，不会等待银行转账这类长时间流程。请持续监听 `payment` 事件获取后续结果。

收到 `redirect` 事件时，保存恢复页面所需的信息（如 `sessionId`、你的订单号）。

> [!WARNING]
>
> 不要在 `redirect` 回调里做页面跳转，否则会打断 SDK 发起的跳转。用户从外部支付页面返回后，可以用保存的 `sessionId` 重新挂载组件恢复结果（见 [会话状态](#会话状态)），并以服务端查询或 Webhook 为准。

```javascript theme={null}
elements.on('redirect', ({ url }) => {
  sessionStorage.setItem('pendingSessionId', elements.sessionId);
});
```

`redirect` 的回调参数只有一个字段 `url`（`string`），即 SDK 即将整页跳转到的外部支付页面地址。

如果你希望自己处理后续动作，创建时传入 `handleNextAction: false`。此时 SDK 不打开弹层也不跳转，而是返回 `status: 'requires_action'`。动作信息是 JSON 字符串，订单在 `data.orderAction`，订阅在 `data.subscription.subscriptionAction`：

```javascript theme={null}
const elements = sdk.createElements(sessionId, { handleNextAction: false });

elements.on('payment', (result) => {
  if (result.status !== 'requires_action') return;

  const data = result.data;
  const raw = data && 'sessionType' in data
    ? data.subscription?.subscriptionAction
    : data?.orderAction;
  if (!raw) return;

  try {
    const action = JSON.parse(raw);
    // 根据 action 中的实际字段展示指引或跳转；跳转前校验 URL 协议和目标
  } catch {
    message.textContent = '无法解析支付动作';
  }
});
```

自行处理后续动作后，SDK 不会再通知最终结果，请通过服务端查询或 Webhook 确认。

## 外观与语言

通过 `appearance` 设置组件外观，挂载后可以用 `elements.updateAppearance()` 实时更新，无需重新挂载：

```javascript theme={null}
await elements.updateAppearance({
  variables: { colorPrimary: '#5b8ef3', colorBackground: '#1a1a1a', colorText: '#ffffff' },
  layout: { type: 'horizontal', maxColumns: 3 },
});
```

建议每次传入完整的目标主题配置。

`variables` 和 `layout` 之外，还可以用两项做更细的调整：

* **`variables.spacing`**：组件内的间距，包括快捷按钮之间、手风琴各行之间、表单字段之间。
* **`rules`**：用 CSS 声明单独调整某类组件的样式，如输入框、标签、跳转按钮、支付方式图标，写法参考 Stripe Appearance API。

```javascript theme={null}
await elements.updateAppearance({
  variables: {
    colorPrimary: '#1957ff',
    spacing: { button: '8px', accordionItem: '8px', gridRow: '16px' },
  },
  rules: {
    '.Input': { border: '1px solid #d0d5dd', borderRadius: '10px' },
    '.Input:focus': 'border-color: var(--colorPrimary)',
    '.PayMethodLogo': { width: '32px', height: '32px' },
  },
});
```

**紧凑卡表单**：`inputs: 'condensed'`（对齐 Stripe 的 `appearance.inputs`）把卡号、有效期和 CVV 拼成一块，只保留一个「银行卡信息」标题，各字段用框内占位文字说明；持卡人姓名在下方单独一个输入框，与 Stripe Checkout 一致；默认的 `'spaced'` 每个字段单独带标签。目前只作用于卡表单。

```javascript theme={null}
const elements = sdk.createElements(sessionId, {
  appearance: { inputs: 'condensed', variables: { colorPrimary: '#1957ff' } },
});
```

**浮动标签**：`labels: 'floating'`（对齐 Stripe 的 `appearance.labels`）把字段名放进输入框里：没有内容时当作占位文字，聚焦或填写后缩小浮到框的左上角；默认的 `'above'` 把字段名放在框上方。和 `inputs: 'condensed'` 一起用时，「银行卡信息」标题和持卡人姓名的标签不再单独显示。目前只作用于卡表单。浮动标签默认使用次要文字色，`.Label` 规则不会改变它的颜色，要单独调整请用 `.Label--floating`。

**输入框尺寸与字号**：输入框文字是 `fontSizeBase` 的 0.875 倍（传 `16px` 得到 `14px`）。要和页面上的其他输入框对齐高度、内边距、字重或字号，请用 `.Input` 规则。`rules` 不能直接写 `height`，可以用上下内边距调整高度：

```javascript theme={null}
rules: {
  '.Input': { padding: '11px 12px', fontWeight: '400', fontSize: '16px' },
}
```

**容器较窄时**：点击即跳转的支付方式按钮，文字不换行，放不下时显示省略号；按钮宽度不足 96px 时只显示图标。Apple Pay、Google Pay 按钮由钱包方渲染，有自身的最小宽度，请为快捷支付区留出足够宽度。

全部主题变量、间距、`rules` 选择器和布局选项见 [Element API 参考](https://waffo.com/docs/zh/developer-docs/integration/checkout/element-reference#appearance)。

`locale` 支持以下语言，未设置时默认 `en`：

| `locale` | 语言 |
| - | - |
| `en` | English |
| `zh-Hans` | 简体中文 |
| `zh-Hant` | 繁體中文 |
| `ja-JP` | 日本語 |
| `ko-KR` | 한국어 |
| `id-ID` | Bahasa Indonesia |
| `ms-MY` | Bahasa Melayu |
| `th-TH` | ไทย |
| `vi-VN` | Tiếng Việt |
| `es-MX` | Español (México) |
| `pt-BR` | Português (Brasil) |
| `pl-PL` | Polski |
| `tr-TR` | Türkçe |

## 更新 session

用户修改了商品或优惠后，商户服务端更新 session，前端调用 `updateSessionId()` 原地刷新所有组件，无需重新挂载：

```javascript theme={null}
// session 已在服务端更新：传入相同的 sessionId
await elements.updateSessionId(sessionId);

// 支付失败后服务端新建了 session：传入新的 sessionId
await elements.updateSessionId(newSessionId);
```

* `updateSessionId()` 不会替你创建 session，新 session 需要服务端先创建。
* 组件还在加载、或上一次更新尚未完成时再次调用，会排队执行：连续调用只有最后一个 `sessionId` 生效，排队的调用在它完成时一起返回。
* 支付已经开始（提交中、等待 3DS 或付款指引、该 session 已有结果）时调用会抛出 `SDK_ELEMENTS_BUSY`。
* 同一个 session 改价刷新时，用户已填写的卡号、有效期、CVV 和其他字段会保留；换成新的 `sessionId` 时表单会清空。
* 更新进行中也可以挂载新的组件（例如改国家后新出现的钱包），新组件直接使用更新后的 session。
* 某个组件 15 秒内没有响应时，更新以 `SDK_CONNECTION_FAILED` 失败，`phase` 变为 `error`。组件的容器已被页面移除时，该组件会在更新时被自动卸载，需要时请重新 `mount()`。
* 支付已进入 `processing`、`succeeded` 或 `failed` 后，不能用同一个 `sessionId` 刷新后重新支付，请换新 session。
* 更新后可用支付方式、金额和自定义渲染的字段可能变化，请在 `change` 事件中重新读取。

## 清理

```javascript theme={null}
element.unmount();   // 移除单个组件
elements.destroy();  // 移除所有组件，并关闭仍在显示的 3DS / 付款指引弹层
sdk.destroy();       // 释放 SDK 资源
```

* `unmount()` 和 `destroy()` 只移除前端组件，不会取消或退款后端订单。
* 支付处于 `processing` 时不要提前调用 `destroy()`，否则收不到后续的 `payment` 事件。

> [!TIP]
>
> 在 React 等框架中，请在组件卸载时调用 `elements.destroy()`；`sessionId` 变化时调用 `updateSessionId()`，不要重新创建 Elements 实例。

## 错误处理

错误通过两种方式返回：

* **方法抛出**：`Elements` / `PaymentElement` 的方法抛出的异常，包括 `confirmPayment()` 的所有 reject，都带 `code` 和 `message`，字段类问题还带 `field`。
* **`error` 事件**：`elements.on('error')` 返回 `{ code, message, field? }`。

两种情况下 `code` 都可能来自 SDK（`SDK_*`）、组件或 Waffo 接口。

组件和接口给出的 `message` 已按 `locale` 本地化，可以直接展示给用户；SDK 在浏览器本地抛出的错误（如组件未挂载、正在提交、`options` 用错），`message` 是英文的开发说明，建议按 `code` 换成你自己的文案。不要根据 `message` 文案做业务分支。组件级的 `element.on('error')` 与 `elements.on('error')` 会收到相同的错误，一般只需监听后者。

### confirmPayment 被拒时的错误

`confirmPayment()` reject 的是一个 `Error` 对象（`name` 为 `WaffoSDKError`），带以下字段：

| 字段 | 类型 | 说明 |
| - | - | - |
| `code` | `string` | 错误码，一定存在 |
| `message` | `string` | 错误说明 |
| `field` | `string` | 可选。出错的字段路径，如 `payMethodProperties.cpf`；只有字段类错误才有 |

常见的错误码和处理方式：

| `code` | 原因 | 同时触发 `error` 事件 | 建议处理 |
| - | - | - | - |
| `SDK_ELEMENT_INCOMPLETE` | 表单没填完或格式不对 | 否 | 组件已经标出出错的字段，展示 `message` 即可 |
| `INVALID_USER_FIELD` | 自定义渲染时 `userFields` 校验不过 | 否，错误同时出现在 `state.errors` | 按 `field` 定位到你自己渲染的输入框 |
| `TERMS_NOT_AGREED` | 订阅 session 未同意协议 | 是 | 提示用户勾选协议 |
| `SDK_ELEMENTS_BUSY` | 正在加载、提交或等待结果 | 否 | 忽略这次点击，或提示稍后再试 |
| `SDK_ELEMENT_NOT_CONFIRMABLE`、`SDK_INVALID_CONFIRM_OPTIONS` | 当前方式不该由你提交，或 `options` 用错 | 否 | 属于接入问题，检查 `confirmTrigger` 和传入的参数 |
| `A0063`、`A0068` 等接口错误码 | 接口拒绝了这次支付 | 是 | 按 [接口错误码](https://waffo.com/docs/zh/developer-docs/integration/checkout/element-reference#错误码) 处理 |
| `SDK_UNKNOWN_ERROR` | 没有更具体原因，或组件页版本较旧 | 视情况 | 展示 `message` |

同时触发 `error` 事件的错误会收到两次，请用同一个提示区替换显示，避免重复提示：

```javascript theme={null}
payButton.addEventListener('click', async () => {
  try {
    await elements.confirmPayment();
  } catch (err) {
    if (err.code === 'SDK_ELEMENTS_BUSY') return; // 已在处理，忽略重复点击
    showError(err.message); // 替换显示，而不是追加
  }
});

elements.on('error', (err) => showError(err.message));
```

全部错误码见 [Element API 参考](https://waffo.com/docs/zh/developer-docs/integration/checkout/element-reference#错误码)。

## 上线检查清单

* 生产环境使用 `env: 'prod'` 和生产环境 Client API Key
* 结账页通过 HTTPS 提供
* 如需 Apple Pay，已在 Waffo 完成域名配置，见 [Apple Pay 集成](https://waffo.com/docs/zh/developer-docs/integration/apple-pay/overview.md)
* 以 `confirmTrigger` 决定是否展示你的**支付**按钮，钱包路径不调用 `confirmPayment()`
* 只在 `payment` 事件中处理结果，`processing` 期间保留 Elements 实例
* 发货以 Webhook 或订单查询为准，不只依赖前端结果
* 离开结账流程时调用 `elements.destroy()`


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.