From 338e28d3861964f7d60db308048dffb2364f5bb3 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 16:49:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2):=20RefundStore(=E5=BB=BA=E5=8D=95/?= =?UTF-8?q?=E6=B1=82=E5=92=8C/=E6=9D=A1=E4=BB=B6=E7=BF=BB=E8=BD=AC/?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E5=BE=85=E5=8A=9E)+=20Order=20=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E6=80=81=E6=9C=BA(PaidAttempt/ApplyRefundToOrder)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/store/order_query.go | 32 +++++++++++ internal/store/refund.go | 84 +++++++++++++++++++++++++++ internal/store/refund_test.go | 105 ++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 internal/store/refund.go create mode 100644 internal/store/refund_test.go diff --git a/internal/store/order_query.go b/internal/store/order_query.go index 691c7dd..06867df 100644 --- a/internal/store/order_query.go +++ b/internal/store/order_query.go @@ -62,3 +62,35 @@ func (s *OrderStore) ExpirePendingAttempts(outTradeNo string) (int64, error) { } return res.RowsAffected, nil } + +// PaidAttempt returns the settled (paid) attempt of an order — the payment a +// refund reverses (channel + provider_ref for the original transaction). +func (s *OrderStore) PaidAttempt(outTradeNo string) (*model.Attempt, error) { + var a model.Attempt + err := s.db.Where("out_trade_no = ? AND status = ?", outTradeNo, model.AttemptPaid).First(&a).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAttemptNotFound + } + return nil, fmt.Errorf("store.PaidAttempt: %w", err) + } + return &a, nil +} + +// ApplyRefundToOrder advances a settled order's status per cumulative refunds: +// fully refunded → refunded, else → partially_refunded. Guard: only from a +// post-paid, non-fully-refunded state (paid / partially_refunded / refunding). +func (s *OrderStore) ApplyRefundToOrder(outTradeNo string, fully bool) (bool, error) { + next := model.OrderPartRefundedV2 + if fully { + next = model.OrderRefundedV2 + } + res := s.db.Model(&model.OrderV2{}). + Where("out_trade_no = ? AND status IN ?", outTradeNo, + []model.OrderStatusV2{model.OrderPaidV2, model.OrderPartRefundedV2, model.OrderRefundingV2}). + Update("status", next) + if res.Error != nil { + return false, fmt.Errorf("store.ApplyRefundToOrder: %w", res.Error) + } + return res.RowsAffected > 0, nil +} diff --git a/internal/store/refund.go b/internal/store/refund.go new file mode 100644 index 0000000..6850c02 --- /dev/null +++ b/internal/store/refund.go @@ -0,0 +1,84 @@ +package store + +import ( + "errors" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/model" +) + +var ErrRefundNotFound = errors.New("store: refund not found") + +type RefundStore struct{ db *gorm.DB } + +func NewRefundStore(db *gorm.DB) *RefundStore { return &RefundStore{db: db} } + +func (s *RefundStore) CreateRefund(r *model.Refund) error { + if r.RefundID == "" { + return fmt.Errorf("store.CreateRefund: empty RefundID") + } + if err := s.db.Create(r).Error; err != nil { + return fmt.Errorf("store.CreateRefund: %w", err) + } + return nil +} + +func (s *RefundStore) GetRefund(refundID string) (*model.Refund, error) { + var r model.Refund + if err := s.db.Where("refund_id = ?", refundID).First(&r).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrRefundNotFound + } + return nil, fmt.Errorf("store.GetRefund: %w", err) + } + return &r, nil +} + +// RefundSum sums amount_minor of an order's refunds in the given statuses +// (no statuses = all). 用两种口径:守卫查非失败之和(占额度),态机查 succeeded 之和。 +func (s *RefundStore) RefundSum(outTradeNo string, statuses ...model.RefundStatus) (int64, error) { + var total int64 + q := s.db.Model(&model.Refund{}).Where("out_trade_no = ?", outTradeNo) + if len(statuses) > 0 { + q = q.Where("status IN ?", statuses) + } + if err := q.Select("COALESCE(SUM(amount_minor),0)").Scan(&total).Error; err != nil { + return 0, fmt.Errorf("store.RefundSum: %w", err) + } + return total, nil +} + +// MarkRefundStatus flips a refund from an expected status to a new one +// (conditional UPDATE + RowsAffected). Returns false if not in the from-status. +func (s *RefundStore) MarkRefundStatus(refundID string, from, to model.RefundStatus, providerRefundRef string, at time.Time) (bool, error) { + updates := map[string]any{"status": to} + if providerRefundRef != "" { + updates["provider_refund_ref"] = providerRefundRef + } + if to == model.RefundSucceeded || to == model.RefundFailed { + updates["completed_at"] = at + } + res := s.db.Model(&model.Refund{}). + Where("refund_id = ? AND status = ?", refundID, from). + Updates(updates) + if res.Error != nil { + return false, fmt.Errorf("store.MarkRefundStatus: %w", res.Error) + } + return res.RowsAffected > 0, nil +} + +// ListManualPending lists refunds awaiting manual (crypto) settlement. +func (s *RefundStore) ListManualPending(limit int) ([]model.Refund, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + var out []model.Refund + if err := s.db.Where("status = ?", model.RefundManualPending). + Order("id ASC").Limit(limit).Find(&out).Error; err != nil { + return nil, fmt.Errorf("store.ListManualPending: %w", err) + } + return out, nil +} diff --git a/internal/store/refund_test.go b/internal/store/refund_test.go new file mode 100644 index 0000000..72d8646 --- /dev/null +++ b/internal/store/refund_test.go @@ -0,0 +1,105 @@ +package store + +import ( + "testing" + "time" + + "github.com/wangjia/pay/internal/model" +) + +func seedPaid(t *testing.T, os *OrderStore, outNo string, amount int64) { + t.Helper() + if err := os.CreateOrder(&model.OrderV2{ + OutTradeNo: outNo, BizSystem: "pangolin", BizCode: "pro_year", + AmountMinor: amount, Currency: "CNY", Status: model.OrderPaidV2, + }); err != nil { + t.Fatal(err) + } + if err := os.CreateAttempt(&model.Attempt{ + OutTradeNo: outNo, Channel: "alipay", ProviderRef: outNo, + AmountMinor: amount, Currency: "CNY", Status: model.AttemptPaid, + }); err != nil { + t.Fatal(err) + } +} + +func TestRefundStoreLifecycle(t *testing.T) { + db := model.OpenTestDB(t) + os := NewOrderStore(db) + rs := NewRefundStore(db) + seedPaid(t, os, "PAY-1", 19900) + + // PaidAttempt 定位原交易 + att, err := os.PaidAttempt("PAY-1") + if err != nil || att.Channel != "alipay" || att.ProviderRef != "PAY-1" { + t.Fatalf("PaidAttempt = %+v, %v", att, err) + } + + // 建两笔部分退款(processing) + for _, id := range []string{"rf-1", "rf-2"} { + if err := rs.CreateRefund(&model.Refund{ + RefundID: id, OutTradeNo: "PAY-1", AttemptProviderRef: "PAY-1", + AmountMinor: 5000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business", + }); err != nil { + t.Fatal(err) + } + } + // 非失败之和 = 10000(占额度) + if sum, _ := rs.RefundSum("PAY-1", model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded); sum != 10000 { + t.Fatalf("reserved sum = %d want 10000", sum) + } + + // rf-1 翻 succeeded(条件正确) + ok, err := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "ali-refund-1", time.Now()) + if err != nil || !ok { + t.Fatalf("mark rf-1 = %v, %v", ok, err) + } + // 再翻一次(from 已不符)→ no-op + if ok, _ := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "x", time.Now()); ok { + t.Fatal("重复翻转应 no-op") + } + r1, _ := rs.GetRefund("rf-1") + if r1.Status != model.RefundSucceeded || r1.ProviderRefundRef != "ali-refund-1" || r1.CompletedAt == nil { + t.Fatalf("rf-1 = %+v", r1) + } + + // 部分退:succeeded 之和 5000 < 19900 → partially_refunded + succ, _ := rs.RefundSum("PAY-1", model.RefundSucceeded) + if flipped, _ := os.ApplyRefundToOrder("PAY-1", succ >= 19900); !flipped { + t.Fatal("ApplyRefundToOrder 应翻转") + } + if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 { + t.Fatalf("order status = %s want partially_refunded", o.Status) + } + + // rf-2 也成功 → 总退 10000 仍 < 19900,保持 partially_refunded(非 fully) + _, _ = rs.MarkRefundStatus("rf-2", model.RefundProcessing, model.RefundSucceeded, "ali-refund-2", time.Now()) + succ, _ = rs.RefundSum("PAY-1", model.RefundSucceeded) + _, _ = os.ApplyRefundToOrder("PAY-1", succ >= 19900) + if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 { + t.Fatalf("order status = %s want partially_refunded", o.Status) + } +} + +func TestApplyRefundToOrderFully(t *testing.T) { + db := model.OpenTestDB(t) + os := NewOrderStore(db) + seedPaid(t, os, "PAY-2", 10000) + if _, err := os.ApplyRefundToOrder("PAY-2", true); err != nil { + t.Fatal(err) + } + if o, _ := os.GetOrder("PAY-2"); o.Status != model.OrderRefundedV2 { + t.Fatalf("status = %s want refunded", o.Status) + } +} + +func TestListManualPending(t *testing.T) { + db := model.OpenTestDB(t) + rs := NewRefundStore(db) + _ = rs.CreateRefund(&model.Refund{RefundID: "rf-m1", OutTradeNo: "C-1", AmountMinor: 1, Currency: "USDT", Status: model.RefundManualPending}) + _ = rs.CreateRefund(&model.Refund{RefundID: "rf-p1", OutTradeNo: "C-2", AmountMinor: 1, Currency: "USDT", Status: model.RefundProcessing}) + list, err := rs.ListManualPending(50) + if err != nil || len(list) != 1 || list[0].RefundID != "rf-m1" { + t.Fatalf("manual pending = %+v, %v", list, err) + } +}