# Digital Goods API

> window.getDigitalGoodsService() 让 TWA 中的 PWA 接入 Google Play Billing：getDetails()、listPurchases()、consume() 及各自的异常与回退。

Digital Goods API 让 Web 应用从商店后端读取自己的商品目录和用户的购买记录：`window.getDigitalGoodsService(provider)` 返回一个 `DigitalGoodsService`，其 `getDetails()`、`listPurchases()`、`listPurchaseHistory()` 与 `consume()` 直接对接该提供方。购买本身通过 Payment Request API 完成，付款方式用的是同一个提供方 URL。

Android 与 ChromeOS 上的 Chrome 101 是唯一实现，并且只在 PWA 运行于发布到 Google Play 的 Trusted Web Activity 中、以 `https://play.google.com/billing` 为提供方时可用（Chrome for Developers 指南）。MDN 的 browser-compat-data 没有 `DigitalGoodsService` 条目；Firefox 与 Safari 不暴露 `getDigitalGoodsService`。规范是 WICG 草案，不支持的环境下 `Window` 上根本没有这个方法，所以一个 `in` 检查就是完整的特性检测。

## 语法

```js
window.getDigitalGoodsService(serviceProvider)

digitalGoodsService.getDetails(itemIds)
digitalGoodsService.listPurchases()
digitalGoodsService.listPurchaseHistory()
digitalGoodsService.consume(purchaseToken)
```

`getDigitalGoodsService()` 返回 `Promise<DigitalGoodsService>`。`getDetails()` 返回 `Promise<sequence<ItemDetails>>`，`listPurchases()` 与 `listPurchaseHistory()` 返回 `Promise<sequence<PurchaseDetails>>`，`consume()` 返回 `Promise<undefined>`。一切都是 `[SecureContext]` 且仅限 `Window`；没有方法需要用户手势，但文档必须与顶层页面同源，并被允许使用 `payment` 这个 Permissions Policy 特性（同站 iframe 需要 `allow="payment"`）。

## 参数

三个方法接受参数；两个列表方法不接受。

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `serviceProvider` | `DOMString` | 是 | 基于 URL 的支付方式标识符；Google Play 为 `"https://play.google.com/billing"`。`undefined`、`null` 或 `""` 会拒绝。 |
| `itemIds` | `sequence<DOMString>` | 是 | 在商店控制台配置的商品或订阅 ID。不能为空；结果可能省略未知 ID，顺序也不保证。 |
| `purchaseToken` | `DOMString` | 是 | `listPurchases()` 或购买时 `PaymentResponse.details` 返回的令牌。不能为空。 |

`getDetails()` 兑现的 `ItemDetails` 字典包含以下成员：

| 成员 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `itemId` | `DOMString` | 是 | 被请求的 ID。 |
| `title` | `DOMString` | 是 | 用于展示的本地化名称。 |
| `price` | `PaymentCurrencyAmount` | 是 | 用户所在地区的 `{ currency, value }`；用 `Intl.NumberFormat` 格式化。 |
| `type` | `"product"` 或 `"subscription"` | 否 | 商品种类。 |
| `description` | `DOMString` | 否 | 本地化的详细描述。 |
| `iconURLs` | `sequence<DOMString>` | 否 | 商品图片。 |
| `subscriptionPeriod` | `DOMString` | 否 | ISO 8601 时长，仅订阅。 |
| `freeTrialPeriod` | `DOMString` | 否 | 免费试用的 ISO 8601 时长。 |
| `introductoryPrice` | `PaymentCurrencyAmount` | 否 | 优惠期内的价格。 |
| `introductoryPricePeriod` | `DOMString` | 否 | 优惠期的 ISO 8601 时长。 |
| `introductoryPriceCycles` | `unsigned long long` | 否 | 优惠价适用的计费周期数。 |

`listPurchases()` 与 `listPurchaseHistory()` 兑现的 `PurchaseDetails` 字典有两个必填成员 `itemId` 与 `purchaseToken`；`listPurchaseHistory()` 可能包含已消耗或已过期的购买，没有历史记录的商店返回与 `listPurchases()` 相同的列表。

## 异常

每个方法都以拒绝而非抛出报错。Chromium 的 `OperationError` 消息就是 Play Billing 的响应码字面量，这是区分「商品已拥有」与「商店应用缺失」的唯一途径。

| 异常 | 条件 |
|---|---|
| `InvalidStateError` | 在非 fully active 的文档中调用 `getDigitalGoodsService()`。 |
| `NotAllowedError` | 调用 `getDigitalGoodsService()` 的文档与顶层源不同源，或 `payment` Permissions Policy 不允许。 |
| `TypeError` | `getDigitalGoodsService()` 的提供方为 `undefined`、`null` 或空串；`getDetails([])`；`consume("")`。 |
| `OperationError` | `getDigitalGoodsService()` 的提供方在当前环境不受支持（不在 TWA 内，或打包时未启用 Play Billing）；四个服务方法在商店报错时。Chromium 的消息字符串：`unsupported payment method`、`unsupported context`、`error`、`itemAlreadyOwned`、`itemNotOwned`、`itemUnavailable`、`clientAppUnavailable`、`clientAppError`。 |

针对同一提供方的 `PaymentRequest.show()` 在用户退出 Play 购买面板时以 `AbortError` 拒绝；那是 Payment Request API 的异常，不属于本 API。

