> ## 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.

# Stripe 迁移工具接入指南

> 把 Stripe 迁移工具接入你的 Java 项目：将即将到期的 Stripe 订阅转移至 Waffo，并完成查询、取消与 Sandbox 验证。

本文假设你已经读过 [Stripe 迁移工具](/docs/zh/developer-docs/tools-and-references/references/stripe-adapter)，确认这条迁移路径适合你的项目。下面先讲怎么改，再讲背后的规则。

## 前置条件

* 你与 Waffo 已签订订阅业务合约，拿到了 Sandbox 的 API 密钥、RSA 密钥对与商户号
* 项目中 `stripe-java` 版本不低于 24.11.0
* 你有一个可公网访问的 HTTPS 端点用于接收 Waffo 通知
* 你要路由的币种在合约范围内，可用 `paymethodconfig/inquiry` 确认

## 第 1 步 安装依赖

<Tabs>
  <Tab title="Maven">
    ```xml theme={null}
    <dependency>
        <groupId>com.waffo</groupId>
        <artifactId>waffo-java-stripe</artifactId>
        <version>0.2.0</version>
    </dependency>
    ```
  </Tab>

  <Tab title="Gradle">
    ```groovy theme={null}
    implementation 'com.waffo:waffo-java-stripe:0.2.0'
    ```
  </Tab>
</Tabs>

**保持你项目里现有的 `stripe-java` 版本不动。** 它是 provided 依赖，适配器不会替你升级或降级。`waffo-java` 由适配器传递引入，你不需要单独声明。

