diff --git a/internal/model/testdb.go b/internal/model/testdb.go index 4910b80..e12d7f3 100644 --- a/internal/model/testdb.go +++ b/internal/model/testdb.go @@ -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 { diff --git a/internal/model/v2.go b/internal/model/v2.go index 94665b0..f5f4dcc 100644 --- a/internal/model/v2.go +++ b/internal/model/v2.go @@ -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 +} diff --git a/internal/store/subscription.go b/internal/store/subscription.go new file mode 100644 index 0000000..0ba7822 --- /dev/null +++ b/internal/store/subscription.go @@ -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 +} diff --git a/internal/store/subscription_test.go b/internal/store/subscription_test.go new file mode 100644 index 0000000..22dc9c1 --- /dev/null +++ b/internal/store/subscription_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 8c039de..55fcf5d 100644 --- a/main.go +++ b/main.go @@ -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) }