:::observed
在 Android 的 Chrome 中，`window.getDigitalGoodsService('')` 以 `TypeError: Empty payment method` 拒绝，从跨站 iframe 调用以 `NotAllowedError: Access denied from cross-site frames` 拒绝；没有 `allow="payment"` 的 iframe 得到 `NotAllowedError: Payment permissions policy not granted`。提供方被拒时，`OperationError` 的消息是上表中的响应码之一，例如在 Trusted Web Activity 之外的普通浏览器标签页中是 `OperationError: unsupported context`。字符串位于 Chromium 的 [`dom_window_digital_goods.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/payments/goods/dom_window_digital_goods.cc) 与 [`digital_goods_type_converters.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/payments/goods/digital_goods_type_converters.cc)（chromium.googlesource.com）。
:::

## 示例

每个示例都从同一个辅助函数出发，它在 Play 支撑的 Trusted Web Activity 之外兑现 `null`，应用的其余部分据此显示网页收银台（Stripe、PayPal 或普通表单）。

### 获取 Play 服务，不可用时回退到网页收银台

`in` 检查只说明浏览器实现了该 API；只有 Promise 兑现才说明这个安装包能连上 Play Billing。两种失败按同一方式处理。

```js
const PLAY_BILLING = 'https://play.google.com/billing';

async function getPlayService() {
  if (!('getDigitalGoodsService' in window)) return null;
  try {
    return await window.getDigitalGoodsService(PLAY_BILLING);
  } catch (err) {
    console.info(`Play Billing 不可用（${err.name}: ${err.message}）`);
    return null;
  }
}

const service = await getPlayService();
document.querySelector('#web-checkout').hidden = Boolean(service);
document.querySelector('#play-checkout').hidden = !service;
```

记录 `err.message` 能把 Play 响应码（`unsupported context`、`clientAppUnavailable`）留在遥测里，而不改变用户看到的内容。

### 用 getDetails() 按用户区域渲染价格

`price` 以 `PaymentCurrencyAmount` 形式到达；用 `Intl.NumberFormat` 格式化，而不是自己拼接 `value` 与 `currency`。缺失的 ID 直接不在结果里，所以遍历响应而不是请求。

```js
async function renderCatalogue(service, ids) {
  if (!service) return renderWebPrices(ids); // 从你自己的服务器获取
  const items = await service.getDetails(ids);
  const money = new Intl.NumberFormat(navigator.language, { style: 'currency', currency: items[0]?.price.currency ?? 'USD' });
  for (const item of items) {
    const row = document.createElement('li');
    row.textContent = `${item.title}: ${money.format(item.price.value)}`;
    if (item.type === 'subscription' && item.freeTrialPeriod) {
      row.textContent += `（试用 ${item.freeTrialPeriod}）`;
    }
    document.querySelector('#catalogue').append(row);
  }
}
```

`P7D` 之类的试用期是 ISO 8601 时长字符串，需要自行格式化；Chrome 101 的 `Intl` 没有可用于它的时长格式化器。

### 用 PaymentRequest 购买，再用 listPurchases() 恢复权益

购买流程是 Payment Request：`supportedMethods` 设为提供方 URL，SKU 放进 `data`；Play 忽略你仍必须提供的 `total`。在服务器上确认 `purchaseToken`：任何三天内未确认的购买，Google Play 都会退款并撤销（Chrome for Developers 指南）。之后的启动里，`listPurchases()` 返回用户已拥有的内容，包括在其他设备上的购买。

```js
async function buy(service, sku) {
  if (!service) return startWebCheckout(sku);
  const request = new PaymentRequest(
    [{ supportedMethods: PLAY_BILLING, data: { sku } }],
    { total: { label: 'Total', amount: { currency: 'USD', value: '0' } } },
  );
  try {
    const response = await request.show();
    const { purchaseToken } = response.details;
    const verified = await postJson('/play/acknowledge', { sku, purchaseToken });
    await response.complete(verified ? 'success' : 'fail');
    if (verified) await service.consume(purchaseToken); // 仅限消耗型商品
  } catch (err) {
    if (err.name !== 'AbortError') throw err; // 用户退出了 Play 购买面板
  }
}

async function restoreEntitlements(service) {
  if (!service) return [];
  const owned = await service.listPurchases();
  return (await postJson('/play/verify', owned)).filter((p) => p.valid).map((p) => p.itemId);
}
```

`consume()` 只用于设计为可重复购买的商品：规范写明购买被消耗后用户预期不再享有该权益，所以不要对订阅或永久升级调用它。

## 另请参阅

- [Payment Request API](/zh/reference/capabilities/payment-request/)，流程中负责购买的那一半
- [Trusted Web Activity（TWA）：PWA 进入 Play 商店](/zh/reference/installation/twa/)，该 API 所要求的容器
- [为 PWA 创造收入](/zh/guides/monetization/)
- [Digital Goods API: getDigitalGoodsService() method](https://wicg.github.io/digital-goods/#getdigitalgoodsservice-method)（wicg.github.io）
- [Receive Payments via Google Play Billing with the Digital Goods API and the Payment Request API](https://developer.chrome.com/docs/android/trusted-web-activity/receive-payments-play-billing/)（developer.chrome.com）
- [Chrome Platform Status: Digital Goods API](https://chromestatus.com/feature/5339955595313152)（chromestatus.com）