feat(v2): RefundStore(建单/求和/条件翻转/人工待办)+ Order 退款态机(PaidAttempt/ApplyRefundToOrder)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user