0912027c69
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
1082 lines
60 KiB
Markdown
1082 lines
60 KiB
Markdown
# pay v2 · P8 订阅/recurring(Stripe 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 不重复。
|