# 支付设计（SaaS P5）—— 充值积分包，不做订阅

> 2026-07-17。前置已就绪：P2 计量（`usage_event`/`credit_ledger`/物化余额/rollup）、
> P4 积分环（`GrantCredits` + 余额硬拦截）、薄 Web 面（账单页已有充值占位）。
> 本文一页纸定路线，增量照此施工；渠道选择（§3）留用户拍板。

## 1. 结论先行

- **做「预付积分包」充值，不做订阅**。理由：积分体系是现成的——支付入账只是给
  `credit_ledger` 多一个自动化的 `grant` 来源，改动集中在「订单 + 回调」；订阅制
  （周期扣费/升降级/按比例退差）是另一个量级的状态机，等有付费用户再议。
  `Tenant.Plan`（free/pro/enterprise）字段留着，将来订阅直接长在上面。
- **渠道做成适配器接口，先上一个「零资质渠道」跑通闭环**（兑换码/人工转账核销），
  真渠道（支付宝/微信/Stripe）接进来只是多一个 adapter，不动骨架。
- **金额一律服务端说了算**：前端只传「包 ID」，价格/积分数由服务端订单锁定，
  回调按订单校验金额，不信任任何客户端传值。

## 2. 与现有账本的对接点（都是现成的）

| 现有件 | 位置 | 支付怎么用 |
|---|---|---|
| `credit_ledger`（append-only，kind: grant/usage/adjust，`Ref` 字段） | `store/credit.go` | 支付入账 = `kind=grant, ref=order_id` |
| `GrantCredits(ctx, tenant, kind, micro, ref, memo)`（事务内：分录+物化余额） | `store/credit.go:151` | 回调确认后调用；**内部已 WithoutTenant**（跨租户写） |
| 余额硬拦截 `credit_enforce` | `store/setting.go` | 不动；充值到账即自然解封 |
| 薄 Web 面「用量与账单」页 | `sundynix-web/src/pages/Usage.tsx` | 占位文案换成真充值入口 |
| admin 计费页 + `/admin/usage` | 观测侧 | 加支付订单流观测（P5.3） |

**⚠️ 必须先补的闸：`GrantCredits` 对 `ref` 无唯一约束。** admin 手工充值无所谓；
支付回调会重复推送（渠道明文保证 at-least-once），不闸就是重复入账事故。
双保险：① 订单状态机 CAS 是主闸（见 §5）；② `credit_ledger` 加 `(kind, ref)`
部分唯一索引（`WHERE kind='grant' AND ref<>''`）兜底，两道闸缺一不可。

## 3. 渠道决策（用户拍板，技术侧全兼容）

**已拍板（2026-07-17）：真渠道用微信支付 Native（P5.2）。** 需企业主体 + 微信商户号
（mchid + APIv3 密钥 + 商户证书）；形态 = 下单得 code_url → Web 面渲染二维码 →
用户扫码付 → 回调（APIv3 签名验签 + AES-GCM 解密）。SDK 用官方
`github.com/wechatpay-apiv3/wechatpay-go`。其余渠道留 adapter 位，不做。

| 渠道 | 前提 | 形态 | 状态 |
|---|---|---|---|
| **兑换码/人工核销**（P5.1 内置） | 无 | admin 生成码 → 用户在 Web 面输码入账 | 先做，零资质跑通闭环 |
| **微信支付 Native**（P5.2） | 企业主体 + 商户号 | 二维码（code_url） | ✅ 已拍板 |
| 支付宝 / Stripe | — | — | 不做，留 adapter 位 |

Adapter 接口（`internal/payment/channel.go`）✅ **已落地**：
```go
type Channel interface {
    Name() string  // 与 store.Channel*（wechat/alipay/stripe）一致，按订单渠道路由
    // CreatePay 依据订单生成支付凭据（微信 Native 出 code_url；其它渠道可出跳转 URL）
    CreatePay(ctx, orderID, description string, amountFen int64) (PayIntent, error)
    // QueryOrder 主动查单（前端轮询确认 + 掉单补偿共用）
    QueryOrder(ctx, orderID string) (PayResult, error)
    // VerifyCallback 验签并解析回调 → 统一 PayResult（验签失败必返 error）
    VerifyCallback(req *http.Request) (PayResult, error)
}
```
实现说明（2026-07-18）：入参用基本类型（orderID/amountFen）而非 `*store.PaymentOrder`，让
`payment` 包不反依赖 `store`，渠道适配层保持纯粹。`Manager` 是渠道注册表（`Get(name)`/`Available()`/
`Status(name)`/`ReloadWechat`），回调路由收敛为 `POST /billing/callback/:channel` 按名路由；
查单/补偿据 `order.Channel` 路由。微信是当前唯一真渠道，支付宝/Stripe = 实现本接口 + 注册即接入。
唯一未泛化的是回调 **ack 应答格式**（现为微信 `{code:SUCCESS}`，其它渠道接入时按渠道分应答）。

