# PiPayment SDK

PiPayment SDK 是 PiPayment 的唯一正式支付内核。它向宿主提供一致的支付、退款、
Reader 查询、动态交互和历史交易查询接口，并把不同 provider 的协议差异隔离在
Provider Adapter 内部。SDK 不负责弹窗、页面 schema、国际化或业务文案渲染；
EFTPOS UI 只消费 SDK 的中立事件协议和结构化展示语义。

## 架构

SDK 按职责拆为五层：

1. `core/`：`PiPaymentSDK` 与 `PaymentSession`，只管理 client、attempt 生命周期、并发锁、重试和销毁。
2. `contracts/`：payment、session、interaction、provider、transport 的稳定中立契约。
3. `services/`：Reader 与 Transaction 用例，不包含 UI 或 Provider 分支。
4. `infrastructure/`：宿主 request 解析和后端 API 传输实现。
5. `providers/`：注册表与各 Provider Adapter；SCI、Tyro 的协议、轮询、action 和映射均封装于此。

一个 SDK 可以创建多个互相隔离的 Session；一个 Session 可以在失败后以相同 operation
重试多个 attempt，但同时只允许一个活跃 Flow。

Provider 通过 `PaymentProviderRegistry` 注册。新增 Provider 不需要修改 `core/` 或
Session；未注册的 Provider 会立即以 `unsupported_provider` 失败，不存在“已识别但未实现”状态。

## 初始化与 request 注入

```ts
import { PiPaymentSDK } from '@pisell/private-materials';

const sdk = new PiPaymentSDK({
  app: {
    request: {
      get: (url, params, config) => http.get(url, params, config),
      post: (url, data, config) => http.post(url, data, config),
      put: (url, data, config) => http.put(url, data, config),
    },
  },
});
```

在 `private-materials` 内接入 Provider 时，将完整的 Provider 对象注册到内置列表；
MX51、Adyen POS、Payo、Tyro 使用同一个注册机制。

SCI Provider 使用统一的声明对象接入：

```ts
import { defineSciProvider } from '../provider';

const provider = defineSciProvider({
  id: 'custom-sci',
  method: 'eftpos',
  capabilities,
  createHooks: () => ({
    onBeforeAttempt,
    onStart,
    onResponse,
    onAction,
    onTimeout,
  }),
});

const sdk = new PiPaymentSDK({ app, providers: [provider] });
```

只有 `onResponse` 必填。`createHooks` 每次交易创建独立 hooks；Provider 只负责参数校验、
展示/副作用映射、action data 转换和超时能力声明，不能接管 SCI 请求、轮询或终态。

`request.post` 是必需能力；Reader 查询需要 `get`；SPI transaction 更新优先使用
`put`，未提供时兼容使用 `post`。SDK 也会兼容从 `app.getApp()`、`app.utils.request`
或 `app.appHelper.utils.request` 解析宿主 request。

## 创建 Session

```ts
const session = sdk.create({
  paymentMethod: 'eftpos',
  paymentProvider: 'mx51',
  salesId: 'sale-1001',
  usageScene: 'merchant',
  onChange(event) {
    // 所有 Reader、交易状态、交互和终态事件都从这里发出。
  },
});
```

`paymentMethod`、`paymentProvider`、`salesId`、`usageScene` 在 Session 生命周期内
不可变。`onChange` 是唯一事件出口；宿主回调抛出的异常不会打断 SDK Flow。

## Reader、支付、退款与 action

```ts
session.listReader({ provider: 'mx51', status: 'paired' });

session.pay({
  amount: '11.50',
  originalAmount: '10.00',
  internalServiceChargeFee: '1.50',
  currency: 'AUD',
  reader,
  operatorId: 'operator-1',
  metadata: { order_id: 'order-1' },
});

// 退款应使用单独创建且锁定为 refund 的 Session。
refundSession.refund({
  amount: '10.00',
  originalPaymentNumber: 'original-payment-number',
  reader,
});

// processing/signature 事件中的 action 必须原样交还当前 Session。
session.action(action, { answer: 'YES' });
```

`listReader` 只适用于 EFTPOS。新查询会中止同一 Session 的上一次 Reader 查询；
查询结果通过 `readerList` 事件返回。`action` 只在当前 attempt 仍有活跃 Flow 时可用。

Adyen POS 使用 `paymentProvider: 'adyenpos'`。其退款必须传原 PiPayment 交易的
`originalPaymentNumber`；SDK 在存在该值时映射为 `original_payment_number`，参数完整
性由后端统一校验。统一支付请求不会转发 UI 草稿中的 `metadata`、`order_id` 或旧
External Unified 字段。

## 查询历史交易

`query` 必须且只能使用 `number` 或 `salesId` 其中一个：

```ts
const byNumber = await sdk.query({
  paymentMethod: 'eftpos',
  paymentProvider: 'mx51',
  number: 'payment-number',
});

const bySale = await sdk.query({
  paymentMethod: 'eftpos',
  salesId: 'sale-1001',
});
```

同时提供两个标识或两个都不提供会得到 `invalid_query` 错误。

## Session 与 attempt 生命周期

- `create` 只建立本地 Session，不会自动发起交易。
- 第一次调用 `pay` 或 `refund` 后，Session 永久锁定到该 operation，禁止混用。
- 每次 attempt 在任何 provider 请求之前生成 `number` 和请求幂等 token。
- 调用方可以传入 `number` 与 `requestUniqueIdempotencyToken` 恢复既有身份；未传入时由 SDK 生成。
- `attemptCreated` 一定先于该 attempt 的 `processing`、`signature`、`failed` 或 `success`。
- 同一 Session 不允许并发 attempt；普通失败会释放当前 Flow 并允许同 operation 重试，
  `retryable:false` 的失败会锁住 Session，禁止再次发起扣款。
