package controller import ( "context" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/gin-gonic/gin" "github.com/shopspring/decimal" "github.com/stripe/stripe-go/v81" "github.com/stripe/stripe-go/v81/checkout/session" "github.com/stripe/stripe-go/v81/webhook" "github.com/thanhpk/randstr" ) var stripeAdaptor = &StripeAdaptor{} // StripePayRequest represents a payment request for Stripe checkout. type StripePayRequest struct { // Amount is the quantity of units to purchase. Amount int64 `json:"amount"` // PaymentMethod specifies the payment method (e.g., "stripe"). PaymentMethod string `json:"payment_method"` // SuccessURL is the optional custom URL to redirect after successful payment. // If empty, defaults to the server's console log page. SuccessURL string `json:"success_url,omitempty"` // CancelURL is the optional custom URL to redirect when payment is canceled. // If empty, defaults to the server's console topup page. CancelURL string `json:"cancel_url,omitempty"` } type StripeAdaptor struct { } func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) { if req.Amount < getStripeMinTopup() { paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getStripeMinTopup()})) return } if req.Amount > 10000 { paymentError(c, common.NewMessage("Top-up amount cannot exceed {{max}}", map[string]any{"max": 10000})) return } id := c.GetInt("id") group, err := model.GetUserGroup(id, true) if err != nil { paymentError(c, common.NewMessage("Failed to get the user group")) return } if rejectInvalidCreditedQuota(c, id, getStripeCreditedQuota(req.Amount, group)) { return } payMoney := getStripePayMoney(float64(req.Amount), group) if payMoney <= 0.01 { paymentError(c, common.NewMessage("Payment amount is too low")) return } c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) } func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { if req.PaymentMethod != model.PaymentMethodStripe { paymentError(c, common.NewMessage("Unsupported payment channel")) return } if req.Amount < getStripeMinTopup() { common.ApiErrorStatus(c, http.StatusOK, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": getStripeMinTopup()}), gin.H{"data": 10}) return } if req.Amount > 10000 { common.ApiErrorStatus(c, http.StatusOK, common.NewMessage("Top-up amount cannot exceed {{max}}", map[string]any{"max": 10000}), gin.H{"data": 10}) return } if req.SuccessURL != "" && common.ValidateRedirectURL(req.SuccessURL) != nil { common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Payment success redirect URL is not in the trusted domain list"), gin.H{"data": ""}) return } if req.CancelURL != "" && common.ValidateRedirectURL(req.CancelURL) != nil { common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Payment cancel redirect URL is not in the trusted domain list"), gin.H{"data": ""}) return } id := c.GetInt("id") user, err := model.GetUserById(id, false) if err != nil || user == nil { paymentError(c, common.NewMessage("User does not exist")) return } chargedMoney := GetChargedAmount(float64(req.Amount), *user) if rejectInvalidCreditedQuota(c, id, decimal.NewFromFloat(chargedMoney).Mul(decimal.NewFromFloat(common.QuotaPerUnit)), ) { return } reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) referenceId := "ref_" + common.Sha1([]byte(reference)) payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount, req.SuccessURL, req.CancelURL) if err != nil { logger.LogError(c.Request.Context(), common.LogText("Stripe failed to create Checkout Session user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error())) paymentError(c, common.NewMessage("Failed to start payment")) return } topUp := &model.TopUp{ UserId: id, Amount: req.Amount, Money: chargedMoney, TradeNo: referenceId, PaymentMethod: model.PaymentMethodStripe, PaymentProvider: model.PaymentProviderStripe, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } err = topUp.Insert() if err != nil { logger.LogError(c.Request.Context(), common.LogText("Stripe failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error())) paymentError(c, common.NewMessage("Failed to create order")) return } logger.LogInfo(c.Request.Context(), common.LogText("Stripe top-up order created user_id=%d trade_no=%s amount=%d money=%.2f", id, referenceId, req.Amount, chargedMoney)) c.JSON(http.StatusOK, gin.H{ "message": "success", "data": gin.H{ "pay_link": payLink, }, }) } func RequestStripeAmount(c *gin.Context) { var req StripePayRequest err := c.ShouldBindJSON(&req) if err != nil { paymentError(c, common.NewMessage("Invalid parameters")) return } stripeAdaptor.RequestAmount(c, &req) } func RequestStripePay(c *gin.Context) { var req StripePayRequest err := c.ShouldBindJSON(&req) if err != nil { paymentError(c, common.NewMessage("Invalid parameters")) return } stripeAdaptor.RequestPay(c, &req) } func StripeWebhook(c *gin.Context) { ctx := c.Request.Context() if !isStripeWebhookEnabled() { logger.LogWarn(ctx, common.LogText("Stripe webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP())) c.AbortWithStatus(http.StatusForbidden) return } payload, err := io.ReadAll(c.Request.Body) if err != nil { logger.LogError(ctx, common.LogText("Stripe webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) c.AbortWithStatus(http.StatusServiceUnavailable) return } signature := c.GetHeader("Stripe-Signature") logger.LogInfo(ctx, common.LogText("Stripe webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(payload))) event, err := webhook.ConstructEventWithOptions(payload, signature, setting.StripeWebhookSecret, webhook.ConstructEventOptions{ IgnoreAPIVersionMismatch: true, }) if err != nil { logger.LogWarn(ctx, common.LogText("Stripe webhook signature verification failed path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) c.AbortWithStatus(http.StatusBadRequest) return } callerIp := c.ClientIP() logger.LogInfo(ctx, common.LogText("Stripe webhook signature verified event_type=%s client_ip=%s path=%q", string(event.Type), callerIp, c.Request.RequestURI)) switch event.Type { case stripe.EventTypeCheckoutSessionCompleted: sessionCompleted(ctx, event, callerIp) case stripe.EventTypeCheckoutSessionExpired: sessionExpired(ctx, event) case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded: sessionAsyncPaymentSucceeded(ctx, event, callerIp) case stripe.EventTypeCheckoutSessionAsyncPaymentFailed: sessionAsyncPaymentFailed(ctx, event, callerIp) default: logger.LogInfo(ctx, common.LogText("Stripe webhook ignored event event_type=%s client_ip=%s", string(event.Type), callerIp)) } c.Status(http.StatusOK) } func sessionCompleted(ctx context.Context, event stripe.Event, callerIp string) { customerId := event.GetObjectValue("customer") referenceId := event.GetObjectValue("client_reference_id") status := event.GetObjectValue("status") if "complete" != status { logger.LogWarn(ctx, common.LogText("Stripe checkout.completed has unexpected status, ignored trade_no=%s status=%s client_ip=%s", referenceId, status, callerIp)) return } paymentStatus := event.GetObjectValue("payment_status") if paymentStatus != "paid" { logger.LogInfo(ctx, common.LogText("Stripe Checkout payment not completed, waiting for async result trade_no=%s payment_status=%s client_ip=%s", referenceId, paymentStatus, callerIp)) return } fulfillOrder(ctx, event, referenceId, customerId, callerIp) } // sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.) // that confirm payment after the checkout session completes. func sessionAsyncPaymentSucceeded(ctx context.Context, event stripe.Event, callerIp string) { customerId := event.GetObjectValue("customer") referenceId := event.GetObjectValue("client_reference_id") logger.LogInfo(ctx, common.LogText("Stripe async payment succeeded trade_no=%s client_ip=%s", referenceId, callerIp)) fulfillOrder(ctx, event, referenceId, customerId, callerIp) } // sessionAsyncPaymentFailed marks orders as failed when delayed payment methods // ultimately fail (e.g. bank transfer not received, SEPA rejected). func sessionAsyncPaymentFailed(ctx context.Context, event stripe.Event, callerIp string) { referenceId := event.GetObjectValue("client_reference_id") logger.LogWarn(ctx, common.LogText("Stripe async payment failed trade_no=%s client_ip=%s", referenceId, callerIp)) if len(referenceId) == 0 { logger.LogWarn(ctx, common.LogText("Stripe async payment failed event missing order number client_ip=%s", callerIp)) return } LockOrder(referenceId) defer UnlockOrder(referenceId) topUp := model.GetTopUpByTradeNo(referenceId) if topUp == nil { logger.LogWarn(ctx, common.LogText("Stripe async payment failed but local order not found trade_no=%s client_ip=%s", referenceId, callerIp)) return } if topUp.PaymentProvider != model.PaymentProviderStripe { logger.LogWarn(ctx, common.LogText("Stripe async payment failed but order payment gateway mismatch trade_no=%s payment_provider=%s client_ip=%s", referenceId, topUp.PaymentProvider, callerIp)) return } if topUp.Status != common.TopUpStatusPending { logger.LogInfo(ctx, common.LogText("Stripe async payment failed but order not pending, ignored trade_no=%s status=%s client_ip=%s", referenceId, topUp.Status, callerIp)) return } topUp.Status = common.TopUpStatusFailed if err := topUp.Update(); err != nil { logger.LogError(ctx, common.LogText("Stripe failed to mark top-up order as failed trade_no=%s client_ip=%s error=%q", referenceId, callerIp, err.Error())) return } logger.LogInfo(ctx, common.LogText("Stripe top-up order marked as failed trade_no=%s client_ip=%s", referenceId, callerIp)) } // fulfillOrder is the shared logic for crediting quota after payment is confirmed. func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, customerId string, callerIp string) { if len(referenceId) == 0 { logger.LogWarn(ctx, common.LogText("Stripe order completion missing order number client_ip=%s", callerIp)) return } LockOrder(referenceId) defer UnlockOrder(referenceId) payload := map[string]any{ "customer": customerId, "amount_total": event.GetObjectValue("amount_total"), "currency": strings.ToUpper(event.GetObjectValue("currency")), "event_type": string(event.Type), } if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload), model.PaymentProviderStripe, ""); err == nil { logger.LogInfo(ctx, common.LogText("Stripe subscription order processed trade_no=%s event_type=%s client_ip=%s", referenceId, string(event.Type), callerIp)) return } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { logger.LogError(ctx, common.LogText("Stripe subscription order processing failed trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error())) return } err := model.Recharge(referenceId, customerId, callerIp) if err != nil { logger.LogError(ctx, common.LogText("Stripe top-up processing failed trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error())) return } total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64) currency := strings.ToUpper(event.GetObjectValue("currency")) logger.LogInfo(ctx, common.LogText("Stripe top-up succeeded trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s", referenceId, total/100, currency, string(event.Type), callerIp)) } func sessionExpired(ctx context.Context, event stripe.Event) { referenceId := event.GetObjectValue("client_reference_id") status := event.GetObjectValue("status") if "expired" != status { logger.LogWarn(ctx, common.LogText("Stripe checkout.expired has unexpected status, ignored trade_no=%s status=%s", referenceId, status)) return } if len(referenceId) == 0 { logger.LogWarn(ctx, common.LogText("Stripe checkout.expired missing order number")) return } // Subscription order expiration LockOrder(referenceId) defer UnlockOrder(referenceId) if err := model.ExpireSubscriptionOrder(referenceId, model.PaymentProviderStripe); err == nil { logger.LogInfo(ctx, common.LogText("Stripe subscription order expired trade_no=%s", referenceId)) return } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { logger.LogError(ctx, common.LogText("Stripe subscription order expiry processing failed trade_no=%s error=%q", referenceId, err.Error())) return } err := model.UpdatePendingTopUpStatus(referenceId, model.PaymentProviderStripe, common.TopUpStatusExpired) if errors.Is(err, model.ErrTopUpNotFound) { logger.LogWarn(ctx, common.LogText("Stripe top-up order not found, cannot mark as expired trade_no=%s", referenceId)) return } if err != nil { logger.LogError(ctx, common.LogText("Stripe top-up order expiry processing failed trade_no=%s error=%q", referenceId, err.Error())) return } logger.LogInfo(ctx, common.LogText("Stripe top-up order expired trade_no=%s", referenceId)) } // genStripeLink generates a Stripe Checkout session URL for payment. // It creates a new checkout session with the specified parameters and returns the payment URL. // // Parameters: // - referenceId: unique reference identifier for the transaction // - customerId: existing Stripe customer ID (empty string if new customer) // - email: customer email address for new customer creation // - amount: quantity of units to purchase // - successURL: custom URL to redirect after successful payment (empty for default) // - cancelURL: custom URL to redirect when payment is canceled (empty for default) // // Returns the checkout session URL or an error if the session creation fails. func genStripeLink(referenceId string, customerId string, email string, amount int64, successURL string, cancelURL string) (string, error) { if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") { return "", errors.New("invalid Stripe API key") } stripe.Key = setting.StripeApiSecret // Use custom URLs if provided, otherwise use defaults if successURL == "" { successURL = paymentReturnPath("/usage-logs") } if cancelURL == "" { cancelURL = paymentReturnPath("/wallet") } params := &stripe.CheckoutSessionParams{ ClientReferenceID: stripe.String(referenceId), SuccessURL: stripe.String(successURL), CancelURL: stripe.String(cancelURL), LineItems: []*stripe.CheckoutSessionLineItemParams{ { Price: stripe.String(setting.StripePriceId), Quantity: stripe.Int64(amount), }, }, Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled), } if "" == customerId { if "" != email { params.CustomerEmail = stripe.String(email) } params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways)) } else { params.Customer = stripe.String(customerId) } result, err := session.New(params) if err != nil { return "", err } return result.URL, nil } func GetChargedAmount(count float64, user model.User) float64 { topUpGroupRatio := common.GetTopupGroupRatio(user.Group) if topUpGroupRatio == 0 { topUpGroupRatio = 1 } return count * topUpGroupRatio } func getStripeCreditedQuota(amount int64, group string) decimal.Decimal { topUpGroupRatio := common.GetTopupGroupRatio(group) if topUpGroupRatio == 0 { topUpGroupRatio = 1 } return decimal.NewFromInt(amount). Mul(decimal.NewFromFloat(topUpGroupRatio)). Mul(decimal.NewFromFloat(common.QuotaPerUnit)) } func getStripePayMoney(amount float64, group string) float64 { originalAmount := amount if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { amount = amount / common.QuotaPerUnit } // Using float64 for monetary calculations is acceptable here due to the small amounts involved topupGroupRatio := common.GetTopupGroupRatio(group) if topupGroupRatio == 0 { topupGroupRatio = 1 } // apply optional preset discount by the original request amount (if configured), default 1.0 discount := 1.0 if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { if ds > 0 { discount = ds } } payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount return payMoney } func getStripeMinTopup() int64 { minTopup := setting.StripeMinTopUp if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { minTopup = minTopup * int(common.QuotaPerUnit) } return int64(minTopup) }