merge: P8 订阅/recurring + 拒付 chargeback 并入(订阅生命周期/续费/取消/past_due/chargeback/事件集收口)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u # Conflicts: # internal/model/testdb.go # internal/provider/provider.go # internal/router/router.go # internal/store/order_query_test.go # main.go
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
)
|
||||
|
||||
// ChargebackStore 持久化拒付留痕(P8 Task6)。与 SubscriptionStore/OrderStore 同惯例:
|
||||
// 幂等靠 DB 唯一约束 + ON CONFLICT DO NOTHING,不在应用层加锁。
|
||||
type ChargebackStore struct{ db *gorm.DB }
|
||||
|
||||
func NewChargebackStore(db *gorm.DB) *ChargebackStore { return &ChargebackStore{db: db} }
|
||||
|
||||
// Create 幂等插入:重复 dispute_ref(渠道重投同一拒付)→ no-op(created=false)。
|
||||
// Chargeback 表对"能否定位业务单"保持中立——OutTradeNo 为空也照常落一行留痕。
|
||||
func (s *ChargebackStore) Create(cb *model.Chargeback) (bool, error) {
|
||||
res := s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "dispute_ref"}},
|
||||
DoNothing: true,
|
||||
}).Create(cb)
|
||||
if res.Error != nil {
|
||||
return false, fmt.Errorf("store.ChargebackStore.Create: %w", res.Error)
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
)
|
||||
|
||||
func TestChargebackCreateIdempotent(t *testing.T) {
|
||||
s := store.NewChargebackStore(model.OpenTestDB(t))
|
||||
cb := &model.Chargeback{
|
||||
DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe",
|
||||
ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD",
|
||||
Reason: "fraudulent", Status: "received",
|
||||
}
|
||||
created, err := s.Create(cb)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first create: created=%v err=%v", created, err)
|
||||
}
|
||||
// 同 dispute_ref 重投(Stripe 重投 charge.dispute.created)→ 幂等 no-op,不双记。
|
||||
again, err := s.Create(&model.Chargeback{
|
||||
DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe",
|
||||
ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD",
|
||||
Reason: "fraudulent", Status: "received",
|
||||
})
|
||||
if err != nil || again {
|
||||
t.Fatalf("dup create: again=%v err=%v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅拒付常无法解析出业务单号(PI 不带 out_trade_no metadata)——Chargeback 表对此
|
||||
// 保持中立,OutTradeNo 空值也照常落一行留痕,不因空值而失败或跳过。
|
||||
func TestChargebackCreateWithEmptyOutTradeNo(t *testing.T) {
|
||||
s := store.NewChargebackStore(model.OpenTestDB(t))
|
||||
created, err := s.Create(&model.Chargeback{
|
||||
DisputeRef: "dp_sub_1", OutTradeNo: "", Channel: "stripe",
|
||||
ProviderPaymentRef: "pi_sub_1", AmountMinor: 999, Currency: "USD",
|
||||
Reason: "fraudulent", Status: "received",
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("create with empty out_trade_no: created=%v err=%v", created, err)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
)
|
||||
@@ -65,6 +66,35 @@ func (s *OrderStore) MarkAttemptPaid(outTradeNo, channel, providerRef string, at
|
||||
return flipped, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (s *OrderStore) CancelOrder(outTradeNo string) (bool, error) {
|
||||
res := s.db.Model(&model.OrderV2{}).
|
||||
Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPendingV2).
|
||||
|
||||
@@ -27,6 +27,19 @@ func (s *OrderStore) GetOrder(outTradeNo string) (*model.OrderV2, error) {
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// MarkDisputed 打拒付标(P8 Task6):条件 UPDATE 仅在 disputed=false 时翻转,幂等——
|
||||
// 重投同一 dispute 命中 rows_affected=0,不报错,调用方(recordChargeback)不看返回值
|
||||
// 也安全(打标不改状态机,不存在"取消标记"这一操作,单向翻转足够)。
|
||||
func (s *OrderStore) MarkDisputed(outTradeNo string) (bool, error) {
|
||||
res := s.db.Model(&model.OrderV2{}).
|
||||
Where("out_trade_no = ? AND disputed = ?", outTradeNo, false).
|
||||
Update("disputed", true)
|
||||
if res.Error != nil {
|
||||
return false, fmt.Errorf("store.MarkDisputed: %w", res.Error)
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// AttemptByProviderRef resolves an attempt from a bare provider_ref, so settlement
|
||||
// can recover out_trade_no + channel from a callback/query that only carries the ref.
|
||||
func (s *OrderStore) AttemptByProviderRef(providerRef string) (*model.Attempt, error) {
|
||||
|
||||
@@ -232,3 +232,25 @@ func TestListAttemptsByChannelSince(t *testing.T) {
|
||||
t.Fatalf("只应含近期 crypto, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkDisputed 覆盖 P8 Task6 打标:条件 UPDATE 只在 disputed=false 时翻转,重投同一
|
||||
// dispute(第二次调用)幂等 no-op(rows_affected=0,不报错)。
|
||||
func TestMarkDisputed(t *testing.T) {
|
||||
s := store.NewOrderStore(model.OpenTestDB(t))
|
||||
seedOrder(t, s, "PAY-Q3")
|
||||
|
||||
flipped, err := s.MarkDisputed("PAY-Q3")
|
||||
if err != nil || !flipped {
|
||||
t.Fatalf("first MarkDisputed: flipped=%v err=%v", flipped, err)
|
||||
}
|
||||
o, err := s.GetOrder("PAY-Q3")
|
||||
if err != nil || !o.Disputed {
|
||||
t.Fatalf("order after MarkDisputed = %+v, %v", o, err)
|
||||
}
|
||||
|
||||
// 重投同一 dispute → 幂等 no-op,不报错。
|
||||
again, err := s.MarkDisputed("PAY-Q3")
|
||||
if err != nil || again {
|
||||
t.Fatalf("dup MarkDisputed: again=%v err=%v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user