diff --git a/common/constants.go b/common/constants.go index cfd4ed81..ba5cf46c 100644 --- a/common/constants.go +++ b/common/constants.go @@ -205,4 +205,6 @@ const ( TopUpStatusSuccess = "success" TopUpStatusExpired = "expired" TopUpStatusFailed = "failed" + TopUpStatusRefundPending = "refund_pending" + TopUpStatusRefunded = "refunded" ) diff --git a/controller/topup.go b/controller/topup.go index 5c5e2a1f..72c33244 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -11,6 +11,8 @@ import ( "github.com/gin-gonic/gin" ) +const topUpRefundWindowSeconds = int64(24 * 60 * 60) + func GetTopUpInfo(c *gin.Context) { // 获取支付方式 payMethods := operation_setting.PayMethods @@ -70,8 +72,129 @@ func GetUserTopUps(c *gin.Context) { return } + now := common.GetTimestamp() + eligibleRefs := make([]string, 0, len(topups)) + for _, t := range topups { + if t == nil { + continue + } + if t.Status != common.TopUpStatusSuccess { + continue + } + if t.PaymentMethod != PaymentMethodStripe { + continue + } + paidAt := t.CompleteTime + if paidAt == 0 { + paidAt = t.CreateTime + } + if paidAt == 0 { + continue + } + if now-paidAt > topUpRefundWindowSeconds { + continue + } + eligibleRefs = append(eligibleRefs, t.TradeNo) + } + + type creditGrantAgg struct { + Reference string `gorm:"column:reference"` + TotalQuota int64 `gorm:"column:total_quota"` + UsedQuota int64 `gorm:"column:used_quota"` + } + grantAgg := map[string]creditGrantAgg{} + if len(eligibleRefs) > 0 { + var rows []creditGrantAgg + if err := model.DB.Model(&model.CreditGrant{}). + Where("user_id = ? AND grant_type = ? AND reference IN ?", userId, "topup", eligibleRefs). + Select("reference, SUM(quota) as total_quota, SUM(used_quota) as used_quota"). + Group("reference"). + Scan(&rows).Error; err != nil { + common.ApiError(c, err) + return + } + for _, r := range rows { + grantAgg[r.Reference] = r + } + } + + type topUpWithRefund struct { + model.TopUp + Refundable bool `json:"refundable"` + RefundIneligibleReason string `json:"refund_ineligible_reason,omitempty"` + RefundWindowSecondsLeft int64 `json:"refund_window_seconds_left,omitempty"` + } + + decorateRefund := func(t *model.TopUp) topUpWithRefund { + row := topUpWithRefund{TopUp: *t} + switch t.Status { + case common.TopUpStatusRefunded: + row.Refundable = false + row.RefundIneligibleReason = "Already refunded" + return row + case common.TopUpStatusRefundPending: + row.Refundable = false + row.RefundIneligibleReason = "Refund in progress" + return row + } + if t.Status != common.TopUpStatusSuccess { + row.Refundable = false + row.RefundIneligibleReason = "Only successful payments can be refunded" + return row + } + if t.PaymentMethod != PaymentMethodStripe { + row.Refundable = false + row.RefundIneligibleReason = "Only Stripe payments can be refunded" + return row + } + paidAt := t.CompleteTime + if paidAt == 0 { + paidAt = t.CreateTime + } + if paidAt == 0 { + row.Refundable = false + row.RefundIneligibleReason = "Missing payment time" + return row + } + age := now - paidAt + if age > topUpRefundWindowSeconds { + row.Refundable = false + row.RefundIneligibleReason = "Refund window expired" + row.RefundWindowSecondsLeft = 0 + return row + } + row.RefundWindowSecondsLeft = topUpRefundWindowSeconds - age + agg, ok := grantAgg[t.TradeNo] + if !ok { + row.Refundable = false + row.RefundIneligibleReason = "Credits not found" + return row + } + if agg.UsedQuota > 0 { + row.Refundable = false + row.RefundIneligibleReason = "Credits already used" + return row + } + if agg.TotalQuota <= 0 { + row.Refundable = false + row.RefundIneligibleReason = "Invalid credit grant" + return row + } + row.Refundable = true + row.RefundIneligibleReason = "" + return row + } + + items := make([]topUpWithRefund, 0, len(topups)) + for _, t := range topups { + if t == nil { + continue + } + items = append(items, decorateRefund(t)) + } + pageInfo.SetTotal(int(total)) - pageInfo.SetItems(topups) + pageInfo.SetItems(items) common.ApiSuccess(c, pageInfo) } diff --git a/controller/topup_refund.go b/controller/topup_refund.go new file mode 100644 index 00000000..032f7513 --- /dev/null +++ b/controller/topup_refund.go @@ -0,0 +1,270 @@ +package controller + +import ( + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + + "github.com/gin-gonic/gin" + "github.com/stripe/stripe-go/v81" + checkoutsession "github.com/stripe/stripe-go/v81/checkout/session" + striperefund "github.com/stripe/stripe-go/v81/refund" + "gorm.io/gorm" +) + +type TopUpRefundRequest struct { + Id int `json:"id"` +} + +type topUpRefundGrantSnapshot struct { + Quota int + CreatedTime int64 + ExpiredTime int64 + Reference string + Remark string + CreatedBy int +} + +func getStripePaymentIntentIDForRefund(tradeNo string) (string, error) { + if tradeNo == "" { + return "", errors.New("missing order number") + } + if strings.HasPrefix(tradeNo, "pi_") { + return tradeNo, nil + } + if strings.HasPrefix(tradeNo, "cs_") { + params := &stripe.CheckoutSessionParams{} + params.AddExpand("payment_intent") + s, err := checkoutsession.Get(tradeNo, params) + if err != nil { + return "", err + } + if s.PaymentIntent == nil || s.PaymentIntent.ID == "" { + return "", errors.New("missing payment_intent in checkout session") + } + return s.PaymentIntent.ID, nil + } + return "", errors.New("unsupported order number") +} + +func createStripeRefundForTopUp(topUpID int, userID int, tradeNo string) (*stripe.Refund, error) { + if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") { + return nil, errors.New("Stripe is not configured") + } + stripe.Key = setting.StripeApiSecret + + piID, err := getStripePaymentIntentIDForRefund(tradeNo) + if err != nil { + return nil, err + } + + params := &stripe.RefundParams{ + PaymentIntent: stripe.String(piID), + Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)), + Metadata: map[string]string{ + "topup_id": strconv.Itoa(topUpID), + "user_id": strconv.Itoa(userID), + "trade_no": tradeNo, + "requested": "self_service", + }, + } + params.SetIdempotencyKey(fmt.Sprintf("topup_refund_%d", topUpID)) + return striperefund.New(params) +} + +func refundTopUpDeductCreditsTx(tx *gorm.DB, userId int, topUp *model.TopUp, now int64) (deducted int, grantSnapshot []topUpRefundGrantSnapshot, err error) { + paidAt := topUp.CompleteTime + if paidAt == 0 { + paidAt = topUp.CreateTime + } + if paidAt == 0 { + return 0, nil, errors.New("missing payment time") + } + if now-paidAt > topUpRefundWindowSeconds { + return 0, nil, errors.New("refund window expired") + } + + var user model.User + if err := tx.Set("gorm:query_option", "FOR UPDATE").Select("id", "quota", "quota_expires_at").First(&user, userId).Error; err != nil { + return 0, nil, err + } + + var grants []model.CreditGrant + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("user_id = ? AND grant_type = ? AND reference = ?", userId, "topup", topUp.TradeNo). + Find(&grants).Error; err != nil { + return 0, nil, err + } + if len(grants) == 0 { + return 0, nil, errors.New("credits not found") + } + + totalQuota := 0 + snapshots := make([]topUpRefundGrantSnapshot, 0, len(grants)) + for _, g := range grants { + if g.UsedQuota != 0 { + return 0, nil, errors.New("credits already used") + } + if g.Quota <= 0 { + return 0, nil, errors.New("invalid credit grant") + } + totalQuota += g.Quota + snapshots = append(snapshots, topUpRefundGrantSnapshot{ + Quota: g.Quota, + CreatedTime: g.CreatedTime, + ExpiredTime: g.ExpiredTime, + Reference: g.Reference, + Remark: g.Remark, + CreatedBy: g.CreatedBy, + }) + } + if totalQuota <= 0 { + return 0, nil, errors.New("invalid credit grant") + } + if user.Quota < totalQuota { + return 0, nil, errors.New("quota is inconsistent") + } + + if err := tx.Where("user_id = ? AND grant_type = ? AND reference = ?", userId, "topup", topUp.TradeNo). + Delete(&model.CreditGrant{}).Error; err != nil { + return 0, nil, err + } + + var next sql.NullInt64 + if err := tx.Model(&model.CreditGrant{}). + Where("user_id = ? AND expired_time != 0 AND quota > used_quota", userId). + Select("MIN(expired_time)"). + Scan(&next).Error; err != nil { + return 0, nil, err + } + newExpiresAt := int64(0) + if next.Valid { + newExpiresAt = next.Int64 + } + + if err := tx.Model(&model.User{}).Where("id = ?", userId). + Updates(map[string]any{"quota": user.Quota - totalQuota, "quota_expires_at": newExpiresAt}).Error; err != nil { + return 0, nil, err + } + + topUp.Status = common.TopUpStatusRefundPending + if err := tx.Save(topUp).Error; err != nil { + return 0, nil, err + } + + return totalQuota, snapshots, nil +} + +func restoreTopUpCreditsTx(tx *gorm.DB, userId int, topUp *model.TopUp, snapshots []topUpRefundGrantSnapshot, now int64) error { + for _, s := range snapshots { + _, err := model.CreateCreditGrantTx(tx, model.CreateCreditGrantParams{ + UserId: userId, + Quota: s.Quota, + GrantType: "topup", + Reference: s.Reference, + Remark: s.Remark, + CreatedBy: s.CreatedBy, + CreatedTime: s.CreatedTime, + ExpiredTime: s.ExpiredTime, + }) + if err != nil { + return err + } + } + _ = now + topUp.Status = common.TopUpStatusSuccess + return tx.Save(topUp).Error +} + +func RefundTopUpSelf(c *gin.Context) { + userId := c.GetInt("id") + if userId <= 0 { + common.ApiErrorMsg(c, "Invalid user id") + return + } + var req TopUpRefundRequest + if err := c.ShouldBindJSON(&req); err != nil || req.Id <= 0 { + common.ApiErrorMsg(c, "Invalid parameters") + return + } + + now := common.GetTimestamp() + var topUp model.TopUp + var didDeduct bool + var grantSnapshot []topUpRefundGrantSnapshot + + err := model.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ? AND user_id = ?", req.Id, userId). + First(&topUp).Error; err != nil { + return errors.New("invoice not found") + } + if topUp.PaymentMethod != PaymentMethodStripe { + return errors.New("only Stripe payments can be refunded") + } + switch topUp.Status { + case common.TopUpStatusRefunded: + return nil + case common.TopUpStatusRefundPending: + return nil + case common.TopUpStatusSuccess: + // continue + default: + return errors.New("only successful payments can be refunded") + } + + if topUp.Status == common.TopUpStatusSuccess { + _, snapshot, err := refundTopUpDeductCreditsTx(tx, userId, &topUp, now) + if err != nil { + return err + } + didDeduct = true + grantSnapshot = snapshot + } + return nil + }) + if err != nil { + common.ApiError(c, err) + return + } + if topUp.Status == common.TopUpStatusRefunded { + common.ApiSuccess(c, gin.H{"status": common.TopUpStatusRefunded}) + return + } + + ref, err := createStripeRefundForTopUp(topUp.Id, userId, topUp.TradeNo) + if err != nil { + if didDeduct { + _ = model.DB.Transaction(func(tx *gorm.DB) error { + var locked model.TopUp + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ? AND user_id = ?", req.Id, userId). + First(&locked).Error; err != nil { + return nil + } + if locked.Status != common.TopUpStatusRefundPending { + return nil + } + return restoreTopUpCreditsTx(tx, userId, &locked, grantSnapshot, now) + }) + } + common.ApiError(c, fmt.Errorf("refund failed: %w", err)) + return + } + + if err := model.DB.Model(&model.TopUp{}). + Where("id = ? AND user_id = ?", topUp.Id, userId). + Update("status", common.TopUpStatusRefunded).Error; err != nil { + common.ApiError(c, err) + return + } + + model.RecordLog(userId, model.LogTypeRefund, fmt.Sprintf("Self refund: invoice #%d (%s) refund_id=%s", topUp.Id, topUp.TradeNo, ref.ID)) + common.ApiSuccess(c, gin.H{"refund_id": ref.ID}) +} diff --git a/model/topup.go b/model/topup.go index 203d910f..d3596827 100644 --- a/model/topup.go +++ b/model/topup.go @@ -75,7 +75,7 @@ func Recharge(referenceId string, customerId string) (err error) { } // Idempotency: Stripe webhooks may be delivered more than once. - if topUp.Status == common.TopUpStatusSuccess { + if topUp.Status == common.TopUpStatusSuccess || topUp.Status == common.TopUpStatusRefundPending || topUp.Status == common.TopUpStatusRefunded { return nil } if topUp.Status != common.TopUpStatusPending { diff --git a/router/api-router.go b/router/api-router.go index ef830ba6..d8640c67 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -79,6 +79,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/credit_grants", controller.GetSelfCreditGrants) selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.GET("/topup/self", controller.GetUserTopUps) + selfRoute.POST("/topup/refund", middleware.CriticalRateLimit(), controller.RefundTopUpSelf) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) diff --git a/web-v2/src/pages/console/TopUpHistoryPage.tsx b/web-v2/src/pages/console/TopUpHistoryPage.tsx index 4e9f603b..00c845f9 100644 --- a/web-v2/src/pages/console/TopUpHistoryPage.tsx +++ b/web-v2/src/pages/console/TopUpHistoryPage.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; -import { Eye } from 'lucide-react'; +import { Eye, Undo2 } from 'lucide-react'; import { fetchJson } from '@/api/client'; import type { ApiResponse } from '@/api/types'; import { formatUnixSeconds } from '@/lib/time'; import { copyText } from '@/lib/clipboard'; +import { confirmModal } from '@/ui/confirmModal'; import { toast } from '@/ui/toast'; import { Button, Card, Modal } from '@/components/ui/heroui'; import { TableActionButton } from '@/components/ui/TableActionButton'; @@ -17,6 +18,9 @@ type TopUpRow = { create_time: number; complete_time: number; status: string; + refundable?: boolean; + refund_ineligible_reason?: string; + refund_window_seconds_left?: number; }; type PageInfo = { page: number; page_size: number; total: number; items: T }; @@ -24,11 +28,16 @@ type PageInfo = { page: number; page_size: number; total: number; items: T }; function TopUpDetailModal({ topUp, onClose, + onRefund, + refunding, }: { topUp: TopUpRow | null; onClose: () => void; + onRefund: (topUp: TopUpRow) => void; + refunding: boolean; }) { const tradeNo = topUp?.trade_no || ''; + const refundable = Boolean(topUp?.refundable); return ( Status
{topUp.status}
+
+
Refund
+
+ {refundable + ? 'Eligible (within 24h and credits unused)' + : topUp.refund_ineligible_reason + ? `Not eligible: ${topUp.refund_ineligible_reason}` + : 'Not eligible'} +
+
-
Order number (Stripe: cs_*)
+
Order number (Stripe: cs_* / pi_*)
                         {tradeNo}
                       
@@ -99,6 +118,16 @@ function TopUpDetailModal({ +