feat(pay-v2): P8 Task1 Subscription 模型 + 三态状态机 + store
This commit is contained in:
@@ -32,7 +32,7 @@ func OpenTestDB(t *testing.T) *gorm.DB {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&OrderV2{}, &Attempt{}, &Account{}, &Refund{}, &WebhookDelivery{},
|
||||
&Product{}, &ProductPrice{}); err != nil {
|
||||
&Product{}, &ProductPrice{}, &Subscription{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := UpgradeWebhookDeliveryIndex(db); err != nil {
|
||||
|
||||
@@ -121,3 +121,33 @@ type Refund struct {
|
||||
InitiatedBy string `gorm:"size:16"` // business/platform
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
// ---- 订阅(recurring)----
|
||||
|
||||
type SubStatus string
|
||||
|
||||
const (
|
||||
SubActive SubStatus = "active" // 已激活,正常续费
|
||||
SubPastDue SubStatus = "past_due" // 某期扣款失败,待恢复(invoice.payment_failed)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.NotifyLog{},
|
||||
&model.BizNotifyLog{},
|
||||
&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, &model.WebhookDelivery{}, // v2
|
||||
&model.Subscription{}, // v2 recurring
|
||||
); err != nil {
|
||||
log.Fatalf("自动迁移失败: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user