- 成功是 Session 终态，成功后不能再次 pay/refund。
- retry 或 destroy 会递增内部 generation；旧 Flow 的迟到事件会被丢弃。
- `destroy` 是幂等的，会中止 Reader 查询、销毁活跃 Flow，并屏蔽后续事件。

## 事件协议

| 事件             | 含义                                                                                 |
| ---------------- | ------------------------------------------------------------------------------------ |
| `readerList`     | Reader 查询成功或失败；不属于交易 attempt。                                          |
| `attemptCreated` | 本地 attempt 身份已生成，尚不表示后端已受理。                                        |
| `processing`     | provider 正在处理，并携带 UI 中立的 `PaymentViewData`。                              |
| `signature`      | 需要签名确认或拒绝，并携带固定签名交互模型。                                         |
| `effect`         | 非状态副作用：`print`、`manualMark`、`printOnTimeout` 或成功后本地复核。             |
| `success`        | 交易成功终态，携带统一 `PaymentResult`。                                             |
| `failed`         | 当前 attempt 失败终态，携带统一 `PaymentError`；可按错误能力决定是否重试或手动处理。 |

`PaymentError` 提供稳定的 `code`、仅供诊断的 `diagnosticMessage`，并可携带
`displayMessage`、`displayTitle`、`displayDetail`、`failureKind`、`manualMarkable`、
`retryable`、`printTimeoutable`、`networkError`、payment 摘要和不透明的 `raw`。
调用方不应把 `diagnosticMessage` 或 provider 原始错误直接展示给用户。

SCI 成功小票的唯一来源是 `PaymentSummary.receipt`。每项通过 `type` 标识
`merchant`、`customer`、`signature` 或 `unknown`，不能依赖后端数组位置判断联单
类型。当前成功打印忽略 `signature`，并将其他类型稳定排序为
`merchant`、`customer`、`unknown`；`receipt` 缺失、非法或为空数组时不打印，
即使 `provider_response` 中仍包含厂商旧小票字段也不回退读取。

展示字段统一使用 `PaymentDisplayText`：SDK 固定语义输出 message key，Provider、后端
或设备的动态原文输出带来源的 literal。message key 的默认翻译、Provider 文案包和宿主
覆盖均由 EFTPOS UI 的唯一 resolver 处理。

## 能力矩阵

| Method / Provider  | Adapter | 交易 Flow | Reader 查询 | 历史查询 |
| ------------------ | ------- | --------- | ----------- | -------- |
| EFTPOS / MX51      | SCI     | 已实现    | 支持        | 支持     |
| EFTPOS / Adyen POS | SCI     | 已实现    | 支持        | 支持     |
| EFTPOS / Tyro      | SPI     | 已实现    | 支持        | 支持     |
| EFTPOS / Payo      | SCI     | 已实现    | 支持        | 支持     |
| EFTPOS / Linkly    | SCI     | 已实现    | 支持        | 支持     |
| EFTPOS / Windcave  | SCI     | 已实现    | 支持        | 支持     |

能力矩阵只列出实际注册且可运行的 Provider。WalletPass 当前不注册，待其 adapter 和
contract tests 完整实现后再加入。

Payo 使用统一 SCI code/status 判定；商户 processing 提供本地手动标记，用户
processing 在 attempt 开始 30 秒后提供本地取消。两种操作都不会调用 provider
`/action`，其中本地取消以 `interaction_cancelled` 结束前台 Flow。

Adyen POS 支付在普通 processing 阶段提供 Abort。Abort 只提交
`data: { action: 'abort' }`，使用当前 attempt 的 `number`，无论 action 成功、失败或
底层请求超时都继续 verify，不能用 action 的响应直接判定交易终态。SCI 不为 action
额外设置超时或生成失败终态。退款不提供 Abort；终端进入签名阶段后也会禁用 Abort
并继续等待交易终态。

Adyen POS 成功响应中的 `provider_response.cvm_result.signature_required` 会生成
Provider 中立的 `postSuccessReview` effect。该 effect 只控制 UI 何时通知宿主成功；
人工接受或拒绝均不会提交签名 action，拒绝也不会自动退款。Adapter 不读取旧
`external_unified_response`。Adyen Alipay/WeChat Scan 不在 PiPayment 的 Adyen POS
Provider 范围内。

## 资源释放

```ts
session.destroy(); // 关闭单个支付弹窗时调用
sdk.destroy(); // 宿主插件卸载时统一销毁全部未释放 Session
```

SDK 被销毁后不能再次 `create` 或 `query`。UI、页面卸载和宿主插件卸载都应明确调用
对应层级的 `destroy`，不要依赖垃圾回收终止网络请求或 provider client。

## Tyro 中断边界

Tyro 当前只支持在同一个页面和 `PaymentSession` 生命周期内完成新支付或退款，不提供
resume 接口，也不会调用 Tyro `continueLastTransaction`。

如果在已发起 Tyro 交易但尚未收到 `transactionComplete` 时刷新页面、App 崩溃或强制
销毁，调用方必须人工核对 Tyro 与后端交易。此时可能留下未收口的 pending transaction；
未核对就重新发起支付存在重复扣款风险。
