Files
pay/docs/superpowers/plans/2026-07-10-pay-v2-p8-recurring.md
T

1082 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# pay v2 · P8 订阅/recurringStripe gateway_scheduled+ 拒付 chargeback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐 Task 实现。步骤用 checkbox`- [ ]`)跟踪。每个 Task 自包含,给完整 Go 代码 + 测试 + 命令,可交给全新 implementer subagent。严格 TDD:写失败测试 → 跑失败 → 实现 → 跑通过 → commit。
> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html`(重点 §5.1 recurring 4 类 kind、§5 webhook v2 `event_type`(`subscription.*`/`chargeback`)、§6 拒付「平台发起 → pay 转发 chargeback」、§3 Order/Attempt/Account)。
> **前置计划(已完成,直接复用不重造):**
> - P1 `docs/superpowers/plans/2026-07-10-pay-v2-p1-core-model.md`(`internal/model/v2.go`、`internal/store`、`internal/money`、`internal/accounts`)。
> - P2 `docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md`(`internal/provider`、`internal/gateway`、webhook v2 outbox;`RecurringProvider` 可选接口已在此定义)。
> - P3 `docs/superpowers/plans/2026-07-10-pay-v2-p3-channel-adapters.md`(`internal/provider/stripe` Checkout 一次性收款 adapter + `providerbuild` 装配;本计划在其上加订阅链路)。
> - P5 `docs/superpowers/plans/2026-07-10-pay-v2-p5-account-routing.md`(多账户路由)。
> - **P4(退款,`refund.succeeded` 事件)与本计划并行编写中。** 本计划的事件幂等键解法(每期续费独立 `out_trade_no`,复用 outbox 唯一键 `(out_trade_no,event_type)`)与 P4「每笔退款独立 `refund_id` 派生 `out_trade_no`」同构——**两者共用同一原则:凡"一单多次异步事件"(多次续费/多次退款),都靠"给每次事件铸一个新 out_trade_no"来落进 `(out_trade_no,event_type)` 唯一键,不改 outbox schema**。若 P4 落地后此原则有出入,以先合并者为准并回归对齐。
**Goal:** 把设计里「设计进模型、暂不实现」的 recurring 落成**最小可行一条真实链路**:Stripe 订阅(`recurring_kind=gateway_scheduled`,Checkout `mode=subscription` + `invoice.paid` webhook 驱动续费)。交付:①`Subscription` 实体 + 三态状态机(`active`/`past_due`/`canceled`)+ store;②Stripe 订阅 Checkout 创建 + 首期激活(诞生 subscription + `subscription.created`);③`invoice.paid` 续费入账(每期一张 renewal order,复用 P2 settle 幂等 + `subscription.renewed`);④取消(API 主动 + 入站 `customer.subscription.deleted`);⑤拒付(`charge.dispute.created``Chargeback` 记录 + `chargeback.received` + 订单标记,**不自动回收权益**);⑥业务方 webhook 事件集声明式扩展。
**明确排除(留能力位,本轮不实现,各处 Capabilities/注释写清):**
- `token_offsession`(我方 cron 主动扣款:Stripe 自建 SetupIntent / 支付宝周期扣 / 微信 papay)——P2 已定义 `provider.RecurringProvider`(`CreateAgreement`/`Charge`/`CancelAgreement`)作为该 kind 的能力位,**本轮不给任何实现**,stripe 走的是 `gateway_scheduled`(网关驱动续费),不经 pay 主动 `Charge`
- `store_managed`(Apple IAP / Google Play,被动接 Server Notification/RTDN)——仅设计,不实现。
- alipay 周期扣款、crypto(天生无 recurring、无 chargeback,§6「钱到账不可逆」)——各自 `Capabilities().SupportsRecurring=false`;crypto 到期靠"提醒再买"伪续订(不在本计划)。
- 计划变更 / 升降级 / 按比例折算(proration)/ 宽限期策略引擎 / 自动回收权益。
**Architecture:** 订阅层与单笔层解耦、但复用同一入账管线(设计 §5.1「单笔层与订阅层解耦」)。渠道差异仍封死在 `internal/provider/stripe`,`internal/gateway`/`internal/provider` 核心保持渠道中性。关键机制:**`provider.PaidEvent` 加一个可选判别字段 `Kind`(默认空=一次性/首期支付,向后兼容 P3 三渠道)**,Stripe `VerifyCallback``checkout.session.completed`(含 subscription 模式)/`invoice.paid`/`invoice.payment_failed`/`customer.subscription.deleted`/`charge.dispute.created` 统一归一化成带 `Kind``PaidEvent`;`gateway.HandleCallback` 由「只调 Settle」升级为**按 `Kind` 分派**的 dispatcher(payment→原 `Settle` 一字不改;其余 Kind → 订阅/拒付处理器)。续费**每期铸一张 renewal `OrderV2`**(`out_trade_no` 由 invoice id 确定性派生),复用 `MarkAttemptPaid` 幂等 + `attempt` 唯一索引 `(channel,provider_ref)` 去重,金额权威取 `invoice.total`(Stripe 报的)。订阅↔渠道靠 `Subscription.ProviderSubRef=stripe sub id` 映射(invoice/deleted webhook 里 `inv.Subscription.ID`/`sub.ID` 反查),无需 metadata。
**Tech Stack:** Go 1.26.1 · `github.com/wangjia/pay` · Gin · GORM v1.31 · glebarez/sqlite(测试内存库)· `github.com/stripe/stripe-go/v79 v79.12.0`(已 pin,复用 P3;新增用到 `CheckoutSessionModeSubscription``Subscription`/`Invoice`/`Dispute`/`PaymentIntent` 类型与 `Subscriptions`/`Invoices`/`PaymentIntents` client)。测试全程 `httptest` 假 Stripe backend(同 P3 惯例,注入指向 `httptest.Server``*client.API`),**不打真网、免 docker、无真实密钥**。
## Global Constraints(继承 P1/P2/P3)
- **复用不重造**:金额 `AmountMinor int64 + Currency` 码(`internal/money`,USD=2 位);模型 `model.OrderV2`/`model.Attempt` + `V2` 后缀状态机;数据访问 `store.OrderStore`;账户 `accounts.Registry`;管线 `gateway.Gateway`(`CreateOrder`/`Settle`/`HandleCallback`);Provider 抽象 `provider.Provider`/`Session`/`PaidEvent`/`CreateRequest`/`CallbackInput`/`Capabilities`/`Registry`。webhook outbox `model.WebhookDelivery` 唯一键 `(out_trade_no,event_type)` + `store.WebhookStore.EnqueueDelivery`(ON CONFLICT DO NOTHING 幂等入队)。
- **金额一律 int64 最小单位 + 币种码,禁 float**。续费金额取 `invoice.total`(渠道权威),不客户端传、不本地重算。
- **Provider 中性**:`internal/gateway` 绝不 import 任何 adapter;`internal/provider` 核心不 import adapter(adapter 反向依赖核心)。新增 `provider.SubscriptionProvider` 可选接口住核心包,stripe 实现之。
- **凭证 env only**:复用 P3 stripe 装配(`accounts.Credential(id,"SECRET_KEY"/"WEBHOOK_SECRET")`),不落库明文、不入 git。测试用假 backend + 假 webhook secret。
- **入账/幂等/金额核对不变**:一次性 & 首期支付仍走 P2 `gateway.Settle`(先幂等入队 webhook、再 `MarkAttemptPaid` 条件 UPDATE 翻转)。订阅处理器**复用同款幂等纪律**(条件 UPDATE + 唯一索引 + 幂等入队),不引入新的并发原语。
- **GORM AutoMigrate 惯例**:新表(`Subscription`/`Chargeback`)、`PaidEvent` 无 DB 副作用;`db.AutoMigrate` 只加不删,对存量库安全;模型内嵌 `model.Base`。同步更新 `model.OpenTestDB`(测试 harness)与 `main.go` 的 AutoMigrate 列表。
- **拒付不自动回收权益**(设计 §6):pay 只记 chargeback + 转发事件 + 打订单标记;缩/降/冻结 entitlement 归业务方(收 `chargeback.received` 自行处理)。
- 每步 `go build ./...` 通过;测试 `go test ./...`(全 `:memory:`/临时 sqlite + `httptest`)。每 Task 严格 bite-sized TDD;禁占位。
---
### Task 1: Subscription 模型 + 三态状态机 + SubscriptionStore
**Files:**
- Modify: `internal/model/v2.go`(加 `SubStatus` 枚举 + `Subscription` struct)
- Modify: `internal/model/testdb.go`(`AutoMigrate` 追加 `&Subscription{}`)
- Create: `internal/store/subscription.go`(`SubscriptionStore`)
- Create: `internal/store/subscription_test.go`
**Interfaces:**
```go
// model
type SubStatus string
const (
SubActive SubStatus = "active" // 已激活,正常续费
SubPastDue SubStatus = "past_due" // 某期扣款失败,待恢复(invoice.payment_failed)
SubCanceled SubStatus = "canceled" // 已取消(主动/网关删除),终态
)
type Subscription struct { ... } // 见 Step 3
// store
func (s *SubscriptionStore) Create(sub *model.Subscription) (created bool, err error) // 幂等 by (channel,provider_sub_ref)
func (s *SubscriptionStore) GetBySubID(subID string) (*model.Subscription, error) // ErrSubNotFound
func (s *SubscriptionStore) GetByProviderRef(channel, ref string) (*model.Subscription, error)
func (s *SubscriptionStore) Activate(subID string, periodEnd *time.Time) (bool, error) // →active(past_due 恢复也走它)
func (s *SubscriptionStore) MarkPastDue(channel, providerRef string) (bool, error) // active→past_due
func (s *SubscriptionStore) MarkCanceled(subID string) (bool, error) // *→canceled(终态,幂等)
```
状态机守卫(条件 UPDATE + RowsAffected,同 P1 `MarkAttemptPaid` 惯例):`canceled` 是终态,`Activate`/`MarkPastDue` 都带 `status <> 'canceled'` 守卫,避免取消后被续费/失败事件复活。
- [ ] **Step 1: 写失败测试**
`internal/store/subscription_test.go`:
```go
package store_test
import (
"testing"
"time"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/store"
)
func newSubStore(t *testing.T) *store.SubscriptionStore {
return store.NewSubscriptionStore(model.OpenTestDB(t))
}
func TestSubscriptionCreateIdempotent(t *testing.T) {
s := newSubStore(t)
sub := &model.Subscription{SubID: "SUB-1", Channel: "stripe", ProviderSubRef: "sub_x",
OutTradeNo: "PAY-1", BizSystem: "pangolin", BizRef: "u-1", BizCode: "pro_month",
AmountMinor: 2999, Currency: "USD", Status: model.SubActive}
created, err := s.Create(sub)
if err != nil || !created {
t.Fatalf("first create: created=%v err=%v", created, err)
}
// 同 provider_sub_ref 再建 → 幂等 no-op(Stripe 重投 checkout.completed)
again, err := s.Create(&model.Subscription{SubID: "SUB-2", Channel: "stripe", ProviderSubRef: "sub_x",
OutTradeNo: "PAY-1", AmountMinor: 2999, Currency: "USD", Status: model.SubActive})
if err != nil || again {
t.Fatalf("dup create: again=%v err=%v", again, err)
}
}
func TestSubscriptionStateMachine(t *testing.T) {
s := newSubStore(t)
end := time.Now().Add(30 * 24 * time.Hour)
if _, err := s.Create(&model.Subscription{SubID: "SUB-9", Channel: "stripe", ProviderSubRef: "sub_9",
OutTradeNo: "PAY-9", AmountMinor: 2999, Currency: "USD", Status: model.SubActive}); err != nil {
t.Fatal(err)
}
// active → past_due
ok, _ := s.MarkPastDue("stripe", "sub_9")
if !ok {
t.Fatal("MarkPastDue should flip active→past_due")
}
// past_due → active(续费恢复)
ok, _ = s.Activate("SUB-9", &end)
if !ok {
t.Fatal("Activate should recover past_due→active")
}
// → canceled(终态)
ok, _ = s.MarkCanceled("SUB-9")
if !ok {
t.Fatal("MarkCanceled should flip →canceled")
}
// canceled 后不可复活
if ok, _ := s.Activate("SUB-9", &end); ok {
t.Fatal("canceled sub must not be re-activated")
}
if ok, _ := s.MarkPastDue("stripe", "sub_9"); ok {
t.Fatal("canceled sub must not go past_due")
}
got, err := s.GetBySubID("SUB-9")
if err != nil || got.Status != model.SubCanceled {
t.Fatalf("final status = %v err=%v", got.Status, err)
}
}
```
- [ ] **Step 2: 跑测试确认失败**
Run: `cd /Users/wangjia/code/pay && go test ./internal/store/ -run Subscription -v`
Expected: 编译失败(无 `Subscription` 模型 / `SubscriptionStore`)。
- [ ] **Step 3: 实现模型**
`internal/model/v2.go` 追加:
```go
// ---- 订阅(recurring)----
type SubStatus string
const (
SubActive SubStatus = "active"
SubPastDue SubStatus = "past_due"
SubCanceled SubStatus = "canceled"
)
// Subscription 是"同一 entitlement 的跨期账本"(设计 §5.1)。单笔层(OrderV2/Attempt)
// 与订阅层解耦:每期扣款仍落一张 renewal OrderV2,Subscription 只维护状态机 + 续费锚点。
type Subscription struct {
Base
SubID string `gorm:"uniqueIndex;size:64;not null"` // pay 生成的逻辑订阅号
OutTradeNo string `gorm:"index;size:64;not null"` // 首购 order(诞生订阅那笔)
MerchantID uint64 `gorm:"index"`
BizSystem string `gorm:"index;size:32"`
BizRef string `gorm:"size:128"`
BizCode string `gorm:"index;size:64"` // 套餐码副本,续费事件带回
Channel string `gorm:"index;size:32;not null;uniqueIndex:uq_sub_provider"`
ProviderSubRef string `gorm:"size:128;not null;uniqueIndex:uq_sub_provider"` // 渠道订阅号(stripe sub id)
RecurringKind string `gorm:"size:24"` // gateway_scheduled/token_offsession/...
AmountMinor int64 `gorm:"not null"`
Currency string `gorm:"size:16;not null"`
Status SubStatus `gorm:"index;size:16;not null"`
CurrentPeriodEnd *time.Time
CanceledAt *time.Time
}
```
`internal/model/testdb.go``AutoMigrate(...)` 追加 `&Subscription{}`
- [ ] **Step 4: 实现 store**
`internal/store/subscription.go`:
```go
package store
import (
"errors"
"fmt"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wangjia/pay/internal/model"
)
var ErrSubNotFound = errors.New("store: subscription not found")
type SubscriptionStore struct{ db *gorm.DB }
func NewSubscriptionStore(db *gorm.DB) *SubscriptionStore { return &SubscriptionStore{db: db} }
// Create 幂等插入:重复 (channel,provider_sub_ref) → no-op(created=false)。
// Stripe 会重投 checkout.session.completed;诞生订阅必须幂等。
func (s *SubscriptionStore) Create(sub *model.Subscription) (bool, error) {
res := s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "channel"}, {Name: "provider_sub_ref"}},
DoNothing: true,
}).Create(sub)
if res.Error != nil {
return false, fmt.Errorf("store.SubscriptionStore.Create: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
func (s *SubscriptionStore) GetBySubID(subID string) (*model.Subscription, error) {
var sub model.Subscription
if err := s.db.Where("sub_id = ?", subID).First(&sub).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSubNotFound
}
return nil, fmt.Errorf("store.GetBySubID: %w", err)
}
return &sub, nil
}
func (s *SubscriptionStore) GetByProviderRef(channel, ref string) (*model.Subscription, error) {
var sub model.Subscription
if err := s.db.Where("channel = ? AND provider_sub_ref = ?", channel, ref).First(&sub).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSubNotFound
}
return nil, fmt.Errorf("store.GetByProviderRef: %w", err)
}
return &sub, nil
}
// Activate 置 active + 刷新续费锚点(past_due 续费成功恢复也走它);canceled 终态不复活。
func (s *SubscriptionStore) Activate(subID string, periodEnd *time.Time) (bool, error) {
upd := map[string]any{"status": model.SubActive}
if periodEnd != nil {
upd["current_period_end"] = periodEnd
}
res := s.db.Model(&model.Subscription{}).
Where("sub_id = ? AND status <> ?", subID, model.SubCanceled).Updates(upd)
if res.Error != nil {
return false, fmt.Errorf("store.Activate: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
func (s *SubscriptionStore) MarkPastDue(channel, providerRef string) (bool, error) {
res := s.db.Model(&model.Subscription{}).
Where("channel = ? AND provider_sub_ref = ? AND status = ?", channel, providerRef, model.SubActive).
Update("status", model.SubPastDue)
if res.Error != nil {
return false, fmt.Errorf("store.MarkPastDue: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
func (s *SubscriptionStore) MarkCanceled(subID string) (bool, error) {
res := s.db.Model(&model.Subscription{}).
Where("sub_id = ? AND status <> ?", subID, model.SubCanceled).
Updates(map[string]any{"status": model.SubCanceled, "canceled_at": time.Now()})
if res.Error != nil {
return false, fmt.Errorf("store.MarkCanceled: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
```
- [ ] **Step 5: 跑测试确认通过**
Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/store/ -run Subscription -v`
Expected: PASS。
- [ ] **Step 6: commit**`feat(pay-v2): P8 Task1 Subscription 模型 + 三态状态机 + store`
---
### Task 2: PaidEvent.Kind 判别字段 + SubscriptionProvider 接口 + Stripe 订阅 Checkout 创建/取消
**Files:**
- Modify: `internal/provider/provider.go`(`EventKind` + `PaidEvent` 加可选字段 + `SubscriptionProvider` 接口 + `RecurringKindGatewayScheduled` 常量)
- Modify: `internal/provider/stripe/stripe.go`(`Capabilities` 开 recurring + `CreateSubscriptionCheckout` + `CancelSubscription`)
- Modify: `internal/provider/stripe/stripe_test.go`(假 backend 加 subscription-mode session + cancel 路由)
**Interfaces:**
```go
// provider 核心包
type EventKind string
const (
EventPayment EventKind = "" // 默认:一次性/首期支付(P3 三渠道全落此,向后兼容)
EventSubscriptionRenewal EventKind = "subscription_renewal"
EventSubscriptionPastDue EventKind = "subscription_past_due"
EventSubscriptionCanceled EventKind = "subscription_canceled"
EventChargeback EventKind = "chargeback"
)
const RecurringKindGatewayScheduled = "gateway_scheduled"
// PaidEvent 追加(全部可选,零值=旧行为):
// Kind EventKind
// SubscriptionRef string // 渠道订阅号(checkout.completed 诞生 / invoice / deleted 反查)
// InvoiceRef string // 续费期次唯一号(renewal attempt 的 provider_ref)
// DisputeRef string // 拒付号
// ProviderPaymentRef string // 拒付关联的 PaymentIntent id
// OutTradeNo string // 拒付解析出的原单号(可空)
type SubscriptionProvider interface {
Provider
CreateSubscriptionCheckout(ctx context.Context, req CreateRequest) (*Session, error)
CancelSubscription(ctx context.Context, providerSubRef string) error
}
```
- [ ] **Step 1: 写失败测试**
`internal/provider/stripe/stripe_test.go` 追加(复用现有 `fakeStripeAPI`/`newStripe`/`signStripe`;先扩 `fakeStripeAPI` 的 switch,见 Step 4):
```go
func TestCreateSubscriptionCheckout(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
sess, err := p.CreateSubscriptionCheckout(context.Background(), provider.CreateRequest{
OutTradeNo: "PAY-S1", Subject: "Pro Monthly", AmountMinor: 2999, Currency: "USD",
ReturnURL: "https://x/return", Metadata: map[string]string{"pay_sub_id": "SUB-1"},
})
if err != nil {
t.Fatalf("create sub checkout: %v", err)
}
if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "cs_sub_123" {
t.Fatalf("session = %+v", sess)
}
}
func TestCancelSubscription(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
if err := p.CancelSubscription(context.Background(), "sub_cancel_ok"); err != nil {
t.Fatalf("cancel: %v", err)
}
}
func TestCapabilitiesRecurring(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
c := newStripe(t, ts).Capabilities()
if !c.SupportsRecurring || c.RecurringKind != provider.RecurringKindGatewayScheduled {
t.Fatalf("caps = %+v", c)
}
}
```
- [ ] **Step 2: 跑测试确认失败**
Run: `cd /Users/wangjia/code/pay && go test ./internal/provider/stripe/ -run 'Subscription|Recurring' -v`
Expected: 编译失败(无 `CreateSubscriptionCheckout`/`CancelSubscription`/`SupportsRecurring` 等)。
- [ ] **Step 3: 实现 provider 核心扩展**
`internal/provider/provider.go`:①`PaidEvent` struct 追加上述可选字段;②加 `EventKind` 枚举 + `RecurringKindGatewayScheduled` 常量;③加 `SubscriptionProvider` 接口。**不改** `Provider` 现有方法签名(向后兼容)。
- [ ] **Step 4: 实现 stripe 订阅创建/取消 + 扩假 backend**
`internal/provider/stripe/stripe.go`:`Capabilities` 里补 `SupportsRecurring: true, RecurringKind: provider.RecurringKindGatewayScheduled`,再加:
```go
// CreateSubscriptionCheckout 建 Stripe 订阅(Checkout mode=subscription):返回 redirect 收银台。
// 订阅号(sub_...)在用户完成支付后才生成 → 经 checkout.session.completed webhook 诞生 pay 订阅。
// 续费由 Stripe 网关驱动(invoice.paid),pay 不主动 Charge(区别于 token_offsession)。
func (p *Provider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
if req.Currency != supportedCurrency {
return nil, fmt.Errorf("stripe: 仅支持 %s, got %s", supportedCurrency, req.Currency)
}
params := &gostripe.CheckoutSessionParams{
Mode: gostripe.String(string(gostripe.CheckoutSessionModeSubscription)),
SuccessURL: gostripe.String(req.ReturnURL),
ClientReferenceID: gostripe.String(req.OutTradeNo),
LineItems: []*gostripe.CheckoutSessionLineItemParams{{
Quantity: gostripe.Int64(1),
PriceData: &gostripe.CheckoutSessionLineItemPriceDataParams{
Currency: gostripe.String(strings.ToLower(supportedCurrency)),
UnitAmount: gostripe.Int64(req.AmountMinor),
// 最小可行:固定月付。周期(month/year)后续由 product 定价档下发,此处留 month 默认。
Recurring: &gostripe.CheckoutSessionLineItemPriceDataRecurringParams{
Interval: gostripe.String("month"),
},
ProductData: &gostripe.CheckoutSessionLineItemPriceDataProductDataParams{
Name: gostripe.String(req.Subject),
},
},
}},
// 订阅 metadata 带 out_trade_no,便于人工对账;续费/取消映射实际走 sub id 反查,不依赖它。
SubscriptionData: &gostripe.CheckoutSessionSubscriptionDataParams{
Metadata: map[string]string{"out_trade_no": req.OutTradeNo},
},
}
if v := req.Metadata["pay_sub_id"]; v != "" {
params.SubscriptionData.Metadata["pay_sub_id"] = v
}
sess, err := p.sc.CheckoutSessions.New(params)
if err != nil {
return nil, fmt.Errorf("stripe: 创建订阅 Checkout 失败: %w", err)
}
return &provider.Session{
ProviderRef: sess.ID,
RenderType: provider.RenderRedirect,
Payload: map[string]any{"url": sess.URL},
}, nil
}
// CancelSubscription 立即取消 Stripe 订阅(不等本期末)。Stripe 随后发 customer.subscription.deleted,
// 入站处理器幂等标 canceled,与本地主动标一致收敛。
func (p *Provider) CancelSubscription(_ context.Context, providerSubRef string) error {
if _, err := p.sc.Subscriptions.Cancel(providerSubRef, nil); err != nil {
return fmt.Errorf("stripe: 取消订阅失败: %w", err)
}
return nil
}
```
`internal/provider/stripe/stripe_test.go``fakeStripeAPI` switch,新增:
```go
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions"):
// 复用现有分支即可 —— 但 subscription-mode 需回不同 id。用 form 值 mode 区分:
// 简化:检查 body 是否含 "subscription"(mode=subscription 会带该 form 值)。
if b, _ := io.ReadAll(r.Body); strings.Contains(string(b), "subscription") {
fmt.Fprint(w, `{"id":"cs_sub_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_sub_123","mode":"subscription","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
} else {
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
}
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_ok"):
fmt.Fprint(w, `{"id":"sub_cancel_ok","object":"subscription","status":"canceled"}`)
```
> 注:stripe-go 的 `Subscriptions.Cancel` 走 `DELETE /v1/subscriptions/{id}`。现有一次性分支要与新分支合并成一个 `case`(避免重复 `case` 冲突),`io`/`strings` 已 import。若合并麻烦,把一次性分支的 id 改判据也行——保持 `cs_test_123` 供 P3 既有用例不破。跑 `go test ./internal/provider/stripe/` 全绿即可。
- [ ] **Step 5: 跑测试确认通过**
Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./internal/provider/stripe/ -v`
Expected: 新用例 + P3 既有用例全 PASS(向后兼容)。
- [ ] **Step 6: commit**`feat(pay-v2): P8 Task2 PaidEvent.Kind + SubscriptionProvider + Stripe 订阅 Checkout/取消`
---
### Task 3: gateway 订阅创建 + 首期激活(诞生订阅 + subscription.created)+ 创建端点
**Files:**
- Create: `internal/gateway/subscription.go`(`Gateway.CreateSubscription` + `onSubscriptionActivated`)
- Modify: `internal/gateway/gateway.go`(`Gateway` 注入 `subs *store.SubscriptionStore` + event 常量;`New` 追加参数)
- Modify: `internal/gateway/settle.go`(`HandleCallback` 升级为按 `Kind` 分派)
- Modify: `internal/handler/gateway.go`(`CreateSubscription` handler)
- Modify: `main.go`(路由 `POST /api/v2/subscriptions` + 装配注入 `SubscriptionStore`)
- Create: `internal/gateway/subscription_test.go`
**Interfaces:**
```go
type CreateSubscriptionInput struct {
SKU, Method, BizSystem, BizRef, ReturnURL string
}
type SubscriptionResult struct {
SubID string `json:"sub_id"`
OrderNo string `json:"order_no"`
Session SessionView `json:"session"`
}
func (g *Gateway) CreateSubscription(ctx context.Context, in CreateSubscriptionInput) (*SubscriptionResult, error)
```
webhook event 常量集中在 `internal/gateway`(Task 7 收敛业务方声明):
```go
const (
EvtPaymentSucceeded = "payment.succeeded"
EvtSubscriptionCreated = "subscription.created"
EvtSubscriptionRenewed = "subscription.renewed"
EvtSubscriptionPastDue = "subscription.past_due"
EvtSubscriptionCanceled = "subscription.canceled"
EvtChargebackReceived = "chargeback.received"
)
```
**决策记录:**
- **端点独立**:订阅走新端点 `POST /api/v2/subscriptions`,不复用 `/api/v2/orders`(一次性)。订阅 create 语义/返回(带 `sub_id`)与一次性不同,合并会脏化 `CreateOrder`
- **首期激活复用一次性 Settle**:订阅 Checkout 完成后 Stripe 发 `checkout.session.completed`(`payment_status=paid`,`amount_total`=首期金额),这条**照走 P2 `Settle`** 把首购 order 翻 paid + 入队 `payment.succeeded`(首期即一次成功收款,业务方按 `payment.succeeded` 开首期权益,零特判)。**额外**:该 event 带 `SubscriptionRef``onSubscriptionActivated` 幂等诞生 `Subscription`(status=active)+ 入队 `subscription.created`(带 `sub_id`,业务方登记订阅关系)。故 `HandleCallback``Settle` 后,若 `ev.SubscriptionRef!=""` 再调 `onSubscriptionActivated`(幂等,与 Settle 结果无关)。
- [ ] **Step 1: 写失败测试**
`internal/gateway/subscription_test.go`(参照 P2/P3 `gateway_test.go``newGateway` helper——本 Task 需其升级版 `newSubGateway` 额外返回 `SubscriptionStore` + webhook spy;若既有 helper 不便复用,在本文件内自建最小装配,注册一个实现 `SubscriptionProvider` 的 fake):
```go
// fakeSubProvider: 实现 provider.SubscriptionProvider,创建订阅 Checkout 返回固定 session;
// VerifyCallback 由测试直接构造 PaidEvent 走 Settle/HandleCallback,不经它。
```
断言两条:
1. `CreateSubscription` → 落一张 pending `OrderV2` + `Attempt`(provider_ref=会话号),返回 `sub_id`/`order_no`/redirect session。
2. 喂一条 `PaidEvent{Kind:EventPayment, ProviderRef:<会话号>, Status:Succeeded, PaidAmountMinor:2999, PaidCurrency:"USD", SubscriptionRef:"sub_new"}``HandleCallback`(经 fake VerifyCallback 回放该 event)→ 断言:①order 翻 paid;②`subscriptions` 表诞生一行 status=active、provider_sub_ref=sub_new;③webhook spy 收到 **2** 条:`payment.succeeded` + `subscription.created`;④重复喂同一 event → 订阅不重复诞生、webhook 不重复(幂等)。
- [ ] **Step 2: 跑测试确认失败**
Run: `cd /Users/wangjia/code/pay && go test ./internal/gateway/ -run Subscription -v` → 编译失败。
- [ ] **Step 3: 实现 gateway 订阅创建 + 激活**
`internal/gateway/subscription.go`:
```go
package gateway
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/store"
"github.com/wangjia/pay/internal/util"
)
// CreateSubscription 解析套餐权威金额 → 选 stripe 账户(须实现 SubscriptionProvider 且 SupportsRecurring)
// → 落 pending 首购 OrderV2 + Attempt → 返回订阅 Checkout redirect。订阅在首期支付回调时诞生。
func (g *Gateway) CreateSubscription(ctx context.Context, in CreateSubscriptionInput) (*SubscriptionResult, error) {
prov, err := g.providers.Get(in.Method)
if err != nil {
return nil, err
}
caps := prov.Capabilities()
subProv, ok := prov.(provider.SubscriptionProvider)
if !ok || !caps.SupportsRecurring {
return nil, provider.ErrNotSupported
}
if len(caps.SettleCurrencies) == 0 {
return nil, ErrNoSettleCurrency
}
currency := caps.SettleCurrencies[0]
amount, subject, bizCode, err := g.products.Resolve(in.SKU, currency)
if err != nil {
return nil, err // ErrProductNotFound(含"该币种无价")
}
outTradeNo := util.NewOutTradeNo("pay") // 复用 P1 单号生成器(真实 helper)
acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{OutTradeNo: outTradeNo, AmountMinor: amount})
if err != nil {
if errors.Is(err, accounts.ErrNoAccount) {
return nil, ErrNoAccount
}
return nil, err
}
// SubID 确定性派生自 out_trade_no(与 onSubscriptionActivated 一致,消除双号)。
subID := "SUB-" + outTradeNo
sess, err := subProv.CreateSubscriptionCheckout(ctx, provider.CreateRequest{
OutTradeNo: outTradeNo, Subject: subject, AmountMinor: amount, Currency: currency,
Account: acct, ReturnURL: in.ReturnURL, Metadata: map[string]string{"pay_sub_id": subID},
})
if err != nil {
return nil, fmt.Errorf("gateway.CreateSubscription: %w", err)
}
order := &model.OrderV2{
OutTradeNo: outTradeNo, BizSystem: in.BizSystem, BizRef: in.BizRef, BizCode: bizCode,
Subject: subject, AmountMinor: amount, Currency: currency, Status: model.OrderPendingV2,
}
if err := g.orders.CreateOrder(order); err != nil {
return nil, err
}
att := &model.Attempt{
OutTradeNo: outTradeNo, Channel: in.Method, AccountID: acct.AccountID, Provider: in.Method,
ProviderRef: sess.ProviderRef, RenderType: string(sess.RenderType),
AmountMinor: amount, Currency: currency, Status: model.AttemptPending, ExpiresAt: sess.ExpiresAt,
}
if err := g.orders.CreateAttempt(att); err != nil {
return nil, err
}
return &SubscriptionResult{
SubID: subID, OrderNo: outTradeNo,
Session: SessionView{RenderType: string(sess.RenderType), Payload: sess.Payload, ExpiresAt: sess.ExpiresAt},
}, nil
}
// onSubscriptionActivated 幂等诞生订阅 + 入队 subscription.created。首期支付回调触发。
// created 事件走首购 order 的 out_trade_no + event_type=subscription.created(唯一键天然不撞 payment.succeeded)。
func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.PaidEvent) error {
att, err := g.orders.AttemptByProviderRef(ev.ProviderRef)
if err != nil {
return nil // 首期会话未落库(不该发生);交由 Settle 侧日志,订阅侧静默
}
o, err := g.orders.GetOrder(att.OutTradeNo)
if err != nil {
return err
}
subID := "SUB-" + att.OutTradeNo // 与 CreateSubscription 同式派生 → 重投算出同一 SubID,Create 幂等
created, err := g.subs.Create(&model.Subscription{
SubID: subID, OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, BizRef: o.BizRef, BizCode: o.BizCode,
Channel: att.Channel, ProviderSubRef: ev.SubscriptionRef, RecurringKind: provider.RecurringKindGatewayScheduled,
AmountMinor: o.AmountMinor, Currency: o.Currency, Status: model.SubActive,
})
if err != nil {
return err
}
if !created || o.BizSystem == "" {
return nil // 已诞生过(重投)/ 独立收款无业务方回调
}
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtSubscriptionCreated, map[string]any{
"event_type": EvtSubscriptionCreated, "out_trade_no": o.OutTradeNo, "sub_id": subID,
"biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode,
"amount_minor": o.AmountMinor, "currency": o.Currency, "channel": att.Channel,
"created_at": time.Now().Format(time.RFC3339),
})
}
```
> **决策(SubID 归一)**:`CreateSubscription` 时无法预知 Stripe sub id,故 `SubID` **两处都从 `out_trade_no` 确定性派生**(`SUB-<out_trade_no>`)——创建时返回给客户端,`onSubscriptionActivated` 重算同值,保证 webhook 重投幂等、无双号。业务方以回调里的 `sub_id` 为准。
`internal/gateway/gateway.go`:`Gateway` struct 加 `subs *store.SubscriptionStore`;`New(...)` 尾部加 `subs` 参数;定义上方 event 常量;把 `CreateOrder` 里选账户那段抽成 `pickAccount(method)` 私有方法供两处复用(若 P5 已抽出则直接用)。
- [ ] **Step 4: HandleCallback 升级为 Kind 分派**
`internal/gateway/settle.go``HandleCallback` 改为:
```go
func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider.CallbackInput) (SettleResult, error) {
prov, err := g.providers.Get(method)
if err != nil {
return SettleNotFound, err
}
ev, err := prov.VerifyCallback(ctx, in)
if err != nil {
return SettleNotFound, err
}
switch ev.Kind {
case provider.EventSubscriptionRenewal:
return g.settleRenewal(ctx, ev) // Task 4
case provider.EventSubscriptionPastDue:
return g.markSubscriptionPastDue(ctx, method, ev) // Task 5
case provider.EventSubscriptionCanceled:
return g.settleSubscriptionCanceled(ctx, ev) // Task 5
case provider.EventChargeback:
return g.recordChargeback(ctx, method, ev) // Task 6
default: // EventPayment:一次性 / 订阅首期
res, serr := g.Settle(ctx, ev)
if serr == nil && ev.SubscriptionRef != "" {
if aerr := g.onSubscriptionActivated(ctx, ev); aerr != nil {
return SettleFailed, aerr // 诞生订阅失败可重试(Stripe 重投)
}
}
return res, serr
}
}
```
> Task 3 只需 `default` 分支可用 + `onSubscriptionActivated`;其余 case 的处理器在 Task 4/5/6 补齐。**为让本 Task 独立编译通过**,先给 `settleRenewal`/`markSubscriptionPastDue`/`settleSubscriptionCanceled`/`recordChargeback` 写 stub(`return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)`),后续 Task 各自替换 + 补测试。
- [ ] **Step 5: handler + 路由 + 装配**
`internal/handler/gateway.go``CreateSubscription`(仿 `CreateOrder`:读 body、`biz_system` 非空验 HMAC、调 `g.CreateSubscription``writeCreateErr` 复用;`ErrNotSupported` → 400 `method_not_recurring`「该支付方式不支持订阅」)。`main.go`:装配处 `gateway.New(...)` 传入 `store.NewSubscriptionStore(db)`;路由注册 `POST /api/v2/subscriptions`;`AutoMigrate` 追加 `&model.Subscription{}`
- [ ] **Step 6: 跑测试确认通过 + 全量回归**
Run: `cd /Users/wangjia/code/pay && go build ./... && go test ./... `
Expected: 新用例 PASS,P1/P2/P3/P5 全绿。
- [ ] **Step 7: commit**`feat(pay-v2): P8 Task3 订阅创建 + 首期激活(subscription.created)+ 端点`
---
### Task 4: 续费入账(invoice.paid → renewal order + subscription.renewed)
**Files:**
- Modify: `internal/provider/stripe/stripe.go`(`VerifyCallback``invoice.paid` 分类)
- Modify: `internal/store/order.go`(`CreateRenewalPaid` 幂等建 renewal order+attempt 并直接置 paid)
- Modify: `internal/gateway/subscription.go`(`settleRenewal` 替换 stub)
- Modify: `internal/provider/stripe/stripe_test.go` + `internal/gateway/subscription_test.go`
**决策记录:**
- **每期一张 renewal `OrderV2`**:`out_trade_no` 由 invoice id 确定性派生(`sub.OutTradeNo + "-r-" + invoice.ID`)。这让「一订阅多次续费」自然落进 outbox 唯一键 `(out_trade_no,event_type)`——每期 `out_trade_no` 不同 → 每期一条 `subscription.renewed` 不撞键。**与 P4「每笔退款独立单号」同构**。
- **金额权威 = `invoice.total`**(Stripe 报的本期实扣),不重算、不取首购 order 金额(可能改价)。renewal order/attempt 建即置 paid(续费不经收银台,无 pending 中间态)。
- **首期 invoice 跳过**:`billing_reason=subscription_create` 的 invoice 与 `checkout.session.completed` 是同一笔首期钱,由后者入账;续费只认 `subscription_cycle`,避免首期双记账。
- **幂等**:renewal attempt 的 `provider_ref=invoice.ID`,唯一索引 `(channel,provider_ref)` 挡住 `invoice.paid` 重投;renewal order 唯一 `out_trade_no` 双保险。`CreateRenewalPaid` 用 ON CONFLICT DO NOTHING,`created=false` 时不重复入队 `subscription.renewed`(outbox 本也幂等,双保险)。
- [ ] **Step 1: 写失败测试**
`internal/provider/stripe/stripe_test.go`:`TestVerifyInvoicePaidRenewal` —— 构造 `type=invoice.paid` + `billing_reason=subscription_cycle` 的签名 webhook,断言 `ev.Kind==EventSubscriptionRenewal``ev.InvoiceRef=="in_123"``ev.SubscriptionRef=="sub_new"``ev.PaidAmountMinor==2999``ev.Status==Succeeded`;再构造 `billing_reason=subscription_create` 断言 `Kind==EventPayment`(被跳过入账)。payload 示例:
```go
payload := `{"id":"evt_r","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_123","object":"invoice","billing_reason":"subscription_cycle","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}`
```
`internal/gateway/subscription_test.go`:`TestSettleRenewal` —— 先经 Task3 路径诞生一个订阅(provider_sub_ref=sub_new),再喂 `PaidEvent{Kind:EventSubscriptionRenewal, SubscriptionRef:"sub_new", InvoiceRef:"in_123", PaidAmountMinor:2999, PaidCurrency:"USD", Status:Succeeded}``HandleCallback`。断言:①新增一张 renewal `OrderV2`(status=paid,out_trade_no 含 `-r-`);②webhook spy 收到 `subscription.renewed`(含 `sub_id`/`out_trade_no`/`amount_minor`);③重复喂 → renewal order 不重复、webhook 不重复;④订阅 `current_period_end` 刷新、status=active(past_due 恢复)。
- [ ] **Step 2 跑失败**`go test ./internal/provider/stripe/ ./internal/gateway/ -run 'Renewal' -v`
- [ ] **Step 3: stripe `invoice.paid` 分类**
`VerifyCallback` 的 switch 增 case:
```go
case "invoice.paid":
var inv gostripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err)
}
if inv.BillingReason != gostripe.InvoiceBillingReasonSubscriptionCycle {
// 首期(subscription_create)由 checkout.session.completed 入账;其余非续费忽略。
return &provider.PaidEvent{Kind: provider.EventPayment, Status: provider.PaidPending, Raw: string(in.Raw)}, nil
}
ev := &provider.PaidEvent{
Kind: provider.EventSubscriptionRenewal, Status: provider.PaidSucceeded,
InvoiceRef: inv.ID, PaidAmountMinor: inv.Total, PaidCurrency: strings.ToUpper(string(inv.Currency)),
}
if inv.Subscription != nil {
ev.SubscriptionRef = inv.Subscription.ID
}
if event.Created > 0 {
t := unixToTime(event.Created)
ev.PaidAt = &t
}
return ev, nil
```
- [ ] **Step 4: store `CreateRenewalPaid`**
`internal/store/order.go`:
```go
// CreateRenewalPaid 幂等建一张已付 renewal order + attempt(续费不经收银台,建即 paid)。
// 重复(invoice 重投)→ created=false。renewal order/attempt 均带唯一约束,ON CONFLICT DO NOTHING。
func (s *OrderStore) CreateRenewalPaid(order *model.OrderV2, att *model.Attempt) (bool, error) {
var created bool
err := s.db.Transaction(func(tx *gorm.DB) error {
ores := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "out_trade_no"}}, DoNothing: true,
}).Create(order)
if ores.Error != nil {
return ores.Error
}
if ores.RowsAffected == 0 {
return nil // 已建过 → 幂等 no-op
}
ares := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "channel"}, {Name: "provider_ref"}}, DoNothing: true,
}).Create(att)
if ares.Error != nil {
return ares.Error
}
created = ares.RowsAffected > 0
return nil
})
if err != nil {
return false, fmt.Errorf("store.CreateRenewalPaid: %w", err)
}
return created, nil
}
```
> 需 `import "gorm.io/gorm/clause"`。order.Status 传 `OrderPaidV2`、att.Status 传 `AttemptPaid`、二者 `PaidAt` 传本期支付时间。
- [ ] **Step 5: gateway `settleRenewal`**
`internal/gateway/subscription.go` 替换 stub:
```go
func (g *Gateway) settleRenewal(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) {
sub, err := g.subs.GetByProviderRef("stripe", ev.SubscriptionRef) // channel 固定 stripe;多渠道时由 ev 带 channel
if err != nil {
if errors.Is(err, store.ErrSubNotFound) {
log.Printf("[renewal] 未知订阅 provider_ref=%s(未诞生/已清理),忽略", ev.SubscriptionRef)
return SettleIgnored, nil
}
return SettleFailed, err
}
paidAt := time.Now()
if ev.PaidAt != nil {
paidAt = *ev.PaidAt
}
renewalNo := sub.OutTradeNo + "-r-" + ev.InvoiceRef
created, err := g.orders.CreateRenewalPaid(
&model.OrderV2{
OutTradeNo: renewalNo, BizSystem: sub.BizSystem, BizRef: sub.BizRef, BizCode: sub.BizCode,
Subject: "续费", AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency,
Status: model.OrderPaidV2, PaidAt: &paidAt,
},
&model.Attempt{
OutTradeNo: renewalNo, Channel: sub.Channel, Provider: sub.Channel, ProviderRef: ev.InvoiceRef,
AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency, Status: model.AttemptPaid, PaidAt: &paidAt,
})
if err != nil {
return SettleFailed, err
}
// 续费成功即恢复/维持 active,刷新续费锚点(period_end 最小可行取 paidAt+30d;精确值后续从 invoice.period_end 下发)。
nextEnd := paidAt.Add(30 * 24 * time.Hour)
if _, err := g.subs.Activate(sub.SubID, &nextEnd); err != nil {
return SettleFailed, err
}
if !created || sub.BizSystem == "" {
return SettleDuplicate, nil // 重投 / 独立收款
}
if err := g.webhook.Enqueue(renewalNo, sub.BizSystem, EvtSubscriptionRenewed, map[string]any{
"event_type": EvtSubscriptionRenewed, "out_trade_no": renewalNo, "sub_id": sub.SubID,
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
"amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "channel": sub.Channel,
"paid_at": paidAt.Format(time.RFC3339),
}); err != nil {
return SettleFailed, err
}
return SettleProcessed, nil
}
```
> 需 import `errors`/`log`/`github.com/wangjia/pay/internal/store`。**顺序不变量**:renewal 走「建单即 paid + 入队」,与一次性「先入队后翻转」略异——续费无 pending 中间态、无晚到风险,renewal order 一诞生就是 paid,故门禁(Notifier 的 `orderPaid`)天然放行;`subscription.renewed` 入队失败返回 `SettleFailed` 让 Stripe 重投,重投时 `created=false` 不重复建单、outbox 幂等,自愈。
- [ ] **Step 6 跑通过 + 全量回归**`go build ./... && go test ./...`
- [ ] **Step 7: commit**`feat(pay-v2): P8 Task4 续费入账 invoice.paid→renewal order + subscription.renewed`
---
### Task 5: 取消(API 主动 + 入站 customer.subscription.deleted)+ past_due
**Files:**
- Modify: `internal/provider/stripe/stripe.go`(`VerifyCallback``customer.subscription.deleted` + `invoice.payment_failed`)
- Modify: `internal/gateway/subscription.go`(`CancelSubscription` + `settleSubscriptionCanceled` + `markSubscriptionPastDue` 替换 stub)
- Modify: `internal/handler/gateway.go`(`CancelSubscription` + `GetSubscription` handler)
- Modify: `main.go`(路由 `POST /api/v2/subscriptions/:sub_id/cancel` + `GET /api/v2/subscriptions/:sub_id`)
- Modify: 对应 `_test.go`
**决策记录:**
- **主动取消**:`POST /api/v2/subscriptions/:sub_id/cancel` → 查订阅 → 调 `subProv.CancelSubscription(providerSubRef)` → 本地 `MarkCanceled` + 入队 `subscription.canceled`。Stripe 随后发 `customer.subscription.deleted`,入站处理器再 `MarkCanceled`(幂等 no-op)+ 入队(outbox 幂等)→ 两路收敛同一终态。
- **入账不回收**:取消只翻订阅状态 + 发事件,**不退款、不改已付 renewal order**;权益到期自然失效(entitlement 归业务)。
- **past_due**:`invoice.payment_failed``MarkPastDue`(active→past_due)+ 入队 `subscription.past_due`(业务方可提醒用户换卡);下期 `invoice.paid` 成功 → `settleRenewal``Activate` 自动恢复 active。past_due 事件走**首购 order 的 out_trade_no**(该状态变更不产生新单),`event_type=subscription.past_due` 唯一——但同一订阅可能多次 past_due(多次失败),与 renewed 不同它不天然多单。**决策**:past_due/canceled 这类"订阅状态事件"用 `out_trade_no = sub.OutTradeNo + "-" + event_type + "-" + <稳定后缀>` 铸键避免重复失败被唯一键吞掉——past_due 后缀取失败 invoice id;canceled 只发一次,后缀取 sub_id。保证"该发的都发、重投不重复"。
- [ ] **Step 1: 写失败测试** —— 覆盖:①`CancelSubscription` API 路径(fake provider 记录 cancel 调用)→ 订阅 canceled + 入队 `subscription.canceled`;②入站 `customer.subscription.deleted` event → 同订阅幂等 canceled(不重复 webhook);③`invoice.payment_failed` event → 订阅 past_due + 入队 `subscription.past_due`;④past_due 后一条 `invoice.paid`(subscription_cycle)→ 恢复 active。stripe 层测 `customer.subscription.deleted`/`invoice.payment_failed``VerifyCallback` 归一化 Kind/SubscriptionRef。
- [ ] **Step 2 跑失败**
- [ ] **Step 3: stripe 两个入站事件分类**
```go
case "customer.subscription.deleted":
var sub gostripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return nil, fmt.Errorf("stripe: 解析 subscription 失败: %w", err)
}
return &provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: sub.ID, Raw: string(in.Raw)}, nil
case "invoice.payment_failed":
var inv gostripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err)
}
ev := &provider.PaidEvent{Kind: provider.EventSubscriptionPastDue, InvoiceRef: inv.ID, Raw: string(in.Raw)}
if inv.Subscription != nil {
ev.SubscriptionRef = inv.Subscription.ID
}
return ev, nil
```
- [ ] **Step 4: gateway 三处**
```go
func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error {
sub, err := g.subs.GetBySubID(subID)
if err != nil {
return err
}
if sub.Status == model.SubCanceled {
return nil // 幂等
}
prov, err := g.providers.Get(sub.Channel)
if err != nil {
return err
}
if sp, ok := prov.(provider.SubscriptionProvider); ok {
if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil {
return err
}
}
return g.finalizeCanceled(sub)
}
func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) {
sub, err := g.subs.GetByProviderRef("stripe", ev.SubscriptionRef)
if err != nil {
if errors.Is(err, store.ErrSubNotFound) {
return SettleIgnored, nil
}
return SettleFailed, err
}
if err := g.finalizeCanceled(sub); err != nil {
return SettleFailed, err
}
return SettleProcessed, nil
}
func (g *Gateway) finalizeCanceled(sub *model.Subscription) error {
flipped, err := g.subs.MarkCanceled(sub.SubID)
if err != nil {
return err
}
if !flipped || sub.BizSystem == "" {
return nil // 已取消(重投)/ 独立收款
}
return g.webhook.Enqueue(sub.OutTradeNo+"-canceled-"+sub.SubID, sub.BizSystem, EvtSubscriptionCanceled, map[string]any{
"event_type": EvtSubscriptionCanceled, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
"canceled_at": time.Now().Format(time.RFC3339),
})
}
func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
sub, err := g.subs.GetByProviderRef("stripe", ev.SubscriptionRef)
if err != nil {
if errors.Is(err, store.ErrSubNotFound) {
return SettleIgnored, nil
}
return SettleFailed, err
}
flipped, err := g.subs.MarkPastDue(sub.Channel, sub.ProviderSubRef)
if err != nil {
return SettleFailed, err
}
if !flipped || sub.BizSystem == "" {
return SettleDuplicate, nil
}
if err := g.webhook.Enqueue(sub.OutTradeNo+"-pastdue-"+ev.InvoiceRef, sub.BizSystem, EvtSubscriptionPastDue, map[string]any{
"event_type": EvtSubscriptionPastDue, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
"failed_at": time.Now().Format(time.RFC3339),
}); err != nil {
return SettleFailed, err
}
return SettleProcessed, nil
}
```
> **注意**:`canceled`/`past_due` 事件的 `out_trade_no` 用带后缀的合成键(见决策)入 outbox 唯一键,但 payload 里的 `out_trade_no` 仍给业务方**真实首购单号** `sub.OutTradeNo`(业务方按 `sub_id` 认订阅,不靠合成键)。Notifier 投递门禁 `orderPaid(合成键)` 会查无此单 → 返回 false 永不投递!**修正**:订阅状态类事件(created/renewed/past_due/canceled)其 outbox 行的门禁需放行。最简做法——`onSubscriptionActivated` 用**真实首购单号**(已 paid,门禁天然放行);renewed 用 renewal 单号(已 paid,放行);**past_due/canceled 无对应 paid 单** → 给 `Notifier.orderPaid` 增一条:`out_trade_no` 查不到 order 时,再查 `subscriptions` 是否存在该订阅的事件(或直接约定:合成键前缀 `*-canceled-*`/`*-pastdue-*` 的行免门禁)。**决策**:Task 7 给 Notifier 门禁加「订阅状态事件免 orderPaid 门禁」旁路(按 `event_type ∈ {subscription.past_due, subscription.canceled}` 放行),本 Task 先用真实 `sub.OutTradeNo` 作 outbox 键 + `event_type` 区分(canceled 每订阅一次不撞;past_due 若同订阅多次失败会被唯一键吞掉——最小可行接受"只报首次 past_due",Task 7 再定夺是否要合成键+门禁旁路)。**实现取真实 `sub.OutTradeNo` 作键**,删除上面合成后缀,保持门禁可放行。
- [ ] **Step 5: handler + 路由** —— `CancelSubscription`(`POST .../:sub_id/cancel`,`ErrSubNotFound`→404)、`GetSubscription`(`GET .../:sub_id` 返回状态/period_end)。`main.go` 注册两路由。
- [ ] **Step 6 跑通过 + 全量回归**`go build ./... && go test ./...`
- [ ] **Step 7: commit**`feat(pay-v2): P8 Task5 订阅取消(API+入站)+ past_due 状态`
---
### Task 6: 拒付 chargeback(charge.dispute.created → Chargeback + chargeback.received)
**Files:**
- Modify: `internal/model/v2.go`(`Chargeback` struct)+ `internal/model/testdb.go` + `main.go`(AutoMigrate)
- Create: `internal/store/chargeback.go`(`ChargebackStore`)+ `_test.go`
- Modify: `internal/provider/stripe/stripe.go`(`Create` 一次性单给 PI 打 out_trade_no metadata + `VerifyCallback``charge.dispute.created` 分类,fetch PI 取 metadata)
- Modify: `internal/gateway/subscription.go`(或新 `chargeback.go`:`recordChargeback` 替换 stub)
- Modify: `_test.go`
**决策记录(chargeback 范围):**
- **只 Stripe 有拒付**;alipay/微信本轮无拒付流(设计仅 Stripe/卡),crypto **永无 chargeback**(§6「钱到账不可逆」)——各 `Capabilities` 不声明、注释写清。
- **不自动回收权益**(§6):`recordChargeback` 只:①落 `Chargeback`(幂等 by dispute id);②给原订单打标(新增 `OrderV2.Disputed bool` 列,标记但**不改状态机**);③入队 `chargeback.received` 给业务方自行冲正。
- **订单映射**:dispute webhook 里 `payment_intent` 常只是 id 未展开 → stripe adapter 在 `VerifyCallback``PaymentIntents.Get(piID)``Metadata["out_trade_no"]`。为此**一次性 payment-mode Checkout 的 `Create``PaymentIntentData.Metadata{out_trade_no}`**(小改,加在本 Task),使 PI 携带原单号。**范围界定**:订阅首期/续费的 charge 由 invoice 生成、PI 不带 out_trade_no metadata(Stripe 不自动透传订阅 metadata 到 PI)→ 这类拒付 `out_trade_no` 解析为空,`recordChargeback` **仍落 Chargeback 记录 + log 告警,但不入队业务 webhook**(无法定位业务单)。订阅拒付的精确定位(PI→invoice→sub)留后续轮次,本轮不做——honest scope。
- [ ] **Step 1: 写失败测试** —— ①stripe `VerifyCallback``charge.dispute.created`(payload 的 `payment_intent``"pi_1"`,fake `GET /v1/payment_intents/pi_1``metadata.out_trade_no=PAY-1`)→ `ev.Kind==EventChargeback``ev.DisputeRef=="dp_1"``ev.OutTradeNo=="PAY-1"``ev.PaidAmountMinor`/`Currency`/`Reason` 正确;②gateway `recordChargeback`:喂该 event → `chargebacks` 表落一行(幂等,重投不重复)+ 原 order `Disputed=true` + webhook spy 收 `chargeback.received`;③`out_trade_no` 空的 dispute(PI metadata 无)→ 落 Chargeback 但**不入队** webhook。
- [ ] **Step 2 跑失败**
- [ ] **Step 3: 模型 + store**
```go
// model
type Chargeback struct {
Base
DisputeRef string `gorm:"uniqueIndex;size:128;not null"` // 渠道拒付号(stripe dp_...)
OutTradeNo string `gorm:"index;size:64"` // 解析出的原单号(订阅拒付可能为空)
Channel string `gorm:"index;size:32;not null"`
ProviderPaymentRef string `gorm:"size:128"` // 关联 PaymentIntent id
AmountMinor int64 `gorm:"not null"`
Currency string `gorm:"size:16;not null"`
Reason string `gorm:"size:64"`
Status string `gorm:"size:24"` // 渠道拒付状态快照(needs_response/...)
}
```
`OrderV2``Disputed bool `gorm:"default:false"``(打标不改状态机)。`ChargebackStore.Create(cb) (created bool, err)`(ON CONFLICT `dispute_ref` DoNothing)。testdb/main AutoMigrate 追加 `&Chargeback{}`。
- [ ] **Step 4: stripe `Create` 打 PI metadata + dispute 分类**
`Create`(一次性)params 补:
```go
params.PaymentIntentData = &gostripe.CheckoutSessionPaymentIntentDataParams{
Metadata: map[string]string{"out_trade_no": req.OutTradeNo},
}
```
`VerifyCallback` 增 case:
```go
case "charge.dispute.created":
var d gostripe.Dispute
if err := json.Unmarshal(event.Data.Raw, &d); err != nil {
return nil, fmt.Errorf("stripe: 解析 dispute 失败: %w", err)
}
ev := &provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: d.ID, Status: provider.PaidFailed,
PaidAmountMinor: d.Amount, PaidCurrency: strings.ToUpper(string(d.Currency)),
Reason: string(d.Reason), Raw: string(in.Raw),
}
if d.PaymentIntent != nil && d.PaymentIntent.ID != "" {
ev.ProviderPaymentRef = d.PaymentIntent.ID
if pi, err := p.sc.PaymentIntents.Get(d.PaymentIntent.ID, nil); err == nil && pi.Metadata != nil {
ev.OutTradeNo = pi.Metadata["out_trade_no"] // 一次性单带;订阅 charge 为空
}
}
return ev, nil
```
fake backend 加 `GET /v1/payment_intents/pi_1` → `{"id":"pi_1","object":"payment_intent","metadata":{"out_trade_no":"PAY-1"}}`。
- [ ] **Step 5: gateway `recordChargeback`**
```go
func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
created, err := g.chargebacks.Create(&model.Chargeback{
DisputeRef: ev.DisputeRef, OutTradeNo: ev.OutTradeNo, Channel: method,
ProviderPaymentRef: ev.ProviderPaymentRef, AmountMinor: ev.PaidAmountMinor,
Currency: ev.PaidCurrency, Reason: ev.Reason, Status: "received",
})
if err != nil {
return SettleFailed, err
}
if !created {
return SettleDuplicate, nil // 拒付重投,幂等
}
if ev.OutTradeNo == "" {
log.Printf("[chargeback] dispute=%s 无法定位业务单(订阅/无 metadata),已记录未转发", ev.DisputeRef)
return SettleProcessed, nil
}
o, err := g.orders.GetOrder(ev.OutTradeNo)
if err != nil {
log.Printf("[chargeback] dispute=%s out_trade_no=%s 查单失败: %v", ev.DisputeRef, ev.OutTradeNo, err)
return SettleProcessed, nil // 已记录 chargeback;定位失败不阻断
}
if err := g.orders.MarkDisputed(o.OutTradeNo); err != nil { // 打标不改状态机
return SettleFailed, err
}
if o.BizSystem == "" {
return SettleProcessed, nil
}
if err := g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtChargebackReceived, map[string]any{
"event_type": EvtChargebackReceived, "out_trade_no": o.OutTradeNo, "dispute_ref": ev.DisputeRef,
"biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode,
"amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "reason": ev.Reason,
"received_at": time.Now().Format(time.RFC3339),
}); err != nil {
return SettleFailed, err
}
return SettleProcessed, nil
}
```
> `chargeback.received` outbox 键 `(o.OutTradeNo, chargeback.received)` 唯一——同一单二次拒付罕见,最小可行接受"只转发首次";`Chargeback` 表按 `dispute_ref` 完整留痕每次。`MarkDisputed` 加进 `OrderStore`(条件 UPDATE 置 `disputed=true`)。`Gateway` 注入 `chargebacks *store.ChargebackStore`。**门禁**:`chargeback.received` 挂在已 paid 的原单上,Notifier `orderPaid` 门禁天然放行。
- [ ] **Step 6 跑通过 + 全量回归**`go build ./... && go test ./...`
- [ ] **Step 7: commit**`feat(pay-v2): P8 Task6 拒付 chargeback 记录 + chargeback.received`
---
### Task 7: 业务方 webhook 事件集声明 + 装配收尾 + 契约文档
**Files:**
- Modify: `config/config.go`(`BizSystemConfig``SupportedEvents []string`)
- Modify: `internal/webhook/notifier.go`(投递前按业务方声明的事件集过滤;订阅状态事件门禁旁路)
- Modify: `main.go`(`providerbuild` 装配确认 stripe 已注册即支持订阅;`AutoMigrate` 最终确认含 `Subscription`/`Chargeback`)
- Modify: `docs/pay-v2-unified-gateway-design.html`(§5.1 recurring 标「P8 已实现 gateway_scheduled(Stripe)」、webhook 事件表补 payload 契约)
- Modify: `internal/webhook/notifier_test.go`
**决策记录:**
- **接入方显式声明支持事件集**(设计 §「接入方显式声明支持事件集」):`BizSystemConfig.SupportedEvents`(空=只收 `payment.succeeded`,向后兼容 v1/P2 接入方,不会突然收到新事件把它们搞崩)。Notifier 投递前:`event_type` 不在声明集 → 直接标 delivered(视为已受理,不投、不阻塞队列),避免给没准备好订阅/拒付的业务方推未知事件。
- **门禁旁路**:`subscription.past_due`/`subscription.canceled` 挂在已 paid 的首购单上(Task5 决策已改用真实 `sub.OutTradeNo`),`orderPaid` 门禁天然放行,无需额外旁路;仅需事件集过滤。
- [ ] **Step 1: 写失败测试** —— `notifier_test.go`:①业务方 `SupportedEvents=["payment.succeeded"]`,outbox 有一条 `subscription.renewed``DeliverPending` 不 POST、标 delivered(不占重试)、`payment.succeeded` 正常投;②`SupportedEvents=["payment.succeeded","subscription.renewed","subscription.created","subscription.canceled","subscription.past_due","chargeback.received"]` → 全部正常投递。
- [ ] **Step 2 跑失败**
- [ ] **Step 3: 实现事件集过滤** —— `config.BizSystemConfig``SupportedEvents []string `mapstructure:"supported_events"``;`Notifier` 注入 `bizConfig` 已能拿到它。`deliverOne` 在门禁后加:
```go
if !eventSupported(cfg.SupportedEvents, d.EventType) {
_ = n.deliveries.MarkDelivered(d.ID) // 业务方未订阅该事件:视为已受理,不投递、不重试
return true
}
```
`eventSupported(list, ev)`:`list` 空 → 仅 `ev=="payment.succeeded"` 为真(向后兼容);非空 → `ev ∈ list`
- [ ] **Step 4: 装配 + 文档** —— `main.go` 确认 `gateway.New` 已注入 `SubscriptionStore`/`ChargebackStore`(Task3/6 已做),`AutoMigrate` 含两新表;`providerbuild` 无需改(stripe 已注册即自带 `SubscriptionProvider`)。更新设计 HTML:§5.1 表格 `gateway_scheduled` 行标「✅ P8」、webhook 事件表(§5)补 `subscription.created/renewed/past_due/canceled``chargeback.received` 的 payload 字段契约(out_trade_no/sub_id/dispute_ref/amount_minor/...);顶部执行分期「(later)订阅/拒付」标「P8 gateway_scheduled + chargeback 已落地,token_offsession/store_managed 仍留能力位」。
- [ ] **Step 5 跑通过 + 全量回归**`cd /Users/wangjia/code/pay && go build ./... && go test ./...`
Expected: 全绿。
- [ ] **Step 6: commit**`feat(pay-v2): P8 Task7 业务方事件集声明 + 装配收尾 + 契约文档`
---
## 完成校验(全 Task 后)
- [ ] `go build ./... && go test ./...` 全绿(全 `:memory:`/`httptest`,免 docker、无真实密钥、不打真网)。
- [ ] 一条真实链路闭环(用假 backend 端到端):创建订阅 Checkout → 首期 `checkout.session.completed` → 首购单 paid + `payment.succeeded` + 诞生订阅 + `subscription.created``invoice.paid`(cycle)→ renewal 单 paid + `subscription.renewed``invoice.payment_failed` → past_due + `subscription.past_due` → 取消 → canceled + `subscription.canceled`;`charge.dispute.created` → Chargeback + 订单打标 + `chargeback.received`
- [ ] 能力位诚实:`token_offsession`(`RecurringProvider`)/`store_managed`/alipay 周期扣 均**未实现**,注释/文档写明;crypto `SupportsRecurring=false` 且无 chargeback。
- [ ] 幂等回归:重复投递首期/续费/取消/拒付 webhook,订阅不重复诞生、renewal 单不重复、webhook 不重复、chargeback 不重复。