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

60 KiB
Raw Blame History

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.gointernal/storeinternal/moneyinternal/accounts)。
  • P2 docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md(internal/providerinternal/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.createdChargeback 记录 + 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 VerifyCallbackcheckout.session.completed(含 subscription 模式)/invoice.paid/invoice.payment_failed/customer.subscription.deleted/charge.dispute.created 统一归一化成带 KindPaidEvent;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;新增用到 CheckoutSessionModeSubscriptionSubscription/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:

// 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:

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 追加:

// ---- 订阅(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.goAutoMigrate(...) 追加 &Subscription{}

  • Step 4: 实现 store

internal/store/subscription.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: commitfeat(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:

// 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):

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,再加:

// 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.gofakeStripeAPI switch,新增:

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.CancelDELETE /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: commitfeat(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:

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 收敛业务方声明):

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 带 SubscriptionRefonSubscriptionActivated 幂等诞生 Subscription(status=active)+ 入队 subscription.created(带 sub_id,业务方登记订阅关系)。故 HandleCallbackSettle 后,若 ev.SubscriptionRef!="" 再调 onSubscriptionActivated(幂等,与 Settle 结果无关)。

  • Step 1: 写失败测试

internal/gateway/subscription_test.go(参照 P2/P3 gateway_test.gonewGateway helper——本 Task 需其升级版 newSubGateway 额外返回 SubscriptionStore + webhook spy;若既有 helper 不便复用,在本文件内自建最小装配,注册一个实现 SubscriptionProvider 的 fake):

// 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:

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.goHandleCallback 改为:

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.goCreateSubscription(仿 CreateOrder:读 body、biz_system 非空验 HMAC、调 g.CreateSubscriptionwriteCreateErr 复用;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: commitfeat(pay-v2): P8 Task3 订阅创建 + 首期激活(subscription.created)+ 端点

Task 4: 续费入账(invoice.paid → renewal order + subscription.renewed)

Files:

  • Modify: internal/provider/stripe/stripe.go(VerifyCallbackinvoice.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==EventSubscriptionRenewalev.InvoiceRef=="in_123"ev.SubscriptionRef=="sub_new"ev.PaidAmountMinor==2999ev.Status==Succeeded;再构造 billing_reason=subscription_create 断言 Kind==EventPayment(被跳过入账)。payload 示例:

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:

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:

// 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:

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: commitfeat(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(VerifyCallbackcustomer.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_failedMarkPastDue(active→past_due)+ 入队 subscription.past_due(业务方可提醒用户换卡);下期 invoice.paid 成功 → settleRenewalActivate 自动恢复 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_failedVerifyCallback 归一化 Kind/SubscriptionRef。

  • Step 2 跑失败

  • Step 3: stripe 两个入站事件分类

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 三处
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: commitfeat(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 + VerifyCallbackcharge.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 在 VerifyCallbackPaymentIntents.Get(piID)Metadata["out_trade_no"]。为此一次性 payment-mode Checkout 的 CreatePaymentIntentData.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 VerifyCallbackcharge.dispute.created(payload 的 payment_intent"pi_1",fake GET /v1/payment_intents/pi_1metadata.out_trade_no=PAY-1)→ ev.Kind==EventChargebackev.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

// 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/...)
}

OrderV2Disputed 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 补:

params.PaymentIntentData = &gostripe.CheckoutSessionPaymentIntentDataParams{
	Metadata: map[string]string{"out_trade_no": req.OutTradeNo},
}

VerifyCallback 增 case:

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
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: commitfeat(pay-v2): P8 Task6 拒付 chargeback 记录 + chargeback.received


Task 7: 业务方 webhook 事件集声明 + 装配收尾 + 契约文档

Files:

  • Modify: config/config.go(BizSystemConfigSupportedEvents []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.renewedDeliverPending 不 POST、标 delivered(不占重试)、payment.succeeded 正常投;②SupportedEvents=["payment.succeeded","subscription.renewed","subscription.created","subscription.canceled","subscription.past_due","chargeback.received"] → 全部正常投递。

  • Step 2 跑失败

  • Step 3: 实现事件集过滤 —— config.BizSystemConfigSupportedEvents []string mapstructure:"supported_events"``;Notifier 注入 bizConfig 已能拿到它。deliverOne 在门禁后加:

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/canceledchargeback.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: commitfeat(pay-v2): P8 Task7 业务方事件集声明 + 装配收尾 + 契约文档


完成校验(全 Task 后)

  • go build ./... && go test ./... 全绿(全 :memory:/httptest,免 docker、无真实密钥、不打真网)。
  • 一条真实链路闭环(用假 backend 端到端):创建订阅 Checkout → 首期 checkout.session.completed → 首购单 paid + payment.succeeded + 诞生订阅 + subscription.createdinvoice.paid(cycle)→ renewal 单 paid + subscription.renewedinvoice.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 不重复。