diff --git a/controllers/pkg/database/cockroach/accountv2.go b/controllers/pkg/database/cockroach/accountv2.go index fbff3ded5..fb8cd2278 100644 --- a/controllers/pkg/database/cockroach/accountv2.go +++ b/controllers/pkg/database/cockroach/accountv2.go @@ -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 + }) +} diff --git a/controllers/pkg/database/cockroach/accountv2_test.go b/controllers/pkg/database/cockroach/accountv2_test.go index 3f91c0711..f98edd3be 100644 --- a/controllers/pkg/database/cockroach/accountv2_test.go +++ b/controllers/pkg/database/cockroach/accountv2_test.go @@ -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) +} diff --git a/controllers/pkg/pay/interface.go b/controllers/pkg/pay/interface.go index 0ab085924..52a2acf86 100644 --- a/controllers/pkg/pay/interface.go +++ b/controllers/pkg/pay/interface.go @@ -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 { diff --git a/controllers/pkg/pay/wechat.go b/controllers/pkg/pay/wechat.go index af893a49b..85b083d2c 100644 --- a/controllers/pkg/pay/wechat.go +++ b/controllers/pkg/pay/wechat.go @@ -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"), }, diff --git a/controllers/pkg/types/payment.go b/controllers/pkg/types/payment.go index afd1e4dc3..bbcc814b8 100644 --- a/controllers/pkg/types/payment.go +++ b/controllers/pkg/types/payment.go @@ -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" +} diff --git a/service/account/api/admin.go b/service/account/api/admin.go index 248bb7faf..7b0f4535e 100644 --- a/service/account/api/admin.go +++ b/service/account/api/admin.go @@ -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}) +} diff --git a/service/account/dao/interface.go b/service/account/dao/interface.go index d86f81268..e8e7f2276 100644 --- a/service/account/dao/interface.go +++ b/service/account/dao/interface.go @@ -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 + }) +} diff --git a/service/account/helper/common.go b/service/account/helper/common.go index ccc7e88d8..bc3159113 100644 --- a/service/account/helper/common.go +++ b/service/account/helper/common.go @@ -43,6 +43,9 @@ const ( AdminFlushDebtResourceStatus = "/flush-debt-resource-status" AdminSuspendUserTraffic = "/suspend-user-traffic" AdminResumeUserTraffic = "/resume-user-traffic" + + AdminRefundForms = "/refund-forms" + AdminCreateCorporate = "/corporate" ) const ( diff --git a/service/account/router/router.go b/service/account/router/router.go index 7f713a616..e0569ebe4 100644 --- a/service/account/router/router.go +++ b/service/account/router/router.go @@ -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).