<Note>
  接入前请到 [Maven Central](https://central.sonatype.com/artifact/com.waffo/waffo-java-stripe) 确认最新版本，并用 `mvn dependency:get -Dartifact=com.waffo:waffo-java-stripe:<版本>` 验证可解析后再写进 `pom.xml`。
</Note>

## 第 2 步 构建路由客户端

把 `new StripeClient(key)` 换成 `WaffoStripe.client(...)`。适配器接收的是 Waffo 的路由配置，**它本身不持有你的 Stripe 密钥**。

```java theme={null}
import com.stripe.Stripe;
import com.stripe.StripeClient;
import com.waffo.stripe.WaffoStripe;
import com.waffo.stripe.config.WaffoConfig;

// 1. 你的 Waffo 凭据，用 waffo-java 的配置类构造
com.waffo.types.config.WaffoConfig waffoJavaConfig =
        com.waffo.types.config.WaffoConfig.builder()
                .apiKey(System.getenv("WAFFO_API_KEY"))
                .privateKey(System.getenv("WAFFO_PRIVATE_KEY"))       // RSA 私钥，base64
                .waffoPublicKey(System.getenv("WAFFO_PUBLIC_KEY"))    // Waffo 公钥，base64
                .merchantId(System.getenv("WAFFO_MERCHANT_ID"))
                .environment(com.waffo.types.config.Environment.SANDBOX)
                .build();

// 2. 路由配置
WaffoConfig routing = WaffoConfig.builder()
        .waffoConfig(waffoJavaConfig)
        .notifyUrl("https://your-app.example/webhooks/waffo")
        .onUnsupported(WaffoConfig.OnUnsupported.FAIL_LOUD)   // 迁移期建议，见下方说明
        .build();

// 3. Stripe 密钥按 stripe-java 原有方式提供，透传与回落时原样使用
Stripe.apiKey = System.getenv("STRIPE_SECRET_KEY");

StripeClient client = WaffoStripe.client(routing);
```

适配器会自动在每个发往 Waffo 的请求上打 `X-Waffo-Client: waffo-stripe-java/<版本>` 标识头，你不需要也不应该自己包装传输层去伪造它。

每个参数的含义、取值与默认值见下方[配置参数参考](#配置参数参考)。这里只强调一条选择建议：

<Tip>
  **迁移期先用 `FAIL_LOUD`。** 默认的 `FALLBACK` 会静默把无法路由的请求转给 Stripe，迁移初期你反而看不出哪些订阅没切过去。先用 `FAIL_LOUD` 把问题全部暴露出来，逐条确认并处理完，再切回 `FALLBACK` 作为生产环境的兜底。
</Tip>

如果你项目里已经有一个配置好超时和代理的 `StripeClient`，用 `WaffoStripe.client(routing, existingClient)` 能把这些设置一并保留，见[客户端构造与 Stripe 凭据](#客户端构造与-stripe-凭据)。

## 第 3 步 标记要转移的订阅

在原有的参数构造上加一行 `metadata`，其余参数不动。

```java theme={null}
SessionCreateParams params = SessionCreateParams.builder()
        .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
        .setSuccessUrl("https://your-app.example/subscription/success")
        .setCancelUrl("https://your-app.example/subscription/cancel")
        .addLineItem(SessionCreateParams.LineItem.builder()
                .setPrice("price_123")
                .setQuantity(1L)
                .build())
        .putMetadata("source", "waffo")        // 唯一需要新增的一行
        .build();

RequestOptions options = RequestOptions.builder()
        .setIdempotencyKey(persistedSubscriptionRequest)   // 调用前已持久化
        .build();

Session session = client.checkout().sessions().create(params, options);
redirect(session.getUrl());   // 路由成功时是 Waffo 收银台地址，回落时是 Stripe 地址
```

示例刻意不设置 `uiMode`。Stripe 默认使用跳转式收银台，迁移工具也把未设置的值按跳转式收银台处理。这样可以同时兼容 `stripe-java` 24.11.x、32.x 和 33.x；不要在 32.x 或 33.x 中改用 `HOSTED_PAGE`，因为对应的 `hosted_page` 值会被判定为非跳转式收银台。

### 将 Stripe 的即将到期订阅无缝转移至 Waffo

先从原 Stripe 订阅读取当前已付周期的结束时间，并记为 `handoffAt`。你仍需通过现有 Stripe 流程让旧订阅在 `handoffAt` 停止续费；迁移工具不会修改原 Stripe 订阅。

创建 Waffo 路由请求时，把同一个时间写入 `billing_cycle_anchor`，并显式设置 `proration_behavior=none`：

```java theme={null}
long handoffAt = stripeSubscription.getCurrentPeriodEnd();

SessionCreateParams params = SessionCreateParams.builder()
        .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
        .setSuccessUrl("https://your-app.example/subscription/success")
        .setCancelUrl("https://your-app.example/subscription/cancel")
        .addLineItem(SessionCreateParams.LineItem.builder()
                .setPrice("price_123")
                .setQuantity(1L)
                .build())
        .setSubscriptionData(SessionCreateParams.SubscriptionData.builder()
                .setBillingCycleAnchor(handoffAt)
                .setProrationBehavior(
                        SessionCreateParams.SubscriptionData.ProrationBehavior.NONE)
                .build())
        .putMetadata("source", "waffo")
        .build();
```

| 时间               | 行为                                                                                                             |
| ---------------- | -------------------------------------------------------------------------------------------------------------- |
| `handoffAt` 之前   | 客户打开 Waffo 收银台，完成卡输入与必要的 3DS 验证；不会收取首笔费用                                                                       |
| 等待期间查询           | `status=active`；`billing_cycle_anchor` 与 `current_period_end` 等于 `handoffAt`；`metadata.waffo_current_period=0` |
| 到达 `handoffAt`   | Waffo 自动发起首笔扣款，客户不需要再次操作                                                                                       |
| `handoffAt` 之前取消 | `Subscription.cancel("wsub_…")` 立即取消 Waffo 订阅，并阻止预定的首次扣款                                                       |

<Note>
  `billing_cycle_anchor` 会精确映射到 Waffo `startTime`。可预约的最大时间范围由 Waffo 后端校验，迁移工具不在本地硬编码 365 或 366 天。不要同时设置 `trial_end` 或 `trial_period_days`；这类组合会按映射失败处理。
</Note>

<Warning>
  **幂等键不能超过 32 个字符。你必须在调用前生成并持久化，重试时复用同一个。**

  适配器会把这个键作为 Waffo 的 `subscriptionRequest`，用于确认创建结果和防止重复订阅。不要直接传入超过 32 个字符的业务单号：当前版本会把长键转换为不可逆的 32 字符摘要，Webhook 无法用摘要还原原值。如果现有业务单号更长，请另外生成一个不超过 32 个字符的稳定关联键，并持久化它与业务单号的关系。

  不要依赖 `stripe-java` 自动生成幂等键。适配器会在 Stripe 网络层生成自动键之前拦截请求，这个自动键不会成为 Waffo 的 `subscriptionRequest`。如果没有在 `RequestOptions` 中显式传入键，商户也无法持久化并在下一次调用中复用它。
</Warning>

创建成功后：

* 返回的 `Session.id` 以 `wcs_` 开头，用 `client.checkout().sessions().retrieve("wcs_…")` 可以查回来。
* `session.getSubscription()` 返回对应的 `wsub_…` 订阅 id；用 `client.subscriptions().retrieve("wsub_…")` 会自动路由回 Waffo。
* 创建成功后，把 `wsub_…` 订阅 id 与你的业务单号一起持久化。处理 Webhook 时按这个订阅 id 查回业务记录，不要尝试从幂等键反推业务单号。
* 原生的 `sub_…` 和 `cs_…` id 仍然走 Stripe，两套 id 不会互相干扰。

## 第 4 步 翻译 Webhook 通知

在你配置的 `notifyUrl` 端点上调用 `handle(...)`。SDK 会完成验签、解析、事件翻译，并生成 Waffo 用来确认本次通知已送达的响应。

```java theme={null}
import com.stripe.exception.StripeException;
import com.stripe.model.Event;
import com.waffo.stripe.net.WaffoStripeWebhooks;
import com.waffo.stripe.net.WaffoStripeWebhookResult;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;

// 用同一份 waffo-java 配置构造一次即可，它自己完成签名校验
WaffoStripeWebhooks webhooks = new WaffoStripeWebhooks(waffoJavaConfig);

@PostMapping(value = "/webhooks/waffo", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> onWaffo(@RequestBody String body,
                                      @RequestHeader("X-SIGNATURE") String signature) throws StripeException {

    WaffoStripeWebhookResult result = webhooks.handle(body, signature);   // SDK 完成验签、翻译和确认响应生成

    // 订阅的支付尝试与重试记录在这里，不要混进一次性支付的发货逻辑
    if (result.getPaymentNotification() != null) {
        subscriptionPaymentHandler.handle(result.getPaymentNotification().getResult());
    }

    Event event = result.getEvent();
    if (event != null) {
        existingStripeWebhookDispatcher.dispatch(event);   // 你原有的 Stripe 分发逻辑
    }

    // SDK 不依赖具体 Web 框架；这里把它生成的确认响应映射为 Spring ResponseEntity
    return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_JSON)
            .body(result.getResponseBody());
}
```

### 返回 SDK 生成的确认响应

`handle(...)` 返回的 `WaffoStripeWebhookResult` 已包含确认响应正文。SDK 保持 Web 框架无关，因此不会直接返回 Spring 的 `ResponseEntity`。在 Spring 中按下面两项映射；使用其他 Web 框架时做等价映射：

| 要求                               | 说明                       |
| -------------------------------- | ------------------------ |
| 响应体                              | 直接返回 `getResponseBody()` |
| `Content-Type: application/json` | 不能是 `text/plain`         |

这份响应表示「本次通知已被你的端点接收」。如果改成返回 `"ok"`，Waffo 无法识别成功结果，会把同一条通知再次投递。

签名校验失败时**不要执行任何业务动作**。记录安全事件，然后通过订阅查询接口对需要恢复的状态做对账。

### 事件映射表

| Waffo 通知                                   | 翻译后的 Stripe 事件                                          |
| ------------------------------------------ | ------------------------------------------------------- |
| `SUBSCRIPTION_STATUS_NOTIFICATION`         | `customer.subscription.created` / `updated` / `deleted` |
| `SUBSCRIPTION_PERIOD_CHANGED_NOTIFICATION` | `invoice.paid` / `invoice.payment_failed`（首期与每次续费）      |
| `REFUND_NOTIFICATION`                      | `charge.refunded`，退款失败时为 `refund.updated`               |

翻译后的事件用你平时的 `event.getDataObjectDeserializer().getObject()` 取数据对象，与原生 Stripe 事件一致。

以下通知**不翻译**，`getEvent()` 返回 `null`：

* `PAYMENT_NOTIFICATION`——账期变更通知已经产生了 `invoice.paid` / `invoice.payment_failed`，再翻译一次会重复记账。它通过 `getPaymentNotification()` 单独暴露，按 `paymentInfo.productName` 区分订阅扣款与一次性支付。
* `SUBSCRIPTION_CHANGE_NOTIFICATION`——订阅升降级，不在首期范围内。
* 非终态的退款通知。

<Warning>
  **依赖 `checkout.session.completed` 发货的项目必须改造。** 适配器不翻译这个事件。请把订阅激活与权益发放迁移到 `customer.subscription.created` 与 `invoice.paid`，并用业务幂等防止重复发放。这是迁移中最容易被漏掉的一处。
</Warning>

<Note>
  旧的 `translate(body, signature)` 方法仍然保留，用于源码兼容。但它只返回翻译后的 `Event`，拿不到上述确认响应。**新接入一律用 `handle(...)`。**
</Note>

## 第 5 步 接入立即取消

迁移工具支持通过 Stripe 的默认取消调用立即取消 Waffo 订阅，但不模拟周期末取消、指定时间取消或订阅修改。

先看两边的差异：

|         | Stripe                      | Waffo                                     |
| ------- | --------------------------- | ----------------------------------------- |
| 立即取消    | 支持                          | 支持                                        |
| 周期末取消   | `cancel_at_period_end=true` | **没有对等开关**，只能立即取消                         |
| 指定时间取消  | `cancel_at`                 | 不支持                                       |
| 取消后能否恢复 | 周期末取消可在到期前撤销                | 取消即终态                                     |
| 取消的入口   | Stripe SDK                  | `client.subscriptions().cancel("wsub_…")` |

默认取消会调用 Waffo `subscription/cancel`，再查询同一个订阅确认取消终态。如果取消结果未知，迁移工具只通过同一个 `wsub_…` 查询恢复结果，不会把操作转给 Stripe。若订阅仍在等待 `handoffAt`，取消后不会发生预定的首次扣款。

<Warning>
  带 `invoice_now=true`、`prorate=true` 等额外计费语义的取消会直接报错。Waffo 当前也不支持与 Stripe 周期末取消等价的能力。如果你的产品依赖 `cancel_at_period_end`、指定时间取消、取消后恢复或 `Subscription.update("wsub_…")`，请把对应链路保留在 Stripe。
</Warning>

## 接入检查表

### 依赖与配置

* 依赖版本已从 Maven Central 确认可解析，不是照抄文档里的版本号
* `stripe-java` 版本不低于 24.11.0
* 客户端已改为 `WaffoStripe.client(...)`，Sandbox 配置已接入

### 代码改造

* 目标创建请求带 `metadata.source=waffo`，幂等键在调用前已持久化
* Webhook 端点使用 `handle(...)`，并返回 SDK 生成的响应 body
* `PAYMENT_NOTIFICATION` 有独立的订阅支付记录逻辑，不与一次性支付混用
* 发货逻辑已从 `checkout.session.completed` 迁移到 `customer.subscription.created` 与 `invoice.paid`
* 项目已提供 `wsub_` 订阅的查询与默认立即取消能力，并确认不依赖周期末取消等不支持的能力
* 即将到期订阅使用同一个 `handoffAt` 结束 Stripe 续费并设置 Waffo `billing_cycle_anchor`

### 验证

* 迁移期的 `FAIL_LOUD` 暴露出的每一处回落都已确认并记录结论
* 项目自身的 build 与测试全部通过
* Sandbox 全链路已跑通（见下一节）

## Sandbox 验证

验证必须通过**你项目自己的 HTTP 接口**发起，不能用适配器的内部测试代替。需要覆盖：

| 验证项     | 要点                                           |
| ------- | -------------------------------------------- |
| 创建      | 带标记的请求确实路由到了 Waffo，返回 `wcs_` 会话与 Waffo 收银台地址 |
| 支付      | 在浏览器里真实完成一次收银台支付                             |
| 查询      | `wsub_` 订阅可以查回，状态映射正确                        |
| 等待交接    | 客户已完成卡与必要的 3DS 验证；查询字段表明首个账期尚未开始             |
| 自动首扣    | 到达 `handoffAt` 后自动产生首笔扣款，不要求客户再次操作           |
| 续期      | 收到并正确处理续期通知                                  |
| 开始日前取消  | 立即取消等待中的 `wsub_…`，并确认没有产生首次扣款                |
| 取消能力    | 默认立即取消成功；依赖周期末取消的订阅链路保留在 Stripe              |
| 回落      | 命中回落条件的请求确实转到了 Stripe，`metadata` 中原因码正确      |
| 透传      | 未标记的请求行为与迁移前一致                               |
| Webhook | 响应体和 `Content-Type` 都符合协议                    |

***

以下是适配器的行为规则与参数详情，接入过程中遇到非预期结果时对照查阅。

## 配置参数参考

接入涉及三组配置，分别来自两个不同的 `WaffoConfig` 类（同名不同包，注意区分）。

### 一、路由配置 `com.waffo.stripe.config.WaffoConfig`

适配器自己的配置，决定哪些请求走 Waffo、通知发到哪、路由不了怎么办。**只有三个参数，没有别的开关。**

| 参数              | 类型                                   | 必填       | 默认值        | 含义                                                                                            |
| --------------- | ------------------------------------ | -------- | ---------- | --------------------------------------------------------------------------------------------- |
| `waffoConfig`   | `com.waffo.types.config.WaffoConfig` | 要路由订阅时必填 | `null`     | 路由目标的 Waffo 凭据。传 `null` 或不设置时，适配器退化为纯 Stripe 透传，`source=waffo` 标记会以 `waffo_not_configured` 回落 |
| `notifyUrl`     | `String`                             | 要路由订阅时必填 | 无          | Waffo 投递订阅通知的地址。必须公网可达。Stripe 的会话参数里没有对应字段，所以只能配在这里，对所有路由到 Waffo 的订阅生效                        |
| `onUnsupported` | `OnUnsupported` 枚举                   | 否        | `FALLBACK` | 创建请求无法路由到 Waffo 时的策略，取值见下                                                                     |

### 二、`OnUnsupported` 枚举取值

| 取值             | 行为                                                                                                                                         | 什么时候用                                           |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
| `FALLBACK`（默认） | 把创建请求转给 Stripe 正常完成，并在返回对象的 `metadata` 写入 `waffo_routing=stripe_fallback`、`waffo_fallback_reason`，以及 Waffo 返回明确拒绝码时的 `waffo_fallback_code` | 生产环境。用户始终能付款，代价是你要靠 `metadata` 事后发现哪些订阅没走 Waffo |
| `FAIL_LOUD`    | 不转给 Stripe，直接抛 `StripeException`，异常信息里带回落原因                                                                                                | 迁移期与联调期。把所有无法路由的情况立刻暴露出来，逐条确认后再切回 `FALLBACK`    |

<Warning>
  **这个参数的管辖范围是有限的。** 它只对「创建请求在发出前或收到明确拒绝后就能判定无法路由」的五类情况生效：命中红线、Waffo 明确拒绝、支付方式不支持、字段映射失败、未配置 Waffo 客户端。

  它**不影响**幂等冲突与网络未知状态的处理——那两种情况由独立的处理逻辑负责，见[为防止用户资损而不回落的场景](#为防止用户资损而不回落的场景)。
</Warning>

### 三、Waffo 凭据 `com.waffo.types.config.WaffoConfig`

这是 `waffo-java` 的配置类，你把它交给上面的 `waffoConfig` 参数，适配器用它构建访问 Waffo 的客户端。

| 参数               | 类型               | 必填 | 含义                                    |
| ---------------- | ---------------- | -- | ------------------------------------- |
| `apiKey`         | `String`         | 是  | Waffo 分配的 API 密钥                      |
| `privateKey`     | `String`         | 是  | 你的 RSA 私钥，base64 编码，用于给请求签名           |
| `waffoPublicKey` | `String`         | 是  | Waffo 的 RSA 公钥，base64 编码，用于验证响应与通知的签名 |
| `merchantId`     | `String`         | 是  | 你的商户号                                 |
| `environment`    | `Environment` 枚举 | 是  | `SANDBOX` 或 `PRODUCTION`，决定请求发往哪个环境   |
| `connectTimeout` | `int`            | 否  | 连接超时                                  |
| `readTimeout`    | `int`            | 否  | 读取超时                                  |

三种构造方式，任选其一：

<CodeGroup>
  ```java Builder（显式） theme={null}
  com.waffo.types.config.WaffoConfig cfg =
          com.waffo.types.config.WaffoConfig.builder()
                  .apiKey(System.getenv("WAFFO_API_KEY"))
                  .privateKey(System.getenv("WAFFO_PRIVATE_KEY"))
                  .waffoPublicKey(System.getenv("WAFFO_PUBLIC_KEY"))
                  .merchantId(System.getenv("WAFFO_MERCHANT_ID"))
                  .environment(com.waffo.types.config.Environment.SANDBOX)
                  .build();
  ```

  ```java 环境变量 theme={null}
  // 读取 WAFFO_API_KEY / WAFFO_PRIVATE_KEY / WAFFO_PUBLIC_KEY
  //    / WAFFO_MERCHANT_ID / WAFFO_ENVIRONMENT
  com.waffo.types.config.WaffoConfig cfg =
          com.waffo.types.config.WaffoConfig.fromEnv();
  ```

  ```java 配置文件 theme={null}
  // 读取 waffo.api-key / waffo.private-key / waffo.waffo-public-key
  //    / waffo.merchant-id / waffo.environment
  // springEnvironment 是已注入的 org.springframework.core.env.Environment
  com.waffo.types.config.WaffoConfig cfg =
          com.waffo.types.config.WaffoConfig.fromProperties(springEnvironment::getProperty);
  ```
</CodeGroup>

### 客户端构造与 Stripe 凭据

`WaffoStripe.client(...)` 有三个重载。它们的区别**只在于透传和回落时用哪个 Stripe 凭据**，路由到 Waffo 的行为三者完全一致。

| 构造方式                                 | 透传与回落使用的 Stripe 凭据                               |
| ------------------------------------ | ------------------------------------------------ |
| `client(WaffoConfig)`                | 全局 `Stripe.apiKey`，或每次请求单独传的密钥                   |
| `client(WaffoConfig, StripeClient)`  | 复用你已有 `StripeClient` 的客户端级密钥、HTTP 客户端、超时与代理设置    |
| `client(WaffoConfig, String apiKey)` | 传入的密钥作为客户端级凭据，等价于上一行传 `new StripeClient(apiKey)` |

凭据优先级：**每请求的 `RequestOptions` 密钥 > 客户端级 > 全局**。

如果第二个重载无法从当前 `stripe-java` 版本读出客户端级凭据，客户端将无法完成初始化。请升级到受支持的版本，或改用显式传入 API 密钥的重载。

### 请求级参数

| 参数                      | 在哪里传                  | 必填             | 含义                                                  |
| ----------------------- | --------------------- | -------------- | --------------------------------------------------- |
| `metadata.source=waffo` | `SessionCreateParams` | 要路由到 Waffo 时必填 | 路由标记。不带这个标记的请求一律原样透传 Stripe                         |
| `idempotencyKey`        | `RequestOptions`      | 接入要求           | 不超过 32 个字符。调用前生成并持久化，重试复用同一个键；如果业务单号更长，另建稳定关联键并保存映射 |

## 不支持的 Stripe 用法

首期只覆盖订阅 Checkout 的一个子集。下表列出全部不被支持的用法。

标着「转回 Stripe」的那些不影响用户支付：适配器把创建请求转给 Stripe 正常完成，用户照常付款，原因码写进返回对象的 `metadata.waffo_fallback_reason`，同时带 `waffo_routing=stripe_fallback`。这两个字段名与 `waffo_fallback_code` 都是适配器保留的，不要在业务代码里占用。其余的会报错或不生效，需要改代码。

<Note>
  表里每一条都需要你对着自己的代码人工确认——扫描器只能给线索，币种、支付方式、动态拼装的参数它都判断不了。确认方法见下方[提前扫出自己会踩哪几条](#提前扫出自己会踩哪几条)。
</Note>

| 你的 Stripe 用法                                                              | 后果与处理                                                                                       | 结果                           |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------- |
| 卖的是一次性商品，不是订阅                                                             | 转回 Stripe。一次性支付本来就不该打标记，去掉 `source=waffo`                                                   | `not_subscription_mode`      |
| 收银台嵌在你自己的页面里，不跳转                                                          | 转回 Stripe。Waffo 需要跳转式收银台，移除 `uiMode` 设置即可（Stripe 默认就是跳转式），或接受走 Stripe                       | `non_hosted_ui`              |
| 一个订阅里包含多个商品或套餐                                                            | 转回 Stripe。Waffo 订阅是单一金额，可拆成多个订阅                                                             | `multi_item`                 |
| 价格信息不完整                                                                   | 转回 Stripe。补上价格，`price` 和 `price_data` 至少要有一个                                                | `missing_price`              |
| 用了优惠券或促销码                                                                 | 转回 Stripe。Waffo 订阅没有优惠券入口，可把折扣算进价格                                                          | `has_discount`               |
| 有试用期，且试用期内不预先收卡                                                           | 转回 Stripe。Waffo 必须先收卡，把 `payment_method_collection` 改为 `always`                             | `trial_without_upfront_card` |
| 按用量计费，用多少付多少                                                              | 转回 Stripe。不在首期支持范围                                                                          | `metered`                    |
| 阶梯定价，买得越多单价越低                                                             | 转回 Stripe。不在首期支持范围                                                                          | `tiered`                     |
| 订阅分阶段，比如前三期一个价之后换价                                                        | 转回 Stripe。无法表达为固定的 Waffo 周期                                                                 | `has_schedule`               |
| 结算币种不在你的 Waffo 合约里                                                        | 转回 Stripe。用 `paymethodconfig/inquiry` 确认合约支持哪些币种                                            | `currency_mismatch`          |
| 用户选的支付方式 Waffo 不能做续费                                                      | 转回 Stripe。Waffo 支持卡以及支付宝、微信支付、GrabPay、KakaoPay、NaverPay、PIX；卡与具名钱包混用、或含 Waffo 不支持的方式也会回落    | `unsupported_payment_method` |
| 价格不是续费型、金额由用户自填、或价格查不到                                                    | 转回 Stripe。检查价格对象——适配器宁可回落，也不会给 Waffo 发一个错的请求                                                | `mapping_error`              |
| 打了标记但还没配 Waffo 凭据                                                         | 转回 Stripe。检查 `waffoConfig(...)` 是不是传了 `null`                                                | `waffo_not_configured`       |
| Waffo 明确拒绝了这一笔                                                            | 转回 Stripe。按 `metadata.waffo_fallback_code` 排查，最常见的是订阅业务还没开通                                 | `waffo_rejected`             |
| 修改订阅：`Subscription.update("wsub_…")`                                      | 适配器不支持                                                                                      | 不支持                          |
| 取消时设置 `invoice_now=true`、`prorate=true` 或其他额外计费语义                         | 迁移工具只承接默认立即取消                                                                               | 不支持                          |
| 周期末取消：`cancel_at_period_end` / `cancel_at`                                | Waffo 没有对等能力，依赖该能力的订阅链路请保留在 Stripe                                                          | 不支持                          |
| 改订阅周期、按比例分摊、暂停、改试用期长度                                                     | Waffo 的订阅更新只能改金额（`amount`、`trialPeriodAmount`、`scheduledAmounts`），这些维度都改不了                  | 语义不对等                        |
| 升降级换 price，走 Stripe 的原地按比例分摊                                              | Waffo 升降级是重建式——带新产品、可能重新过一次收银台，结果与 Stripe 不等价                                               | 语义不对等                        |
| 对 Waffo 订阅操作 item：`subscriptionItems.create/list(subscription="wsub_…")`  | Waffo 不产生 `si_…` 这类 item id，没有可对应的对象                                                        | `InvalidRequestException`    |
| 用 Waffo 订阅建计划表：`subscriptionSchedules.create(from_subscription="wsub_…")` | 会让 Stripe 接管 Waffo 订阅的计费节奏，因此拒绝                                                             | `InvalidRequestException`    |
| 靠 `checkout.session.completed` 发货                                         | 迁移后这个事件不再产生，发货逻辑会静默不执行。改用 `customer.subscription.created` 与 `invoice.paid`，见[事件映射表](#事件映射表) | 无异常，逻辑不执行                    |

反过来，已经存在的 `si_…`、`sub_sched_…`、`sub_…` id 都表示这个对象归 Stripe 所有，相关操作**原样透传**，行为不变。

<Tip>
  迁移期间应保持 `onUnsupported=FAIL_LOUD`。标着「转回 Stripe」的那一类会改为抛异常，便于逐条确认；全部确认后再切回 `FALLBACK` 作为生产兜底。见[配置参数参考](#配置参数参考)。
</Tip>

### 提前扫出自己会踩哪几条

不用手工通读全部代码。[AI 迁移技能](/docs/zh/developer-docs/tools-and-references/references/stripe-adapter#用-ai-迁移技能自动完成)自带一个扫描器，会把项目里每一处 Stripe 调用归到下面的分类里：

| 扫描结论                               | 对应上表                    | 你要做什么           |
| ---------------------------------- | ----------------------- | --------------- |
| `PASS_THROUGH`                     | 不在上表，保持走 Stripe         | 不用改             |
| `ROUTED_LIKELY`                    | 没扫到上表任何一条，但**不等于**一定能路由 | 必须 Sandbox 实测确认 |
| `FALLBACK`                         | 命中标着「转回 Stripe」的某条      | 接受，或调整参数        |
| `UNSUPPORTED`                      | 命中其余某条                  | 必须改代码           |
| `REVIEW`                           | 扫描判断不了来源                | 人工追到能定性为止       |
| `BLOCKED_PENDING_OWNERSHIP_REVIEW` | 创建链归属没确认清楚              | 先定归属，在此之前不要打标记  |

扫描器给不出结论、需要你人工追的主要是这几类：

* **参数在别的文件、工厂方法或自研封装里拼装**——追到真正传给 create 的那一组值，再逐条对照上表。
* **修改/取消操作的目标 id 来源不明**——顺着业务链路确认是 `wsub_` 还是 `sub_`：迁移工具支持查询和默认立即取消 `wsub_`，但不支持修改；`sub_` 原样透传 Stripe。
* **`metadata` 或事件名用枚举、常量拼出来**——静态扫描枚举不全，人工确认标记有没有真的打上。

<Warning>
  如果你的项目里同时存在候选迁移的订阅创建，以及 `SubscriptionItem` 或 `SubscriptionSchedule` 调用，必须先确认这些调用属于哪条创建链。依赖多 item、按比例分摊或计划表阶段的整条链路应当整体保留在 Stripe。归属没确认清楚之前，不要给候选的创建请求加 `source=waffo`。
</Warning>

<Note>
  扫描器基于正则与文件上下文，不是 Java 语法树分析，**输出只能当清单用，不能当验收结论**。`ROUTED_LIKELY` 尤其要注意：它只说明没扫到明显的红线。适配器会在创建时尽量按合约预检币种，拿不到合约配置时交给 Waffo 创建接口校验；支付方式只做映射预筛，是否在合约内同样由 Waffo 创建接口校验。动态拼装的参数也只有跑起来才确定。真正的结论来自 [Sandbox 验证](#sandbox-验证)。
</Note>

## 为防止用户资损而不回落的场景

有些情况下 Waffo 可能已经把订阅落库了，此时再转给 Stripe 建一次，用户就会被扣两次款。所以下面这些场景**无论**你把 `onUnsupported` 设成什么，适配器都不会回落到 Stripe：

**幂等冲突与网络未知状态。** 这两种情况下 Waffo 侧可能已经创建了订阅。适配器会用原请求的同一个幂等键发起查询：

* 查到已存在的订阅 → 返回这个订阅的 `wsub_` 会话，等同于创建成功。
* 无法确认结果 → 不支持自动回落，由你查询并确认最终状态。

**Waffo 明确拒绝。** 即使是明确拒绝，适配器也会先用同一个幂等键查询一次，**只有在确认订阅不存在时**才按原因码回落。查到已有订阅就路由到它；任何瞬时或不确定的查询结果都不支持自动回落。

这两条规则的取舍是一致的：**宁可暂停本次创建并先确认状态，也不能让用户被重复扣款。**

## 版本兼容与发布认证

`stripe-java` 是 provided 依赖：适配器不锁定版本，由你的项目决定用哪个。

|       | 版本                               |
| ----- | -------------------------------- |
| 支持下限  | 24.11.0。这是带集合点请求接口的最低版本；更低版本不受支持 |
| 已验证上限 | 33.x                             |

每个 `waffo-java-stripe` 版本发布前，都要在 14 个固定的 `stripe-java` 稳定版本上跑完整的确定性测试与真实 Sandbox 回归，全部通过才允许发布。版本变更记录见仓库的 [CHANGELOG](https://github.com/waffo-com/waffo-stripe/blob/main/CHANGELOG.md)。

## 相关资源

* [Stripe 迁移工具](/docs/zh/developer-docs/tools-and-references/references/stripe-adapter)——功能介绍与适用性判断
* [Webhook 签名验证](/docs/zh/developer-docs/webhook/signature-verification)——Waffo 通知的签名机制
* [幂等性](/docs/zh/developer-docs/core-concepts/idempotency)——Waffo 的幂等键设计
* [错误码](/docs/zh/developer-docs/tools-and-references/developer-tools/error-codes)——查询 Waffo 错误码的含义
* [GitHub 仓库](https://github.com/waffo-com/waffo-stripe)——源码与更新日志
