# 为 PWA 创造收入

> 用 Payment Request API 在 PWA 中收款：检测支持、在点击处理函数内构造请求、调用 show()，并保留表单回退。

完成本指南后，PWA 里的结账按钮会通过
[Payment Request API](/zh/reference/capabilities/payment-request/) 唤起浏览器自带的支付面板，
收集买家的卡片或钱包信息，把支付响应交给你的服务器，并在拒绝该请求的浏览器中回退到现有表单。
这个 API 是收集支付、地址与联系方式的标准方式；它本身不转账，扣款仍由你的支付处理商完成。

你需要一个通过 HTTPS 提供的页面（普通 HTTP 下该 API 为 `undefined`）、支付处理商给出的支付方式标识符，
以及一个接收处理商令牌的服务端接口。哪些商店与支付网络允许网页端结账属于政策问题，带日期的来源整理在
[支付](/zh/ecosystem/payments/)。

## 1. 检测 API 并预检支付方式

没有该 API 的浏览器中 `window.PaymentRequest` 不存在，所以渲染原生结账按钮之前先检测。存在时，构造一个
一次性请求并调用 `canMakePayment()`：浏览器能处理你列出的至少一种方式时它解析为 `true`。下面的 URL 形式
标识符沿用 MDN 示例的写法；你的处理商会给出自己的标识符。

```js
const methodData = [{ supportedMethods: 'https://example.com/pay' }];
const stubDetails = {
  total: { label: 'Stub', amount: { currency: 'USD', value: '0.01' } },
};

async function nativeCheckoutAvailable() {
  if (!('PaymentRequest' in window)) return false;
  try {
    return await new PaymentRequest(methodData, stubDetails).canMakePayment();
  } catch {
    // 用户可能在隐私设置里关闭了该查询，或者浏览器对频繁调用
    // 以 DOMException 拒绝。
    return false;
  }
}
```

每个页面只调用一次 `canMakePayment()`，不要每次渲染都调：MDN 注明调用过于频繁时浏览器可能以 `DOMException`
拒绝该 Promise。传给预检请求的 `methodData` 要与真实请求完全一致，否则预检回答的是另一个问题。

## 2. 在点击处理函数内构造请求

每次点击都新建一个 `PaymentRequest`：`show()` 在每个实例上只能调用一次。在处理函数内同步调用 `show()`。
规范允许浏览器在页面没有瞬时用户激活时以 `SecurityError` 拒绝 `show()`，Chrome 正是这样做的，
因此在点击与 `show()` 之间 `await` 一次网络请求会让结账失败。金额还没算好时，把一个 Promise 作为
`show()` 的参数传入，而不是先 `await`。

```js
function buildDetails(cart) {
  return {
    id: cart.orderId,
    displayItems: cart.lines.map((line) => ({
      label: line.name,
      amount: { currency: 'USD', value: line.price },
    })),
    total: { label: 'Total', amount: { currency: 'USD', value: cart.total } },
  };
}

document.querySelector('#checkout').addEventListener('click', async () => {
  const request = new PaymentRequest(methodData, buildDetails(currentCart()));
  try {
    const response = await request.show();
    const result = await fetch('/api/charge', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ orderId: currentCart().orderId, details: response.details }),
    });
    await response.complete(result.ok ? 'success' : 'fail');
  } catch (error) {
    if (error.name === 'NotSupportedError') {
      location.assign('/checkout/form'); // 这种支付方式没有可用的支付应用
    } else if (error.name !== 'AbortError') {
      throw error; // 买家关闭面板不算错误
    }
  }
});
```

`response.details` 装的是支付方式返回的内容：对 Google Pay 而言是一个支付令牌，由你的后端转交给处理商，
所以扣款发生在服务端，`complete()` 传入的是服务端的结论。Google Pay 也提供自己的 JavaScript 库（`pay.js`）
来驱动同一个面板；代价是页面多加载一个脚本，换来的是在没有 Payment Request API 的浏览器里也能渲染按钮。

:::observed
点击带来的用户激活过期后再调用 `show()`，Chrome 会以 `SecurityError` 拒绝，报错信息为
`PaymentRequest.show() requires either transient user activation or delegated payment request capability`。
跨源 `<iframe>` 在没有收到顶层页面带 `delegate: "payment"` 的 `postMessage()` 时调用 `show()`，报错信息相同。
在上面的处理函数里、`show()` 之前插入 `await new Promise((r) => setTimeout(r, 10000))` 即可复现。
:::

## 3. 保留表单路径并显示正确的按钮

只在第 1 步返回 `true` 时渲染原生结账按钮；否则渲染指向表单结账的链接。原生面板省去买家在手机上打字，
代价是多出一条需要测试的结账路径。表单是没有该 API 的浏览器（参见
[Payment Request API 浏览器支持](/zh/compatibility/payment-request/)）以及关闭了面板的买家的回退。

```js
nativeCheckoutAvailable().then((ok) => {
  document.querySelector('#checkout').hidden = !ok;
  document.querySelector('#checkout-form-link').hidden = ok;
});
```

面板以 `success` 关闭时，浏览器收起面板、页面继续；以 `fail` 关闭时，浏览器显示自己的错误状态。
至此，有浏览器面板的地方用面板，其余地方用你的表单。

## 另请参阅

- [Payment Request API：浏览器托管的结账流程](/zh/reference/capabilities/payment-request/)
- [Digital Goods API（TWA 中的 Play Billing）](/zh/reference/capabilities/digital-goods/)
- [支付](/zh/ecosystem/payments/)
- [Payment Request API: show() method](https://developer.mozilla.org/en-US/docs/Web/API/PaymentRequest/show)（developer.mozilla.org）
- [Payment Request API specification](https://www.w3.org/TR/payment-request/)（w3.org）

← 返回[指南](/zh/guides/)总览。