* support_pay_refund

* corporate

---------

Co-authored-by: wj <3638877@qq.com>
This commit is contained in:
Jiahui
2025-07-31 17:17:44 +08:00
committed by Yun Pan
co-authored by wj
parent a13e10e8fa
commit d5cfcd2c19
9 changed files with 294 additions and 13 deletions
@@ -913,6 +913,15 @@ func (c *Cockroach) updateWithAccount(userUID uuid.UUID, isDeduction, add, isAct
return HandleUpdateResult(result, types.Account{}.TableName())
}
func (c *Cockroach) UpdateWithAccount(
userUID uuid.UUID,
isDeduction, add, isActive bool,
amount int64,
db *gorm.DB,
) error {
return c.updateWithAccount(userUID, isDeduction, add, isActive, amount, db)
}
func HandleUpdateResult(result *gorm.DB, entityName string) error {
if result.Error != nil {
return fmt.Errorf("failed to update %s: %w", entityName, result.Error)
@@ -1735,7 +1744,7 @@ func (c *Cockroach) transferAccount(from, to *types.UserQueryOpts, amount int64,
func (c *Cockroach) InitTables() error {
err := CreateTableIfNotExist(c.DB, types.Account{}, types.AccountTransaction{}, types.Payment{}, types.Transfer{}, types.Region{}, types.Invoice{},
types.InvoicePayment{}, types.Configs{}, types.Credits{}, types.CreditsTransaction{},
types.CardInfo{}, types.PaymentOrder{},
types.CardInfo{}, types.PaymentOrder{}, types.PaymentRefund{}, types.Corporate{},
types.SubscriptionPlan{}, types.Subscription{}, types.SubscriptionTransaction{},
types.AccountRegionUserTask{}, types.UserKYC{}, types.RegionConfig{}, types.Debt{}, types.DebtStatusRecord{}, types.DebtResumeDeductionBalanceTransaction{},
types.UserTimeRangeTraffic{})
@@ -1808,6 +1817,17 @@ func (c *Cockroach) InitTables() error {
}
}
}
// 增加状态列
if !c.DB.Migrator().HasColumn(&types.Payment{}, "status") {
fmt.Println("add column status to payment")
// 注意:IF NOT EXISTS 避免重复报错
sql := `ALTER TABLE "Payment" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'PAID';`
if err := c.DB.Exec(sql).Error; err != nil {
return fmt.Errorf("failed to add payment.status column: %v", err)
}
}
return nil
}
@@ -2032,3 +2052,49 @@ func (c *Cockroach) GetEnterpriseRealNameInfoByUserID(userID string) (*types.Ent
}
return &enterpriseRealNameInfo, nil
}
func (c *Cockroach) CreateCorporate(account *types.Corporate) error {
return c.DB.Transaction(func(tx *gorm.DB) error {
if account.UserUID == "" {
return fmt.Errorf("corporate uid is empty")
}
if account.ReceiptSerialNumber == "" {
return fmt.Errorf("corporate receiptSerialNumber is empty")
}
if account.PayerName == "" {
return fmt.Errorf("corporate payerName is empty")
}
if account.PaymentAmount < 0 {
return fmt.Errorf("corporate paymentAmount is zero")
}
id, err := gonanoid.New(12)
if err != nil {
return fmt.Errorf("failed to generate payment id: %v", err)
}
account.ID = id
pay := &types.Payment{}
pay.ID = account.ID
pay.UserUID, err = c.getUserUIDByID(account.UserUID)
if err != nil {
return fmt.Errorf("failed to get user uid: %v", err)
}
pay.Method = "corporate"
pay.TradeNO = account.ReceiptSerialNumber
pay.Amount = account.PaymentAmount
pay.Gift = account.GiftAmount
pay.Status = types.PaymentStatusPAID
if err := c.DB.Create(pay).Error; err != nil {
return fmt.Errorf("failed to create payment: %w", err)
}
if err := c.DB.Create(account).Error; err != nil {
return fmt.Errorf("failed to create corporate: %w", err)
}
amount := account.PaymentAmount + account.GiftAmount
if account.PaymentAmount > 0 {
if err := c.UpdateWithAccount(pay.UserUID, false, true, false, amount, tx); err != nil {
return fmt.Errorf("failed to update corporate payment amount: %w", err)
}
}
return nil
})
}
@@ -18,6 +18,7 @@ import (
"os"
"testing"
"github.com/google/uuid"
"github.com/labring/sealos/controllers/pkg/types"
)
@@ -115,3 +116,26 @@ func TestCockroach_InitTables(t *testing.T) {
// t.Fatalf("AddDeductionBalanceWithCredits() error = %v", err)
//}
}
func TestCockroach_CreateCorporate(t *testing.T) {
os.Setenv("LOCAL_REGION", "")
ck, err := NewCockRoach("/", "")
if err != nil {
t.Errorf("NewCockRoach() error = %v", err)
return
}
defer ck.Close()
cor := &types.Corporate{
UserUID: "66EqYNUnLr",
ReceiptSerialNumber: uuid.New().String(),
PayerName: "payerName",
PaymentAmount: 1_000_000,
GiftAmount: 1_000_000,
}
err = ck.CreateCorporate(cor)
if err != nil {
t.Errorf("CreateCorporate() error = %v", err)
}
t.Logf("cor: %+v", cor)
}
+4 -3
View File
@@ -15,9 +15,10 @@
package pay
type RefundOption struct {
OrderID string `json:"order_id"`
TradeNo string `json:"trade_no"`
Amount int64 `json:"amount"`
OrderID string `json:"order_id"`
RefundID string `json:"refund_id"`
TradeNo string `json:"trade_no"`
Amount int64 `json:"amount"`
}
type Interface interface {
+11 -4
View File
@@ -19,6 +19,8 @@ import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
)
@@ -74,10 +76,15 @@ func (w WechatPayment) RefundPayment(option RefundOption) (string, string, error
return "", "", fmt.Errorf("order %s has not been paid or the payment time is unknown and cannot be refunded", option.TradeNo)
}
_, paidAmount, err := w.GetPaymentDetails(option.TradeNo)
if err != nil {
return "", "", fmt.Errorf("failed to query the payment order: %v", err)
}
// generate a merchant refund number
refundNo := GetRandomString(32)
if refundNo == "" {
return "", "", fmt.Errorf("generate refundNo failed")
refundNo := uuid.NewString()
if option.RefundID != "" {
refundNo = option.RefundID
}
// Amount Unit Conversion: option. Amount is the "cent", which is the same as CreatePayment (amount/10000)
@@ -94,7 +101,7 @@ func (w WechatPayment) RefundPayment(option RefundOption) (string, string, error
OutRefundNo: core.String(refundNo),
Reason: core.String(fmt.Sprintf("refund for order %s", option.OrderID)),
Amount: &refunddomestic.AmountReq{
Total: core.Int64(refundAmt),
Total: core.Int64(paidAmount / 10000),
Refund: core.Int64(refundAmt),
Currency: core.String("CNY"),
},
+42 -3
View File
@@ -24,9 +24,10 @@ type PaymentRaw struct {
ActivityType ActivityType `gorm:"type:text;column:activityType"`
Message string `gorm:"type:text;not null"`
//TODO 初始化判断 新加字段
CardUID *uuid.UUID `gorm:"type:uuid"`
Type PaymentType `gorm:"type:text"` // 交易类型: AccountRecharge, Subscription,UpgradeSubscription...
ChargeSource ChargeSource `gorm:"type:text"`
CardUID *uuid.UUID `gorm:"type:uuid"`
Type PaymentType `gorm:"type:text"` // 交易类型: AccountRecharge, Subscription,UpgradeSubscription...
ChargeSource ChargeSource `gorm:"type:text"`
Status PaymentStatus `gorm:"type:text;column:status;not null"`
}
type ChargeSource string
@@ -45,12 +46,19 @@ type PaymentOrder struct {
}
type (
PaymentStatus string
PaymentOrderStatus string
CardPaymentStatus string
PaymentType string
)
const (
PaymentStatusPAID PaymentStatus = "PAID"
PaymentStatusRefunded PaymentStatus = "REFUNDED"
)
const (
// PaymentOrderStatusPending TODO will delete
PaymentOrderStatusPending PaymentOrderStatus = "PENDING"
PaymentOrderStatusSuccess PaymentOrderStatus = "SUCCESS"
PaymentOrderStatusFailed PaymentOrderStatus = "FAILED"
@@ -218,3 +226,34 @@ func (c *CaptureResponse) Raw() []byte {
data, _ := json.Marshal(c)
return data
}
type PaymentRefund struct {
TradeNo string `json:"tradeNo" gorm:"type:uuid;not null"`
ID string `json:"Id" gorm:"type:string;not null"` //外键 跟payment关联
Method string `json:"method" gorm:"type:varchar(255);not null"` // 退款方式
//OutTradeNo string `json:"outTradeNo" gorm:"type:uuid"`
RefundNo string `json:"refundNo" gorm:"type:string;not null"`
RefundAmount int64 `json:"refundAmount" gorm:"type:float;not null"`
DeductAmount int64 `json:"deductAmount" gorm:"type:float;not null"` // 从 account的 balance里面扣款
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamp(3) with time zone;default:current_timestamp"`
RefundReason string `json:"refundReason" gorm:"type:text"`
}
func (PaymentRefund) TableName() string {
return "PaymentRefund"
}
type Corporate struct {
UserUID string `json:"userUid" gorm:"type:string;not null"`
ID string `json:"Id" gorm:"type:string;not null"`
ReceiptSerialNumber string `json:"receiptSerialNumber" gorm:"type:uuid;not null"`
PayerName string `json:"payerName" gorm:"type:varchar(255);not null"`
PaymentAmount int64 `json:"paymentAmount" gorm:"type:float;not null"`
GiftAmount int64 `json:"giftAmount" gorm:"type:float;not null"`
PayDate time.Time `json:"payDate" gorm:"type:timestamp(3) with time zone;default:current_timestamp"`
CreationDate time.Time `json:"creationDate" gorm:"type:timestamp(3) with time zone;default:current_timestamp"`
}
func (Corporate) TableName() string {
return "Corporate"
}
+77 -2
View File
@@ -5,6 +5,9 @@ import (
"errors"
"fmt"
"net/http"
"github.com/labring/sealos/controllers/pkg/pay"
"strings"
"github.com/gin-gonic/gin"
@@ -15,7 +18,7 @@ import (
"gorm.io/gorm"
)
// GetAccount
// AdminGetAccountWithWorkspaceID GetAccount
// @Summary Get user account
// @Description Get user account
// @Tags Account
@@ -47,7 +50,7 @@ func AdminGetAccountWithWorkspaceID(c *gin.Context) {
})
}
// ChargeBilling
// AdminChargeBilling ChargeBilling
// @Summary Charge billing
// @Description Charge billing
// @Tags Account
@@ -226,3 +229,75 @@ func adminUserTrafficOperator(c *gin.Context, networkStatus string) {
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func AdminPaymentRefund(c *gin.Context) {
// 1. 管理员鉴权
if err := authenticateAdminRequest(c); err != nil {
c.JSON(http.StatusUnauthorized, helper.ErrorMessage{
Error: fmt.Sprintf("authenticate error: %v", err),
})
return
}
// 2. 解析前端传来的 JSON
var refundData types.PaymentRefund
if err := c.ShouldBindJSON(&refundData); err != nil {
c.JSON(http.StatusBadRequest, helper.ErrorMessage{
Error: fmt.Sprintf("invalid request body: %v", err),
})
return
}
postDo := func(p types.PaymentRefund) error {
svc, err := pay.NewPayHandler(p.Method)
if err != nil {
return fmt.Errorf("new payment handler failed: %v", err)
}
_, _, err = svc.RefundPayment(pay.RefundOption{
TradeNo: p.TradeNo,
Amount: p.RefundAmount,
RefundID: p.RefundNo,
})
if err != nil {
return fmt.Errorf("failed to refund payment: %v", err)
}
return nil
}
// 3. 调用 RefundAmount
if err := dao.DBClient.RefundAmount(refundData, postDo); err != nil {
c.JSON(http.StatusInternalServerError, helper.ErrorMessage{
Error: fmt.Sprintf("refund processing error: %v", err),
})
return
}
// 4. 成功返回
c.JSON(http.StatusOK, gin.H{"success": true})
}
func AdminCreateCorporate(c *gin.Context) {
if err := authenticateAdminRequest(c); err != nil {
c.JSON(http.StatusUnauthorized, helper.ErrorMessage{
Error: fmt.Sprintf("authenticate error: %v", err),
})
return
}
// Parse interfaces coming from the frontend
var corporateData types.Corporate
if err := c.ShouldBindJSON(&corporateData); err != nil {
c.JSON(http.StatusBadRequest, helper.ErrorMessage{
Error: fmt.Sprintf("invalid request body: %v", err),
})
return
}
// invoke CreateCorporate
if err := dao.DBClient.CreateCorporate(corporateData); err != nil {
c.JSON(http.StatusInternalServerError, helper.ErrorMessage{
Error: fmt.Sprintf("failed to create corporate: %v", err),
})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
+64
View File
@@ -3,6 +3,7 @@ package dao
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
@@ -92,6 +93,8 @@ type Interface interface {
SetDefaultCard(cardID uuid.UUID, userUID uuid.UUID) error
GlobalTransactionHandler(funcs ...func(tx *gorm.DB) error) error
GetSubscriptionPlan(planName string) (*types.SubscriptionPlan, error)
RefundAmount(ref types.PaymentRefund, postDo func(types.PaymentRefund) error) error
CreateCorporate(corporate types.Corporate) error
}
type Account struct {
@@ -2322,3 +2325,64 @@ func (m *MongoDB) reconcileUnsettledLLMBilling(startTime, endTime time.Time) (ma
}
return result, nil
}
func (g *Cockroach) RefundAmount(ref types.PaymentRefund, postDo func(types.PaymentRefund) error) error {
//g.ck.GetGlobalDB().Transaction(func(tx *gorm.DB) error {
// // 1. get payment with id,status设置为退款
// // 2. 创建 paymentRefund 数据 进行关联
// // 3. 更新用户账户余额
//})
return g.ck.GetGlobalDB().Transaction(func(tx *gorm.DB) error {
// 1. 查询原 payment 并设置状态为已退款
var payment types.Payment
if err := tx.
Where("id = ? ", ref.ID).
First(&payment).Error; err != nil {
return fmt.Errorf("payment not found: %w", err)
}
// 状态改为 "refunded"
payment.Status = types.PaymentStatusRefunded
if err := tx.Save(&payment).Error; err != nil {
return fmt.Errorf("failed to update payment status: %w", err)
}
// 2. 调用退款接口进行退款
// 调用退款接口之后返回 OutTradeNo 传入payment_refund
if ref.RefundNo == "" {
ref.RefundNo = uuid.NewString()
}
// 2. 创建一条 payment_refund 记录
refund := types.PaymentRefund{
TradeNo: payment.TradeNO, //自查询
ID: payment.ID, //外键 与payment关联 前端传入
Method: payment.Method, //前端传入
RefundNo: ref.RefundNo, //生成传入
RefundAmount: ref.RefundAmount, //前端传入
DeductAmount: ref.DeductAmount, //前端传入
RefundReason: ref.RefundReason, //前端选择传入
}
if err := tx.Create(&refund).Error; err != nil {
log.Printf("创建 refund 时的字段内容: %+v", refund)
return fmt.Errorf("failed to create payment_refund: %w", err)
}
// 用公开方法调用
if ref.DeductAmount > 0 {
if err := g.ck.UpdateWithAccount(payment.UserUID, false, false, false, ref.DeductAmount, tx); err != nil {
return fmt.Errorf("扣款失败:%w", err)
}
}
return postDo(refund)
})
}
func (g *Cockroach) CreateCorporate(corporate types.Corporate) error {
return g.ck.GetGlobalDB().Transaction(func(tx *gorm.DB) error {
if err := g.ck.CreateCorporate(&corporate); err != nil {
return fmt.Errorf("failed to create corporate: %w", err)
}
return nil
})
}
+3
View File
@@ -43,6 +43,9 @@ const (
AdminFlushDebtResourceStatus = "/flush-debt-resource-status"
AdminSuspendUserTraffic = "/suspend-user-traffic"
AdminResumeUserTraffic = "/resume-user-traffic"
AdminRefundForms = "/refund-forms"
AdminCreateCorporate = "/corporate"
)
const (
+2
View File
@@ -77,6 +77,8 @@ func RegisterPayRouter() {
adminGroup := router.Group(helper.AdminGroup).
GET(helper.AdminGetAccountWithWorkspace, api.AdminGetAccountWithWorkspaceID).
GET(helper.AdminGetUserRealNameInfo, api.AdminGetUserRealNameInfo).
POST(helper.AdminCreateCorporate, api.AdminCreateCorporate).
POST(helper.AdminRefundForms, api.AdminPaymentRefund).
POST(helper.AdminChargeBilling, api.AdminChargeBilling).
POST(helper.AdminFlushDebtResourceStatus, api.AdminFlushDebtResourceStatus).
POST(helper.AdminSuspendUserTraffic, api.AdminSuspendUserTraffic).