## 3b. 两层汇率，各管各的（用户 2026-07-17 强调：积分↔token 必须可动态调）

```
人民币 ──(第一层: 积分包定价 price_fen→credits_micro, admin 配包/上下架)──> 积分
积分  ──(第二层: tokens_per_credit, admin 计费页动态调, DB 热生效 ✅已存在)──> token
```

- **第二层是现成的**：`SettingTokensPerCredit`（DB 优先→env→1000），admin「计费 & 用量」
  页可改，对后续任务实时生效（P2 期 live 验证过：改 500 → 新任务 credits=tok/500）。
  支付不碰它。
- **第一层是本期新增**：`sundynix_credit_pack` 表，admin 可改价/加量/上下架。
- 解耦收益：促销只动包；模型成本变了要调积分购买力只动汇率；互不牵连。
  订单锁定的是**下单当时**的包价与积分数（写死在订单行），之后改包不影响已付订单。

## 4. 数据模型（新增两件）

```
sundynix_payment_order            # 订单：支付侧事实源（与 credit_ledger 对账的另一条腿）
  id            雪花 (= 对外 order_id, ledger.ref)
  tenant_id     计费租户（下单时用 ResolveBillingTenantID 解析并锁定）
  user_id       操作人（审计）
  pack_id       积分包 ID
  amount_fen    应付金额(分)      # 服务端按包锁定
  credits_micro 到账积分(micro)   # 服务端按包锁定
  channel       redeem / alipay / wechat / stripe
  status        pending → paid | failed | expired ; paid → refunded(人工)
  channel_txn   渠道流水号（回调带回）
  paid_at
sundynix_credit_pack              # 积分包配置（admin 可改，别硬编码）
  id / name / credits_micro / price_fen / active / sort
```

## 5. 流程与幂等（核心就这一张图）

```
Web面账单页 → GET /billing/packs → 选包 → POST /billing/orders {pack_id}
  → 服务端建 pending 订单(锁价) → 返回 PayIntent(二维码/跳转/输码框)
用户支付 → 渠道回调 POST /billing/callback/:channel（公开路由，验签是唯一门）
  → VerifyCallback 验签 + 金额与订单核对
  → 一个事务内：UPDATE payment_order SET status='paid' WHERE id=? AND status='pending'
      RowsAffected==0 → 已处理过，直接 200（幂等闸①）
      ==1 → GrantCredits(kind=grant, ref=order_id)（唯一索引兜底，幂等闸②）
  → 前端轮询 GET /billing/orders/:id 到 paid → 刷余额
掉单补偿：pending 超 15min 的订单定时 QueryOrder 补态；过期置 expired
```

- 回调路由是**公开**的（渠道服务器打不了 Bearer），安全完全靠验签 + 金额核对 +
  订单状态机；这与 `/reports/:id/export` 公开的先例同构，但多了签名门。
- 退款先只做人工：admin 发起 → `adjust` 负分录 + 订单置 refunded；自动退款不做。
- 对账三条腿：渠道账单 ↔ `payment_order(paid)` ↔ `ledger(kind=grant)`，
  日终脚本比对（P5.3，先出 admin 页面人肉看，再自动化）。

## 6. 分增量（每步「机制→单测→live」）

- **P5.1 订单骨架 + 兑换码渠道**（不依赖任何外部资质，全链路即刻可 live 验证）：
  两张表 + ledger 唯一索引 + Channel 接口 + redeem adapter + `/billing/*` 路由
  （下单≥billing_admin? 不——**充值谁都该能充，挂 ≥member**；viewer 只读仍拦）+
  Web 面账单页真充值 UI + admin 生成兑换码。
- **P5.2 微信支付 Native（已拍板）**：wechatpay-go adapter（Native 下单 code_url →
  Web 面二维码）+ APIv3 回调验签解密 + 轮询查单。商户号/证书经 env 注入，
  未配置时渠道自动隐藏（只剩兑换码），别让半配置状态把下单路由搞出 5xx。
- **P5.3 对账与观测**：admin「支付订单」流 + 日终对账 + 掉单补偿定时器。
- **P5.4 按需**：退款流程化、发票、微信/Stripe 并列。

## 7. 明确不做（本期）

订阅/自动续费、套餐权益（plan 仍是展示字段）、多币种、自动退款、发票自动化、
渠道分账。等真实付费流量拉动。
