[Feature Request] Waffo Pancake gateway — full integration with subscription support + admin catalog binding flow (#4935)

This commit is contained in:
Hill-waffo 2026-05-22 11:00:58 +08:00 committed by GitHub
parent 8e5e89bb5b
commit 19f1821fc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2437 additions and 1091 deletions

View File

@ -42,15 +42,6 @@ func isPositiveOptionValue(value string) bool {
return err == nil && floatValue > 0
}
func isVisiblePublicKeyOption(key string) bool {
switch key {
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
return true
default:
return false
}
}
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
if strings.TrimSpace(raw) == "" {
return
@ -95,7 +86,7 @@ func GetOptions(c *gin.Context) {
strings.HasSuffix(k, "Key") ||
strings.HasSuffix(k, "secret") ||
strings.HasSuffix(k, "api_key")
if isSensitiveKey && !isVisiblePublicKeyOption(k) {
if isSensitiveKey {
continue
}
options = append(options, &model.Option{

View File

@ -77,24 +77,15 @@ func isWaffoPancakeTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
if !setting.WaffoPancakeEnabled {
return false
}
return isWaffoPancakeWebhookConfigured() &&
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
// Presence-of-credentials = enabled. Webhook public keys ship inside
// the SDK; mode (test/prod) is read from each event.
return strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
}
func isWaffoPancakeWebhookConfigured() bool {
currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
if setting.WaffoPancakeSandbox {
currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
}
return currentWebhookKey != ""
return isWaffoPancakeTopUpEnabled()
}
func isWaffoPancakeWebhookEnabled() bool {

View File

@ -114,47 +114,32 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalEnabled := setting.WaffoPancakeEnabled
originalSandbox := setting.WaffoPancakeSandbox
originalMerchantID := setting.WaffoPancakeMerchantID
originalPrivateKey := setting.WaffoPancakePrivateKey
originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey
originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey
originalStoreID := setting.WaffoPancakeStoreID
originalProductID := setting.WaffoPancakeProductID
t.Cleanup(func() {
setting.WaffoPancakeEnabled = originalEnabled
setting.WaffoPancakeSandbox = originalSandbox
setting.WaffoPancakeMerchantID = originalMerchantID
setting.WaffoPancakePrivateKey = originalPrivateKey
setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey
setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey
setting.WaffoPancakeStoreID = originalStoreID
setting.WaffoPancakeProductID = originalProductID
})
setting.WaffoPancakeEnabled = true
setting.WaffoPancakeSandbox = false
setting.WaffoPancakeMerchantID = "merchant"
// Presence of all three credentials enables the gateway. Webhook public
// keys are bundled in the SDK and there is no separate Enabled toggle —
// clear any of the three fields to disable.
setting.WaffoPancakeMerchantID = ""
setting.WaffoPancakePrivateKey = "private"
setting.WaffoPancakeStoreID = "store"
setting.WaffoPancakeProductID = "product"
setting.WaffoPancakeWebhookPublicKey = ""
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeWebhookPublicKey = "public"
setting.WaffoPancakeMerchantID = "merchant"
require.True(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeEnabled = false
setting.WaffoPancakeProductID = ""
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeEnabled = true
setting.WaffoPancakeSandbox = true
setting.WaffoPancakeWebhookTestKey = ""
setting.WaffoPancakeProductID = "product"
setting.WaffoPancakePrivateKey = ""
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeWebhookTestKey = "test_public"
require.True(t, isWaffoPancakeWebhookEnabled())
}
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {

View File

@ -0,0 +1,125 @@
package controller
import (
"fmt"
"net/http"
"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/service"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/thanhpk/randstr"
)
type SubscriptionWaffoPancakePayRequest struct {
PlanId int `json:"plan_id"`
}
func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
var req SubscriptionWaffoPancakePayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
plan, err := model.GetSubscriptionPlanById(req.PlanId)
if err != nil {
common.ApiError(c, err)
return
}
if !plan.Enabled {
common.ApiErrorMsg(c, "套餐未启用")
return
}
if strings.TrimSpace(plan.WaffoPancakeProductId) == "" {
common.ApiErrorMsg(c, "该套餐未配置 WaffoPancakeProductId")
return
}
// Plan targets its own Pancake product, so we only require credentials
// here — not the gateway-level WaffoPancakeProductID.
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" {
common.ApiErrorMsg(c, "Waffo Pancake 未配置或密钥无效")
return
}
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
if user == nil {
common.ApiErrorMsg(c, "用户不存在")
return
}
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
if err != nil {
common.ApiError(c, err)
return
}
if count >= int64(plan.MaxPurchasePerUser) {
common.ApiErrorMsg(c, "已达到该套餐购买上限")
return
}
}
// WAFFO_PANCAKE_SUB- prefix (vs. wallet's WAFFO_PANCAKE-) drives webhook
// dispatch in WaffoPancakeWebhook.
tradeNo := fmt.Sprintf("WAFFO_PANCAKE_SUB-%d-%d-%s", userId, time.Now().UnixMilli(), randstr.String(6))
order := &model.SubscriptionOrder{
UserId: userId,
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: tradeNo,
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
expiresInSeconds := 45 * 60
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
ProductID: plan.WaffoPancakeProductId,
BuyerIdentity: service.WaffoPancakeBuyerIdentityFromUserID(user.Id),
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
Amount: decimal.NewFromFloat(plan.PriceAmount).StringFixed(2),
TaxCategory: "saas",
},
BuyerEmail: getWaffoPancakeBuyerEmail(user),
ExpiresInSeconds: &expiresInSeconds,
})
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
order.Status = common.TopUpStatusFailed
_ = order.Update()
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f", userId, plan.Id, tradeNo, session.SessionID, plan.PriceAmount))
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": session.CheckoutURL,
"session_id": session.SessionID,
"expires_at": session.ExpiresAt,
"order_id": tradeNo,
"token": session.Token,
"token_expires_at": session.TokenExpiresAt,
},
})
}

View File

@ -52,6 +52,27 @@ func GetTopUpInfo(c *gin.Context) {
}
}
// Waffo Pancake displayed above the legacy Waffo gateway.
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
if enableWaffoPancake {
hasWaffoPancake := false
for _, method := range payMethods {
if method["type"] == model.PaymentMethodWaffoPancake {
hasWaffoPancake = true
break
}
}
if !hasWaffoPancake {
payMethods = append(payMethods, map[string]string{
"name": "Waffo Pancake",
"type": model.PaymentMethodWaffoPancake,
"color": "rgba(var(--semi-orange-5), 1)",
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
})
}
}
// 如果启用了 Waffo 支付,添加到支付方法列表
enableWaffo := isWaffoTopUpEnabled()
if enableWaffo {
@ -74,26 +95,6 @@ func GetTopUpInfo(c *gin.Context) {
}
}
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
if enableWaffoPancake {
hasWaffoPancake := false
for _, method := range payMethods {
if method["type"] == model.PaymentMethodWaffoPancake {
hasWaffoPancake = true
break
}
}
if !hasWaffoPancake {
payMethods = append(payMethods, map[string]string{
"name": "Waffo Pancake",
"type": model.PaymentMethodWaffoPancake,
"color": "rgba(var(--semi-orange-5), 1)",
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
})
}
}
data := gin.H{
"enable_online_topup": isEpayTopUpEnabled(),
"enable_stripe_topup": isStripeTopUpEnabled(),

View File

@ -102,27 +102,254 @@ func getWaffoPancakeBuyerEmail(user *model.User) string {
return ""
}
func getWaffoPancakeReturnURL() string {
if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" {
return setting.WaffoPancakeReturnURL
// The admin config endpoints below accept typed-but-not-yet-saved creds in
// the body and fall back to persisted creds when the body is blank (see
// resolveWaffoPancakeAdminCreds). Only SaveWaffoPancake writes to OptionMap.
type waffoPancakeCredsRequest struct {
MerchantID string `json:"merchant_id"`
PrivateKey string `json:"private_key"`
}
type saveWaffoPancakeRequest struct {
MerchantID string `json:"merchant_id"`
PrivateKey string `json:"private_key"`
ReturnURL string `json:"return_url"`
StoreID string `json:"store_id"`
ProductID string `json:"product_id"`
}
type createWaffoPancakePairRequest struct {
MerchantID string `json:"merchant_id"`
PrivateKey string `json:"private_key"`
ReturnURL string `json:"return_url"`
}
// SaveWaffoPancake atomically persists all five operator-controlled fields.
// Catalog / pair endpoints are transient — only this one writes the OptionMap.
func SaveWaffoPancake(c *gin.Context) {
var req saveWaffoPancakeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
return paymentReturnPath("/console/topup?show_history=true")
if err := service.SaveWaffoPancakeConfig(
c.Request.Context(),
req.MerchantID,
req.PrivateKey,
req.ReturnURL,
req.StoreID,
req.ProductID,
); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q",
req.StoreID, req.ProductID, err.Error(),
))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "保存配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"product_id": setting.WaffoPancakeProductID,
"store_id": setting.WaffoPancakeStoreID,
},
})
}
// resolveWaffoPancakeAdminCreds prefers body creds (typed-but-not-yet-saved
// values, for verification) and falls back to persisted creds when the body
// is blank (so returning admins don't have to re-paste the private key,
// which is stripped from GET /api/option/).
func resolveWaffoPancakeAdminCreds(bodyMerchantID, bodyPrivateKey string) (string, string) {
m := strings.TrimSpace(bodyMerchantID)
k := strings.TrimSpace(bodyPrivateKey)
if m == "" && k == "" {
return setting.WaffoPancakeMerchantID, setting.WaffoPancakePrivateKey
}
return m, k
}
// CreateWaffoPancakePair mints a Store + OnetimeProduct pair in one round-
// trip. Surfaces an orphan-store flag when the product half fails so the
// frontend can preselect / retry without losing context.
func CreateWaffoPancakePair(c *gin.Context) {
var req createWaffoPancakePairRequest
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
}
merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey)
if merchantID == "" || privateKey == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
return
}
result, err := service.CreateWaffoPancakePrimaryPair(
c.Request.Context(), merchantID, privateKey, req.ReturnURL,
)
if err != nil {
orphan := result != nil && result.OrphanStore
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q",
orphan, func() string {
if result == nil {
return ""
}
return result.StoreID
}(), err.Error(),
))
data := gin.H{"error": err.Error()}
if orphan {
data["store_id"] = result.StoreID
data["store_name"] = result.StoreName
data["orphan_store"] = true
}
c.JSON(http.StatusOK, gin.H{"message": "error", "data": data})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"store_id": result.StoreID,
"store_name": result.StoreName,
"product_id": result.ProductID,
"product_name": result.ProductName,
},
})
}
// ListWaffoPancakeCatalog returns the merchant's Stores + OnetimeProducts.
// Doubles as a credential probe (a successful 200 proves the resolved creds
// authenticate). See resolveWaffoPancakeAdminCreds for credential resolution.
func ListWaffoPancakeCatalog(c *gin.Context) {
var req waffoPancakeCredsRequest
// An empty body means "use persisted creds"; only fail on malformed JSON.
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
}
merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey)
if merchantID == "" || privateKey == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
return
}
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 拉取店铺与产品目录失败 error=%q", err.Error(),
))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取目录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": catalog})
}
type createWaffoPancakeSubscriptionProductRequest struct {
Name string `json:"name"`
Amount string `json:"amount"`
}
// CreateWaffoPancakeSubscriptionProduct mints an OnetimeProduct (not
// SubscriptionProduct — see service.CreateWaffoPancakeProductForPlan)
// sized to a plan's `name` + `amount`, using persisted Pancake credentials
// + StoreID. Reads from the form, not the plan row, so newly-typed unsaved
// plans can mint a product too.
func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) {
var req createWaffoPancakeSubscriptionProductRequest
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
}
if strings.TrimSpace(req.Name) == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐名称不能为空"})
return
}
if strings.TrimSpace(req.Amount) == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐价格不能为空"})
return
}
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
if merchantID == "" || privateKey == "" || storeID == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
return
}
productID, err := service.CreateWaffoPancakeProductForPlan(
c.Request.Context(),
merchantID,
privateKey,
storeID,
req.Name,
req.Amount,
setting.WaffoPancakeReturnURL,
)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q",
storeID, req.Name, req.Amount, err.Error(),
))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建套餐产品失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"product_id": productID,
"product_name": req.Name,
"store_id": storeID,
},
})
}
// ListWaffoPancakeSubscriptionProductOptions returns the OnetimeProducts
// in the saved Pancake store, for the subscription-plan dropdown. The name
// reflects new-api's plan concept; under the hood it's still OnetimeProducts.
func ListWaffoPancakeSubscriptionProductOptions(c *gin.Context) {
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
if merchantID == "" || privateKey == "" || storeID == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
return
}
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q", storeID, err.Error(),
))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取产品列表失败"})
return
}
products := []service.WaffoPancakeCatalogProduct{}
for _, store := range catalog.Stores {
if store.ID == storeID {
products = store.OnetimeProducts
break
}
}
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"store_id": storeID,
"products": products,
},
})
}
func getWaffoPancakeBuyerIdentity(user *model.User) string {
if user == nil {
return ""
}
return service.WaffoPancakeBuyerIdentityFromUserID(user.Id)
}
func RequestWaffoPancakePay(c *gin.Context) {
if !setting.WaffoPancakeEnabled {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"})
return
}
currentWebhookKey := setting.WaffoPancakeWebhookPublicKey
if setting.WaffoPancakeSandbox {
currentWebhookKey = setting.WaffoPancakeWebhookTestKey
}
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" ||
strings.TrimSpace(currentWebhookKey) == "" ||
strings.TrimSpace(setting.WaffoPancakeStoreID) == "" ||
strings.TrimSpace(setting.WaffoPancakeProductID) == "" {
if !isWaffoPancakeTopUpEnabled() {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"})
return
}
@ -175,17 +402,13 @@ func RequestWaffoPancakePay(c *gin.Context) {
expiresInSeconds := 45 * 60
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
StoreID: setting.WaffoPancakeStoreID,
ProductID: setting.WaffoPancakeProductID,
ProductType: "onetime",
Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)),
ProductID: setting.WaffoPancakeProductID,
BuyerIdentity: getWaffoPancakeBuyerIdentity(user),
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
Amount: formatWaffoPancakeAmount(payMoney),
TaxIncluded: false,
TaxCategory: "saas",
},
BuyerEmail: getWaffoPancakeBuyerEmail(user),
SuccessURL: getWaffoPancakeReturnURL(),
ExpiresInSeconds: &expiresInSeconds,
})
if err != nil {
@ -200,10 +423,12 @@ func RequestWaffoPancakePay(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": session.CheckoutURL,
"session_id": session.SessionID,
"expires_at": session.ExpiresAt,
"order_id": tradeNo,
"checkout_url": session.CheckoutURL,
"session_id": session.SessionID,
"expires_at": session.ExpiresAt,
"order_id": tradeNo,
"token": session.Token,
"token_expires_at": session.TokenExpiresAt,
},
})
}
@ -215,6 +440,19 @@ func WaffoPancakeWebhook(c *gin.Context) {
return
}
// :env splits test vs prod traffic at the routing layer — operator
// registers each URL in the matching webhook slot in Pancake's dashboard.
// We then enforce event.mode == expectedEnv to catch mis-registrations.
expectedEnv := strings.TrimSpace(c.Param("env"))
if expectedEnv != "test" && expectedEnv != "prod" {
logger.LogWarn(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s",
expectedEnv, c.Request.RequestURI, c.ClientIP(),
))
c.String(http.StatusNotFound, "unknown env")
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
@ -232,15 +470,57 @@ func WaffoPancakeWebhook(c *gin.Context) {
return
}
if !strings.EqualFold(strings.TrimSpace(event.Mode), expectedEnv) {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
expectedEnv, event.Mode, event.ID, event.Data.OrderID, c.ClientIP(),
))
c.String(http.StatusOK, "OK")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
if event.NormalizedEventType() != "order.completed" {
c.String(http.StatusOK, "OK")
return
}
// Subscription vs top-up dispatch by trade_no prefix (written at
// session-creation time): WAFFO_PANCAKE_SUB- vs WAFFO_PANCAKE-.
rawTradeNo := strings.TrimSpace(event.Data.OrderID)
isSubscription := strings.HasPrefix(rawTradeNo, "WAFFO_PANCAKE_SUB-")
if isSubscription {
tradeNo, err := service.ResolveWaffoPancakeSubscriptionTradeNo(event)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
))
c.String(http.StatusOK, "OK")
return
}
LockOrder(tradeNo)
defer UnlockOrder(tradeNo)
if err := model.CompleteSubscriptionOrder(tradeNo, string(bodyBytes), model.PaymentProviderWaffoPancake, ""); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
c.String(http.StatusInternalServerError, "retry")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
c.String(http.StatusOK, "OK")
return
}
tradeNo, err := service.ResolveWaffoPancakeTradeNo(event)
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 订单号映射失败 event_id=%s order_id=%s error=%q", event.ID, event.Data.OrderID, err.Error()))
// LogError (not LogWarn): covers order-not-found and buyer-identity
// mismatch — both warrant human attention. 200 OK so Waffo doesn't
// retry a permanently-unresolvable webhook.
logger.LogError(c.Request.Context(), fmt.Sprintf(
"Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
))
c.String(http.StatusOK, "OK")
return
}

2
go.mod
View File

@ -60,6 +60,8 @@ require (
gorm.io/gorm v1.25.2
)
require github.com/waffo-com/waffo-pancake-sdk-go v0.2.0
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect

4
go.sum
View File

@ -308,6 +308,10 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw=
github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g=
github.com/waffo-com/waffo-pancake-sdk-go v0.1.1 h1:YOI7+3zTBlTB7Ou6+ZXnJV2JvW/ag9d7CwE/TxH3Hls=
github.com/waffo-com/waffo-pancake-sdk-go v0.1.1/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI=
github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 h1:cCSgccM66p7feTtgRqUUGT50tYQOhahsoPXavd+ib1U=
github.com/waffo-com/waffo-pancake-sdk-go v0.2.0/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=

View File

@ -399,6 +399,7 @@ func ensureSubscriptionPlanTableSQLite() error {
` + "`sort_order`" + ` integer DEFAULT 0,
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
` + "`max_purchase_per_user`" + ` integer DEFAULT 0,
` + "`upgrade_group`" + ` varchar(64) DEFAULT '',
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
@ -432,6 +433,7 @@ PRIMARY KEY (` + "`id`" + `)
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
{Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"},
{Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"},
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},

View File

@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/setting/performance_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"gorm.io/gorm"
)
type Option struct {
@ -106,18 +107,13 @@ func InitOptionMap() {
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
common.OptionMap["WaffoPancakeEnabled"] = strconv.FormatBool(setting.WaffoPancakeEnabled)
common.OptionMap["WaffoPancakeSandbox"] = strconv.FormatBool(setting.WaffoPancakeSandbox)
common.OptionMap["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID
common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey
common.OptionMap["WaffoPancakeWebhookPublicKey"] = setting.WaffoPancakeWebhookPublicKey
common.OptionMap["WaffoPancakeWebhookTestKey"] = setting.WaffoPancakeWebhookTestKey
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
common.OptionMap["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL
common.OptionMap["WaffoPancakeCurrency"] = setting.WaffoPancakeCurrency
common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp)
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@ -222,6 +218,39 @@ func UpdateOption(key string, value string) error {
return updateOptionMap(key, value)
}
// UpdateOptionsBulk persists multiple key/value pairs in a single database
// transaction, then dispatches them through updateOptionMap in one pass. If
// any DB write fails the whole transaction rolls back and no in-memory state
// is touched — safe for callers that must commit a set of related options
// atomically (e.g. payment gateway binding).
func UpdateOptionsBulk(values map[string]string) error {
if len(values) == 0 {
return nil
}
err := DB.Transaction(func(tx *gorm.DB) error {
for k, v := range values {
option := Option{Key: k}
if err := tx.FirstOrCreate(&option, Option{Key: k}).Error; err != nil {
return err
}
option.Value = v
if err := tx.Save(&option).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
for k, v := range values {
if err := updateOptionMap(k, v); err != nil {
return err
}
}
return nil
}
func updateOptionMap(key string, value string) (err error) {
common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
@ -419,26 +448,16 @@ func updateOptionMap(key string, value string) (err error) {
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoMinTopUp":
setting.WaffoMinTopUp, _ = strconv.Atoi(value)
case "WaffoPancakeEnabled":
setting.WaffoPancakeEnabled = value == "true"
case "WaffoPancakeSandbox":
setting.WaffoPancakeSandbox = value == "true"
case "WaffoPancakeMerchantID":
setting.WaffoPancakeMerchantID = value
case "WaffoPancakePrivateKey":
setting.WaffoPancakePrivateKey = value
case "WaffoPancakeWebhookPublicKey":
setting.WaffoPancakeWebhookPublicKey = value
case "WaffoPancakeWebhookTestKey":
setting.WaffoPancakeWebhookTestKey = value
case "WaffoPancakeReturnURL":
setting.WaffoPancakeReturnURL = value
case "WaffoPancakeStoreID":
setting.WaffoPancakeStoreID = value
case "WaffoPancakeProductID":
setting.WaffoPancakeProductID = value
case "WaffoPancakeReturnURL":
setting.WaffoPancakeReturnURL = value
case "WaffoPancakeCurrency":
setting.WaffoPancakeCurrency = value
case "WaffoPancakeUnitPrice":
setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoPancakeMinTopUp":

View File

@ -159,8 +159,9 @@ type SubscriptionPlan struct {
Enabled bool `json:"enabled" gorm:"default:true"`
SortOrder int `json:"sort_order" gorm:"type:int;default:0"`
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"`
// Max purchases per user (0 = unlimited)
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`

View File

@ -56,7 +56,9 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
// :env separates test vs prod URLs so the operator can register each
// in Pancake's matching webhook slot; handler enforces env match.
apiRouter.POST("/waffo-pancake/webhook/:env", controller.WaffoPancakeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
@ -100,8 +102,8 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay)
selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount)
selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay)
//selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount)
//selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount)
selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting)
@ -154,6 +156,7 @@ func SetApiRouter(router *gin.Engine) {
subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay)
subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay)
subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay)
subscriptionRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestWaffoPancakePay)
}
subscriptionAdminRoute := apiRouter.Group("/subscription/admin")
subscriptionAdminRoute.Use(middleware.AdminAuth())
@ -186,6 +189,11 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
optionRoute.POST("/waffo-pancake/catalog", controller.ListWaffoPancakeCatalog)
optionRoute.POST("/waffo-pancake/pair", controller.CreateWaffoPancakePair)
optionRoute.POST("/waffo-pancake/save", controller.SaveWaffoPancake)
optionRoute.POST("/waffo-pancake/subscription-product", controller.CreateWaffoPancakeSubscriptionProduct)
optionRoute.POST("/waffo-pancake/subscription-product-options", controller.ListWaffoPancakeSubscriptionProductOptions)
}
// Custom OAuth provider management (root only)

View File

@ -1,398 +1,472 @@
package service
import (
"bytes"
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
pancake "github.com/waffo-com/waffo-pancake-sdk-go"
)
const (
waffoPancakeAuthBaseURL = "https://waffo-pancake-auth-service.vercel.app"
waffoPancakeCheckoutPath = "/v1/actions/checkout/create-session"
waffoPancakeDefaultTolerance = 5 * time.Minute
)
// WaffoPancakePriceSnapshot is the per-session price override sent with checkout.
type WaffoPancakePriceSnapshot struct {
Amount string `json:"amount"`
TaxIncluded bool `json:"taxIncluded"`
TaxCategory string `json:"taxCategory"`
Amount string
TaxCategory string
}
// WaffoPancakeCreateSessionParams is the input to CreateWaffoPancakeCheckoutSession.
// BuyerIdentity (merchant-controlled, stable per user) is what survives the
// buyer editing email at checkout — see WaffoPancakeBuyerIdentityFromUserID.
type WaffoPancakeCreateSessionParams struct {
StoreID string `json:"storeId"`
ProductID string `json:"productId"`
ProductType string `json:"productType"`
Currency string `json:"currency"`
PriceSnapshot *WaffoPancakePriceSnapshot `json:"priceSnapshot,omitempty"`
BuyerEmail string `json:"buyerEmail,omitempty"`
SuccessURL string `json:"successUrl,omitempty"`
ExpiresInSeconds *int `json:"expiresInSeconds,omitempty"`
ProductID string
BuyerIdentity string
PriceSnapshot *WaffoPancakePriceSnapshot
BuyerEmail string
ExpiresInSeconds *int
}
// WaffoPancakeCheckoutSession is the response of CreateWaffoPancakeCheckoutSession.
// CheckoutURL already carries the `#token=...` fragment; Token / TokenExpiresAt
// are exposed separately for self-service flows driven from new-api's own UI.
type WaffoPancakeCheckoutSession struct {
SessionID string `json:"sessionId"`
CheckoutURL string `json:"checkoutUrl"`
ExpiresAt string `json:"expiresAt"`
OrderID string `json:"orderId"`
SessionID string
CheckoutURL string
ExpiresAt string
OrderID string
Token string
TokenExpiresAt string
}
type waffoPancakeAPIError struct {
Message string `json:"message"`
Layer string `json:"layer"`
// WaffoPancakeWebhookEvent mirrors the SDK's WebhookEvent shape using plain
// strings so controllers don't have to import the SDK package.
type WaffoPancakeWebhookEvent struct {
ID string
Timestamp string
EventType string
EventID string
StoreID string
Mode string
Data WaffoPancakeWebhookData
}
type waffoPancakeCreateSessionResponse struct {
Data *WaffoPancakeCheckoutSession `json:"data"`
Errors []waffoPancakeAPIError `json:"errors"`
type WaffoPancakeWebhookData struct {
OrderID string
BuyerEmail string
Currency string
Amount string
TaxAmount string
ProductName string
MerchantProvidedBuyerIdentity string
}
type waffoPancakeWebhookData struct {
ID string `json:"id"`
OrderID string `json:"orderId"`
BuyerEmail string `json:"buyerEmail"`
Currency string `json:"currency"`
Amount dto.StringValue `json:"amount"`
TaxAmount dto.StringValue `json:"taxAmount"`
ProductName string `json:"productName"`
}
type waffoPancakeWebhookEvent struct {
ID string `json:"id"`
Timestamp string `json:"timestamp"`
EventType string `json:"eventType"`
EventID string `json:"eventId"`
StoreID string `json:"storeId"`
Mode string `json:"mode"`
Data waffoPancakeWebhookData `json:"data"`
}
func (e *waffoPancakeWebhookEvent) NormalizedEventType() string {
// NormalizedEventType returns the event type or empty string for a nil event.
func (e *WaffoPancakeWebhookEvent) NormalizedEventType() string {
if e == nil {
return ""
}
return e.EventType
}
// newWaffoPancakeClient builds an SDK client from persisted settings. The
// runtime checkout / webhook paths use this; configuration endpoints use
// newWaffoPancakeClientFromCreds so the operator can verify typed-but-not-
// yet-saved credentials.
func newWaffoPancakeClient() (*pancake.Client, error) {
return pancake.New(pancake.Config{
MerchantID: setting.WaffoPancakeMerchantID,
PrivateKey: setting.WaffoPancakePrivateKey,
})
}
func newWaffoPancakeClientFromCreds(merchantID, privateKey string) (*pancake.Client, error) {
if strings.TrimSpace(merchantID) == "" || strings.TrimSpace(privateKey) == "" {
return nil, fmt.Errorf("merchant id and private key are required")
}
return pancake.New(pancake.Config{
MerchantID: merchantID,
PrivateKey: privateKey,
})
}
// CreateWaffoPancakeCheckoutSession creates an Authenticated-mode checkout
// session: the order is bound to BuyerIdentity (stable per user) so it stays
// attributable even if the buyer edits the email on Waffo's checkout form.
func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancakeCreateSessionParams) (*WaffoPancakeCheckoutSession, error) {
if params == nil {
return nil, fmt.Errorf("missing checkout params")
}
body, err := common.Marshal(params)
if strings.TrimSpace(params.BuyerIdentity) == "" {
return nil, fmt.Errorf("missing buyer identity")
}
client, err := newWaffoPancakeClient()
if err != nil {
return nil, fmt.Errorf("marshal Waffo Pancake checkout payload: %w", err)
return nil, fmt.Errorf("build Waffo Pancake client: %w", err)
}
privateKey, err := normalizeRSAPrivateKey(setting.WaffoPancakePrivateKey)
if err != nil {
return nil, err
sdkParams := pancake.AuthenticatedCheckoutParams{
CreateCheckoutSessionParams: pancake.CreateCheckoutSessionParams{
ProductID: params.ProductID,
Currency: "USD",
BuyerEmail: optionalString(params.BuyerEmail),
ExpiresInSeconds: params.ExpiresInSeconds,
},
BuyerIdentity: params.BuyerIdentity,
}
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
signature, err := signWaffoPancakeRequest(http.MethodPost, waffoPancakeCheckoutPath, timestamp, string(body), privateKey)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, waffoPancakeAuthBaseURL+waffoPancakeCheckoutPath, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build Waffo Pancake checkout request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Merchant-Id", setting.WaffoPancakeMerchantID)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Signature", signature)
if setting.WaffoPancakeSandbox {
req.Header.Set("X-Environment", "test")
} else {
req.Header.Set("X-Environment", "prod")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request Waffo Pancake checkout session: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read Waffo Pancake checkout response: %w", err)
}
var result waffoPancakeCreateSessionResponse
if err := common.Unmarshal(responseBody, &result); err != nil {
return nil, fmt.Errorf("decode Waffo Pancake checkout response: %w", err)
}
if resp.StatusCode >= http.StatusBadRequest {
if len(result.Errors) > 0 {
return nil, fmt.Errorf("Waffo Pancake error (%d): %s", resp.StatusCode, result.Errors[0].Message)
if params.PriceSnapshot != nil {
sdkParams.PriceSnapshot = &pancake.PriceInfo{
Amount: params.PriceSnapshot.Amount,
TaxCategory: pancake.TaxCategory(params.PriceSnapshot.TaxCategory),
}
return nil, fmt.Errorf("Waffo Pancake checkout request failed with status %d", resp.StatusCode)
}
if len(result.Errors) > 0 {
return nil, fmt.Errorf("Waffo Pancake error: %s", result.Errors[0].Message)
session, err := client.Checkout.Authenticated.Create(ctx, sdkParams)
if err != nil {
return nil, err
}
if result.Data == nil || result.Data.CheckoutURL == "" || strings.TrimSpace(result.Data.SessionID) == "" {
if session == nil || strings.TrimSpace(session.CheckoutURL) == "" || strings.TrimSpace(session.SessionID) == "" {
return nil, fmt.Errorf("Waffo Pancake returned empty checkout session")
}
return result.Data, nil
return &WaffoPancakeCheckoutSession{
SessionID: session.SessionID,
CheckoutURL: session.CheckoutURL,
ExpiresAt: session.ExpiresAt,
Token: session.Token,
TokenExpiresAt: session.TokenExpiresAt,
}, nil
}
func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*waffoPancakeWebhookEvent, error) {
environment := resolveWaffoPancakeWebhookEnvironment(payload)
return verifyWaffoPancakeWebhook(payload, signatureHeader, environment)
func optionalString(s string) *string {
if strings.TrimSpace(s) == "" {
return nil
}
v := s
return &v
}
func ResolveWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) (string, error) {
// WaffoPancakeBuyerIdentityFromUserID renders the canonical buyer identity
// for checkout. Webhook handlers compare against the value rendered here to
// reject identity mismatches, so both call sites must use this function.
func WaffoPancakeBuyerIdentityFromUserID(userID int) string {
return fmt.Sprintf("new-api-user-%d", userID)
}
// VerifyConfiguredWaffoPancakeWebhook verifies the signature header. The SDK
// picks the matching test / prod public key from the payload's `mode` field.
func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*WaffoPancakeWebhookEvent, error) {
evt, err := pancake.VerifyWebhookTyped[pancake.WebhookEventData](payload, signatureHeader, nil)
if err != nil {
return nil, err
}
identity := ""
if evt.Data.MerchantProvidedBuyerIdentity != nil {
identity = *evt.Data.MerchantProvidedBuyerIdentity
}
return &WaffoPancakeWebhookEvent{
ID: evt.ID,
Timestamp: evt.Timestamp,
EventType: evt.EventType,
EventID: evt.EventID,
StoreID: evt.StoreID,
Mode: string(evt.Mode),
Data: WaffoPancakeWebhookData{
OrderID: evt.Data.OrderID,
BuyerEmail: evt.Data.BuyerEmail,
Currency: evt.Data.Currency,
Amount: evt.Data.Amount,
TaxAmount: evt.Data.TaxAmount,
ProductName: evt.Data.ProductName,
MerchantProvidedBuyerIdentity: identity,
},
}, nil
}
// ResolveWaffoPancakeTradeNo maps a verified webhook event to a local TopUp
// trade_no, rejecting any payload whose buyer identity doesn't match the one
// we recorded at checkout — defence-in-depth on top of signature verification.
func ResolveWaffoPancakeTradeNo(event *WaffoPancakeWebhookEvent) (string, error) {
if event == nil {
return "", fmt.Errorf("missing webhook event")
}
if tradeNo := strings.TrimSpace(event.Data.OrderID); tradeNo != "" {
topUp := model.GetTopUpByTradeNo(tradeNo)
if topUp != nil && topUp.PaymentMethod == model.PaymentMethodWaffoPancake {
return tradeNo, nil
}
tradeNo := strings.TrimSpace(event.Data.OrderID)
if tradeNo == "" {
return "", fmt.Errorf("missing webhook orderId")
}
topUp := model.GetTopUpByTradeNo(tradeNo)
if topUp == nil || topUp.PaymentProvider != model.PaymentProviderWaffoPancake {
return "", fmt.Errorf("waffo pancake order not found for webhook orderId=%s", tradeNo)
}
return "", fmt.Errorf("missing webhook orderId")
}
func normalizeRSAPrivateKey(raw string) (string, error) {
return normalizePEMKey(raw, "PRIVATE KEY", "RSA PRIVATE KEY")
}
func normalizeRSAPublicKey(raw string) (string, error) {
return normalizePEMKey(raw, "PUBLIC KEY", "RSA PUBLIC KEY")
}
func normalizePEMKey(raw string, pkcs8Type string, pkcs1Type string) (string, error) {
if strings.TrimSpace(raw) == "" {
return "", fmt.Errorf("%s is empty", strings.ToLower(pkcs8Type))
expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(topUp.UserId)
actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity)
if actualIdentity != expectedIdentity {
return "", fmt.Errorf(
"waffo pancake buyer identity mismatch for tradeNo=%s: expected=%q actual=%q",
tradeNo,
expectedIdentity,
actualIdentity,
)
}
return tradeNo, nil
}
normalized := strings.TrimSpace(strings.ReplaceAll(raw, `\n`, "\n"))
if strings.Contains(normalized, "BEGIN ") {
block, _ := pem.Decode([]byte(normalized))
if block == nil {
return "", fmt.Errorf("invalid PEM encoded %s", strings.ToLower(pkcs8Type))
}
return string(pem.EncodeToMemory(block)), nil
// ResolveWaffoPancakeSubscriptionTradeNo is the SubscriptionOrder counterpart
// of ResolveWaffoPancakeTradeNo.
func ResolveWaffoPancakeSubscriptionTradeNo(event *WaffoPancakeWebhookEvent) (string, error) {
if event == nil {
return "", fmt.Errorf("missing webhook event")
}
tradeNo := strings.TrimSpace(event.Data.OrderID)
if tradeNo == "" {
return "", fmt.Errorf("missing webhook orderId")
}
order := model.GetSubscriptionOrderByTradeNo(tradeNo)
if order == nil || order.PaymentProvider != model.PaymentProviderWaffoPancake {
return "", fmt.Errorf("waffo pancake subscription order not found for webhook orderId=%s", tradeNo)
}
expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(order.UserId)
actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity)
if actualIdentity != expectedIdentity {
return "", fmt.Errorf(
"waffo pancake buyer identity mismatch for subscription tradeNo=%s: expected=%q actual=%q",
tradeNo,
expectedIdentity,
actualIdentity,
)
}
return tradeNo, nil
}
der, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(normalized, "\n", ""))
// Deterministic default names for "+ Create": stable bodies mean stable
// X-Idempotency-Key, which lets Pancake dedupe retries server-side.
const (
defaultWaffoPancakeStoreName = "new-api-store"
defaultWaffoPancakeProductName = "new-api-charge-product"
)
// CreateWaffoPancakePrimaryStore creates a Pancake Store using in-flight
// (not-yet-persisted) credentials and returns the new store ID.
func CreateWaffoPancakePrimaryStore(ctx context.Context, merchantID, privateKey string) (string, error) {
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
if err != nil {
return "", fmt.Errorf("invalid base64 encoded %s: %w", strings.ToLower(pkcs8Type), err)
return "", err
}
pemType := pkcs8Type
if pkcs8Type == "PRIVATE KEY" {
if _, err := x509.ParsePKCS8PrivateKey(der); err != nil {
if _, err := x509.ParsePKCS1PrivateKey(der); err == nil {
pemType = pkcs1Type
} else {
return "", fmt.Errorf("invalid RSA private key")
}
}
} else {
if _, err := x509.ParsePKIXPublicKey(der); err != nil {
if _, err := x509.ParsePKCS1PublicKey(der); err == nil {
pemType = pkcs1Type
} else {
return "", fmt.Errorf("invalid RSA public key")
}
}
}
return string(pem.EncodeToMemory(&pem.Block{Type: pemType, Bytes: der})), nil
}
func signWaffoPancakeRequest(method string, path string, timestamp string, body string, privateKeyPEM string) (string, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("invalid RSA private key PEM")
}
var privateKey *rsa.PrivateKey
switch block.Type {
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("parse PKCS#8 private key: %w", err)
}
parsed, ok := key.(*rsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not RSA")
}
privateKey = parsed
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("parse PKCS#1 private key: %w", err)
}
privateKey = key
default:
return "", fmt.Errorf("unsupported private key type: %s", block.Type)
}
canonicalRequest := buildWaffoPancakeCanonicalRequest(method, path, timestamp, body)
digest := sha256.Sum256([]byte(canonicalRequest))
signature, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, digest[:])
storeRes, err := client.Stores.Create(ctx, pancake.CreateStoreParams{
Name: defaultWaffoPancakeStoreName,
})
if err != nil {
return "", fmt.Errorf("sign Waffo Pancake request: %w", err)
return "", fmt.Errorf("create Waffo Pancake store: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
return storeRes.Store.ID, nil
}
func buildWaffoPancakeCanonicalRequest(method string, path string, timestamp string, body string) string {
bodyHash := sha256.Sum256([]byte(body))
return fmt.Sprintf(
"%s\n%s\n%s\n%s",
strings.ToUpper(method),
path,
timestamp,
base64.StdEncoding.EncodeToString(bodyHash[:]),
)
}
func verifyWaffoPancakeWebhook(payload string, signatureHeader string, environment string) (*waffoPancakeWebhookEvent, error) {
if signatureHeader == "" {
return nil, fmt.Errorf("missing X-Waffo-Signature header")
// CreateWaffoPancakeProductForPlan mints (and publishes) a Pancake
// OnetimeProduct priced at `amount` USD, used as a subscription plan's
// SubscriptionPlan.WaffoPancakeProductId.
//
// OnetimeProduct (not SubscriptionProduct) because new-api has no renewal-
// event handling; Pancake auto-renewing without new-api extending user
// access would be a UX divergence. Revisit if renewal handling is added.
func CreateWaffoPancakeProductForPlan(ctx context.Context, merchantID, privateKey, storeID, name, amount, returnURL string) (string, error) {
storeID = strings.TrimSpace(storeID)
if storeID == "" {
return "", fmt.Errorf("store id is required to create a product")
}
timestampPart, signaturePart := parseWaffoPancakeSignatureHeader(signatureHeader)
if timestampPart == "" || signaturePart == "" {
return nil, fmt.Errorf("malformed X-Waffo-Signature header")
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("plan name is required")
}
timestampMs, err := strconv.ParseInt(timestampPart, 10, 64)
amount = strings.TrimSpace(amount)
if amount == "" {
return "", fmt.Errorf("plan price is required")
}
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
if err != nil {
return nil, fmt.Errorf("invalid timestamp in X-Waffo-Signature header")
return "", err
}
if math.Abs(float64(time.Now().UnixMilli()-timestampMs)) > float64(waffoPancakeDefaultTolerance.Milliseconds()) {
return nil, fmt.Errorf("webhook timestamp outside tolerance window")
}
signatureInput := fmt.Sprintf("%s.%s", timestampPart, payload)
if err := verifyWaffoPancakeWebhookWithKey(signatureInput, signaturePart, resolveWaffoPancakeWebhookPublicKey(environment)); err != nil {
return nil, fmt.Errorf("invalid webhook signature")
}
var event waffoPancakeWebhookEvent
if err := common.Unmarshal([]byte(payload), &event); err != nil {
return nil, fmt.Errorf("parse Waffo Pancake webhook payload: %w", err)
}
return &event, nil
}
func parseWaffoPancakeSignatureHeader(header string) (string, string) {
var timestampPart string
var signaturePart string
for _, pair := range strings.Split(header, ",") {
key, value, found := strings.Cut(strings.TrimSpace(pair), "=")
if !found {
continue
}
switch key {
case "t":
timestampPart = value
case "v1":
signaturePart = value
}
}
return timestampPart, signaturePart
}
func resolveWaffoPancakeWebhookEnvironment(payload string) string {
var envelope struct {
Mode string `json:"mode"`
}
if err := common.Unmarshal([]byte(payload), &envelope); err != nil {
if setting.WaffoPancakeSandbox {
return "test"
}
return "prod"
}
switch strings.ToLower(strings.TrimSpace(envelope.Mode)) {
case "test":
return "test"
case "prod":
return "prod"
default:
if setting.WaffoPancakeSandbox {
return "test"
}
return "prod"
}
}
func resolveWaffoPancakeWebhookPublicKey(environment string) string {
if environment == "prod" {
return strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
}
return strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
}
func verifyWaffoPancakeWebhookWithKey(signatureInput string, signaturePart string, rawPublicKey string) error {
publicKeyPEM, err := normalizeRSAPublicKey(rawPublicKey)
prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{
StoreID: storeID,
Name: name,
Prices: pancake.Prices{
"USD": {
Amount: amount,
TaxCategory: pancake.TaxCategory("saas"),
},
},
SuccessURL: optionalString(strings.TrimSpace(returnURL)),
})
if err != nil {
return err
return "", fmt.Errorf("create Waffo Pancake plan product: %w", err)
}
block, _ := pem.Decode([]byte(publicKeyPEM))
if block == nil {
return fmt.Errorf("invalid RSA public key PEM")
productID := prodRes.Product.ID
if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil {
return "", fmt.Errorf("publish Waffo Pancake plan product: %w", err)
}
return productID, nil
}
var publicKey *rsa.PublicKey
switch block.Type {
case "PUBLIC KEY":
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("parse PKIX public key: %w", err)
}
parsed, ok := key.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("public key is not RSA")
}
publicKey = parsed
case "RSA PUBLIC KEY":
key, err := x509.ParsePKCS1PublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("parse PKCS#1 public key: %w", err)
}
publicKey = key
default:
return fmt.Errorf("unsupported public key type: %s", block.Type)
// CreateWaffoPancakePrimaryProduct mints (and publishes) the wallet-top-up
// OnetimeProduct under storeID. Per-checkout price overrides via PriceSnapshot
// are what make the "1.00" seed price irrelevant at runtime.
func CreateWaffoPancakePrimaryProduct(ctx context.Context, merchantID, privateKey, storeID, returnURL string) (string, error) {
storeID = strings.TrimSpace(storeID)
if storeID == "" {
return "", fmt.Errorf("store id is required to create a product")
}
signature, err := base64.StdEncoding.DecodeString(signaturePart)
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
if err != nil {
return fmt.Errorf("decode webhook signature: %w", err)
return "", err
}
prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{
StoreID: storeID,
Name: defaultWaffoPancakeProductName,
Prices: pancake.Prices{
"USD": {
Amount: "1.00", // overridden at checkout via PriceSnapshot
TaxCategory: pancake.TaxCategory("saas"),
},
},
SuccessURL: optionalString(strings.TrimSpace(returnURL)),
})
if err != nil {
return "", fmt.Errorf("create Waffo Pancake product: %w", err)
}
productID := prodRes.Product.ID
if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil {
return "", fmt.Errorf("publish Waffo Pancake product: %w", err)
}
return productID, nil
}
digest := sha256.Sum256([]byte(signatureInput))
if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature); err != nil {
return fmt.Errorf("verify webhook signature: %w", err)
// WaffoPancakePairResult is the response of CreateWaffoPancakePrimaryPair.
// When OrphanStore is true the store was created but the product wasn't,
// so the caller can surface a partial-failure message with StoreID.
type WaffoPancakePairResult struct {
StoreID string
StoreName string
ProductID string
ProductName string
OrphanStore bool
}
// CreateWaffoPancakePrimaryPair mints a Store + OnetimeProduct in one
// round-trip — the canonical "+ Create" entry point. Nothing is persisted
// to settings; the operator's final Save commits the chosen IDs.
func CreateWaffoPancakePrimaryPair(ctx context.Context, merchantID, privateKey, returnURL string) (*WaffoPancakePairResult, error) {
storeID, err := CreateWaffoPancakePrimaryStore(ctx, merchantID, privateKey)
if err != nil {
return nil, err
}
productID, err := CreateWaffoPancakePrimaryProduct(ctx, merchantID, privateKey, storeID, returnURL)
if err != nil {
return &WaffoPancakePairResult{
StoreID: storeID,
StoreName: defaultWaffoPancakeStoreName,
OrphanStore: true,
}, fmt.Errorf("store created at %s but product creation failed: %w", storeID, err)
}
return &WaffoPancakePairResult{
StoreID: storeID,
StoreName: defaultWaffoPancakeStoreName,
ProductID: productID,
ProductName: defaultWaffoPancakeProductName,
}, nil
}
// SaveWaffoPancakeConfig persists the operator-controlled fields atomically
// at the end of the configuration flow via model.UpdateOptionsBulk (single
// DB transaction). A blank privateKey is treated as "keep current"
// (Stripe-style API-secret UX) and is omitted from the bulk payload.
func SaveWaffoPancakeConfig(ctx context.Context, merchantID, privateKey, returnURL, storeID, productID string) error {
merchantID = strings.TrimSpace(merchantID)
storeID = strings.TrimSpace(storeID)
productID = strings.TrimSpace(productID)
if merchantID == "" || storeID == "" || productID == "" {
return fmt.Errorf("merchant id, store id, and product id are required to save")
}
values := map[string]string{
"WaffoPancakeMerchantID": merchantID,
"WaffoPancakeReturnURL": strings.TrimSpace(returnURL),
"WaffoPancakeStoreID": storeID,
"WaffoPancakeProductID": productID,
}
if pk := strings.TrimSpace(privateKey); pk != "" {
values["WaffoPancakePrivateKey"] = pk
}
if err := model.UpdateOptionsBulk(values); err != nil {
return fmt.Errorf("persist Waffo Pancake config: %w", err)
}
return nil
}
type WaffoPancakeCatalogProduct struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
}
// WaffoPancakeCatalogStore nests its OnetimeProducts so the UI can render a
// dependent store→product select without a second round-trip.
type WaffoPancakeCatalogStore struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
ProdEnabled bool `json:"prodEnabled"`
OnetimeProducts []WaffoPancakeCatalogProduct `json:"onetimeProducts"`
}
type WaffoPancakeCatalog struct {
Stores []WaffoPancakeCatalogStore `json:"stores"`
}
// ListWaffoPancakeCatalog queries Pancake's GraphQL `stores` for the
// merchant's stores + onetime products. A successful call also proves
// the supplied credentials authenticate (doubles as a credential probe).
func ListWaffoPancakeCatalog(ctx context.Context, merchantID, privateKey string) (*WaffoPancakeCatalog, error) {
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
if err != nil {
return nil, err
}
type queryShape struct {
Stores []WaffoPancakeCatalogStore `json:"stores"`
}
// `limit: 100` because the API returns a single store when limit is
// omitted, even for multi-store merchants. Bump to paginated fetches
// (via `offset`) if real catalogs ever cross the cap.
resp, err := pancake.GraphQLQuery[queryShape](ctx, client, pancake.GraphQLParams{
Query: `query {
stores(limit: 100) {
id
name
status
prodEnabled
onetimeProducts {
id
name
status
}
}
}`,
})
if err != nil {
return nil, fmt.Errorf("query Waffo Pancake catalog: %w", err)
}
if len(resp.Errors) > 0 {
return nil, fmt.Errorf("waffo pancake catalog query returned %d errors: %s",
len(resp.Errors), resp.Errors[0].Message)
}
// Drop non-active products. Operators should only see items they can
// actually bind without later hitting "product unavailable" at checkout.
stores := resp.Data.Stores
for i := range stores {
active := stores[i].OnetimeProducts[:0]
for _, p := range stores[i].OnetimeProducts {
if strings.EqualFold(strings.TrimSpace(p.Status), "active") {
active = append(active, p)
}
}
stores[i].OnetimeProducts = active
}
return &WaffoPancakeCatalog{Stores: stores}, nil
}

View File

@ -8,7 +8,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
@ -29,7 +28,7 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
model.DB = db
model.LOG_DB = db
require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}))
require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}, &model.SubscriptionOrder{}))
t.Cleanup(func() {
sqlDB, err := db.DB()
@ -41,21 +40,6 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
return db
}
func TestWaffoPancakeCreateSessionResponseParsesDocumentedPayload(t *testing.T) {
var result waffoPancakeCreateSessionResponse
err := common.Unmarshal([]byte(`{
"data": {
"sessionId": "cs_550e8400-e29b-41d4-a716-446655440000",
"checkoutUrl": "https://checkout.waffo.ai/my-store-abc123/checkout/cs_550e8400-e29b-41d4-a716-446655440000",
"expiresAt": "2026-01-22T10:30:00.000Z"
}
}`), &result)
require.NoError(t, err)
require.NotNil(t, result.Data)
require.Equal(t, "cs_550e8400-e29b-41d4-a716-446655440000", result.Data.SessionID)
require.Empty(t, result.Data.OrderID)
}
func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
@ -64,21 +48,79 @@ func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *te
Amount: 10,
Money: 29,
TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(topUp).Error)
tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{
Data: waffoPancakeWebhookData{
OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
Data: WaffoPancakeWebhookData{
OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(topUp.UserId),
},
})
require.NoError(t, err)
require.Equal(t, "ORD_5dXBtmF2HLlHfbPNm0Wcnz", tradeNo)
}
func TestResolveWaffoPancakeTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
topUp := &model.TopUp{
UserId: 42,
Amount: 10,
Money: 29,
TradeNo: "ORD_identity_mismatch_case",
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(topUp).Error)
// Webhook reports the right order but a different buyer — could be a
// crossed-wires bug or a tampered payload. Either way: reject.
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
Data: WaffoPancakeWebhookData{
OrderID: "ORD_identity_mismatch_case",
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user
},
})
require.Error(t, err)
require.Empty(t, tradeNo)
require.Contains(t, err.Error(), "buyer identity mismatch")
}
func TestResolveWaffoPancakeTradeNo_RejectsMissingBuyerIdentity(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
topUp := &model.TopUp{
UserId: 7,
Amount: 10,
Money: 29,
TradeNo: "ORD_missing_identity",
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(topUp).Error)
// An empty MerchantProvidedBuyerIdentity means the order was either created
// via the (now-deprecated) anonymous flow or the field was stripped — also
// reject so that we never credit anonymous orders to a specific user.
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
Data: WaffoPancakeWebhookData{
OrderID: "ORD_missing_identity",
},
})
require.Error(t, err)
require.Empty(t, tradeNo)
require.Contains(t, err.Error(), "buyer identity mismatch")
}
func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
@ -95,14 +137,15 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.
Amount: 10,
Money: 29,
TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(topUp).Error)
tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{
Data: waffoPancakeWebhookData{
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
Data: WaffoPancakeWebhookData{
OrderID: "ORD_unknown",
BuyerEmail: user.Email,
Amount: "29.00",
@ -112,46 +155,107 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.
require.Empty(t, tradeNo)
}
func TestResolveWaffoPancakeWebhookEnvironment(t *testing.T) {
originalSandbox := setting.WaffoPancakeSandbox
t.Cleanup(func() {
setting.WaffoPancakeSandbox = originalSandbox
// Parity tests for ResolveWaffoPancakeSubscriptionTradeNo — same four cases
// as the TopUp resolver above, exercised against SubscriptionOrder records.
// Drift between the two webhook flows is a real risk because they share
// the same buyer-identity defence-in-depth pattern.
func TestResolveWaffoPancakeSubscriptionTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
order := &model.SubscriptionOrder{
UserId: 1,
PlanId: 5,
Money: 29,
TradeNo: "WAFFO_PANCAKE_SUB-1-1700000000-abc123",
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(order).Error)
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
Data: WaffoPancakeWebhookData{
OrderID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123",
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(order.UserId),
},
})
testCases := []struct {
name string
payload string
expected string
sandbox bool
}{
{
name: "test mode",
payload: `{"mode":"test"}`,
expected: "test",
},
{
name: "prod mode",
payload: `{"mode":"prod"}`,
expected: "prod",
},
{
name: "missing mode falls back to sandbox",
payload: `{}`,
expected: "test",
sandbox: true,
},
{
name: "invalid mode falls back to prod",
payload: `{"mode":"staging"}`,
expected: "prod",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
setting.WaffoPancakeSandbox = tc.sandbox
environment := resolveWaffoPancakeWebhookEnvironment(tc.payload)
require.Equal(t, tc.expected, environment)
})
}
require.NoError(t, err)
require.Equal(t, "WAFFO_PANCAKE_SUB-1-1700000000-abc123", tradeNo)
}
func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
order := &model.SubscriptionOrder{
UserId: 42,
PlanId: 5,
Money: 29,
TradeNo: "WAFFO_PANCAKE_SUB-42-mismatch",
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(order).Error)
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
Data: WaffoPancakeWebhookData{
OrderID: "WAFFO_PANCAKE_SUB-42-mismatch",
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user
},
})
require.Error(t, err)
require.Empty(t, tradeNo)
require.Contains(t, err.Error(), "buyer identity mismatch")
}
func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsMissingBuyerIdentity(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
order := &model.SubscriptionOrder{
UserId: 7,
PlanId: 5,
Money: 29,
TradeNo: "WAFFO_PANCAKE_SUB-7-missing-identity",
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(order).Error)
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
Data: WaffoPancakeWebhookData{
OrderID: "WAFFO_PANCAKE_SUB-7-missing-identity",
},
})
require.Error(t, err)
require.Empty(t, tradeNo)
require.Contains(t, err.Error(), "buyer identity mismatch")
}
func TestResolveWaffoPancakeSubscriptionTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
order := &model.SubscriptionOrder{
UserId: 42,
PlanId: 5,
Money: 29,
TradeNo: "WAFFO_PANCAKE_SUB-42-real-order",
PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(order).Error)
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
Data: WaffoPancakeWebhookData{
OrderID: "WAFFO_PANCAKE_SUB-unknown",
},
})
require.Error(t, err)
require.Empty(t, tradeNo)
}

View File

@ -1,16 +1,15 @@
package setting
// Waffo Pancake hosted checkout configuration. Gateway is enabled once
// MerchantID + PrivateKey + ProductID are populated (no separate Enabled
// flag, matching Stripe / Creem). StoreID + ProductID are operator-bound
// via SaveWaffoPancakeConfig.
var (
WaffoPancakeEnabled bool
WaffoPancakeSandbox bool
WaffoPancakeMerchantID string
WaffoPancakePrivateKey string
WaffoPancakeWebhookPublicKey string
WaffoPancakeWebhookTestKey string
WaffoPancakeStoreID string
WaffoPancakeProductID string
WaffoPancakeReturnURL string
WaffoPancakeCurrency string = "USD"
WaffoPancakeUnitPrice float64 = 1.0
WaffoPancakeMinTopUp int = 1
WaffoPancakeMerchantID string
WaffoPancakePrivateKey string
WaffoPancakeReturnURL string
WaffoPancakeUnitPrice float64 = 1.0
WaffoPancakeMinTopUp int = 1
WaffoPancakeStoreID string
WaffoPancakeProductID string
)

View File

@ -0,0 +1,5 @@
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8965 17.8787L11.2995 12.6132L10.1344 8.7762C9.97497 8.25193 9.4909 7.89355 8.94204 7.89355H4.41838C3.89937 7.89355 3.52838 8.39249 3.67767 8.88637L6.0675 16.7643C6.37941 17.7907 7.32849 18.4928 8.40398 18.4928H12.4398C12.7599 18.4922 12.9893 18.1845 12.8965 17.8787ZM7.47396 10.6301C7.11059 10.7302 6.71038 10.4345 6.58079 9.96909C6.4512 9.50371 6.64177 9.04403 7.00514 8.94399C7.36851 8.84395 7.76745 9.13964 7.89641 9.60502C8.026 10.0717 7.83733 10.5301 7.47396 10.6301Z" fill="white"/>
<path d="M13.0281 18.269C12.8777 18.4077 12.6794 18.4926 12.4646 18.4927H12.4382C12.7588 18.4926 12.9887 18.1847 12.8962 17.8784L11.2996 12.6128L11.3054 12.5923L13.0281 18.269ZM14.5144 13.771V13.7729L13.2615 17.9028C13.2401 17.973 13.2071 18.0369 13.1697 18.0972L11.4021 12.271L12.4626 8.77588C12.6221 8.25169 13.1063 7.89317 13.655 7.89307H16.2976L14.5144 13.771Z" fill="white"/>
<path d="M19.5133 18.4932H16.8707C16.3221 18.493 15.8378 18.135 15.6783 17.6104L14.61 14.0859L16.3883 8.19336L17.7311 12.6133L19.1617 7.89355H22.7291L19.5133 18.4932Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,5 @@
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8967 17.8789L11.2996 12.6135L10.1346 8.77644C9.97511 8.25218 9.49104 7.8938 8.94218 7.8938H4.4185C3.8995 7.8938 3.5285 8.39274 3.67779 8.88662L6.06763 16.7646C6.37954 17.7909 7.32862 18.4931 8.40411 18.4931H12.4399C12.7601 18.4925 12.9894 18.1848 12.8967 17.8789ZM7.47409 10.6304C7.11073 10.7304 6.71051 10.4347 6.58092 9.96934C6.45133 9.50396 6.64191 9.04428 7.00527 8.94423C7.36864 8.84419 7.76758 9.13989 7.89654 9.60527C8.02613 10.0719 7.83746 10.5303 7.47409 10.6304Z" fill="black"/>
<path d="M13.0278 18.2703C12.8774 18.4086 12.679 18.4929 12.4643 18.4929H12.4379C12.7587 18.4929 12.9886 18.1851 12.8959 17.8787L11.2993 12.613L11.3051 12.5925L13.0278 18.2703ZM16.2973 7.89331L14.5151 13.7712V13.7732L13.2612 17.9031C13.2397 17.9736 13.207 18.0379 13.1694 18.0984L11.4018 12.2712L12.4633 8.77612C12.6228 8.25186 13.1068 7.89331 13.6557 7.89331H16.2973Z" fill="black"/>
<path d="M22.7283 7.89478L19.5134 18.4934H16.8718C16.323 18.4934 15.8389 18.1355 15.6794 17.6106L14.6101 14.0862L16.3884 8.19458L17.7312 12.6145L19.1619 7.89478H22.7283Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -53,16 +53,9 @@ const PaymentSetting = () => {
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
'payment_setting.compliance_confirmed': false,
'payment_setting.compliance_terms_version': '',
'payment_setting.compliance_confirmed_at': 0,
@ -171,21 +164,13 @@ const PaymentSetting = () => {
case 'MinTopUp':
case 'StripeUnitPrice':
case 'StripeMinTopUp':
case 'WaffoPancakeUnitPrice':
case 'WaffoPancakeMinTopUp':
newInputs[item.key] = parseFloat(item.value);
break;
case 'WaffoPancakeMerchantID':
case 'WaffoPancakePrivateKey':
case 'WaffoPancakeStoreID':
case 'WaffoPancakeProductID':
case 'WaffoPancakeReturnURL':
case 'WaffoPancakeCurrency':
newInputs[item.key] = item.value;
break;
case 'WaffoPancakeSandbox':
newInputs[item.key] = toBoolean(item.value);
break;
default:
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = toBoolean(item.value);
@ -320,6 +305,13 @@ const PaymentSetting = () => {
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>
<SettingsPaymentGatewayWaffoPancake
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
<SettingsPaymentGatewayWaffo
options={inputs}
@ -327,13 +319,6 @@ const PaymentSetting = () => {
hideSectionTitle
/>
</Tabs.TabPane>
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
{/* <SettingsPaymentGatewayWaffoPancake*/}
{/* options={inputs}*/}
{/* refresh={onRefresh}*/}
{/* hideSectionTitle*/}
{/* />*/}
{/*</Tabs.TabPane>*/}
</Tabs>
</div>
</Card>

View File

@ -47,6 +47,7 @@ import {
} from 'lucide-react';
import { IconGift } from '@douyinfe/semi-icons';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { useActualTheme } from '../../context/Theme';
import { getCurrencyConfig } from '../../helpers/render';
import SubscriptionPlansCard from './SubscriptionPlansCard';
@ -102,6 +103,7 @@ const RechargeCard = ({
const redeemFormApiRef = useRef(null);
const initialTabSetRef = useRef(false);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
const actualTheme = useActualTheme();
const [activeTab, setActiveTab] = useState('topup');
const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0;
@ -355,9 +357,18 @@ const RechargeCard = ({
}}
/>
) : payMethod.type === 'waffo_pancake' ? (
<CreditCard
size={18}
color='var(--semi-color-primary)'
<img
src={
actualTheme === 'dark'
? '/waffo-logo-dark.svg'
: '/waffo-logo-light.svg'
}
alt='Waffo'
style={{
width: 18,
height: 18,
objectFit: 'contain',
}}
/>
) : (
<CreditCard

View File

@ -40,6 +40,23 @@ import TransferModal from './modals/TransferModal';
import PaymentConfirmModal from './modals/PaymentConfirmModal';
import TopupHistoryModal from './modals/TopupHistoryModal';
// Reject non-navigable schemes (e.g. javascript:, data:) and relative URLs.
// Only http / https are allowed for backend-provided redirect targets.
// Mirrors isSafeHttpCheckoutUrl in the default frontend's
// features/wallet/hooks/use-waffo-pancake-payment.ts.
function isSafeHttpCheckoutUrl(value) {
const trimmed = (value || '').trim();
if (!trimmed) {
return false;
}
try {
const u = new URL(trimmed);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
const TopUp = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
@ -454,8 +471,12 @@ const TopUp = () => {
const { message, data } = res.data;
if (message === 'success') {
const checkoutUrl = data?.checkout_url || '';
if (checkoutUrl) {
window.open(checkoutUrl, '_blank');
if (checkoutUrl && isSafeHttpCheckoutUrl(checkoutUrl)) {
// In-tab redirect (not window.open) popup blocker fires after
// the await loses user-gesture context.
window.location.href = checkoutUrl;
} else if (checkoutUrl) {
showError(t('支付跳转地址不安全'));
} else {
showError(t('支付请求失败'));
}

View File

@ -1720,6 +1720,7 @@
"支付渠道": "Payment Channels",
"支付设置": "Payment",
"支付请求失败": "Payment request failed",
"支付跳转地址不安全": "Unsafe payment redirect URL",
"支付返回地址": "Return URL",
"支付金额": "Payment Amount",
"支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images",

View File

@ -1154,6 +1154,7 @@
"支付方式": "支付方式",
"支付设置": "支付设置",
"支付请求失败": "支付请求失败",
"支付跳转地址不安全": "支付跳转地址不安全",
"支付金额": "支付金额",
"支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片",
"支持6位TOTP验证码或8位备用码可到`个人设置-安全设置-两步验证设置`配置或查看。": "支持6位TOTP验证码或8位备用码可到`个人设置-安全设置-两步验证设置`配置或查看。",

View File

@ -26,25 +26,14 @@ import {
showSuccess,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { BookOpen, TriangleAlert } from 'lucide-react';
import { BookOpen } from 'lucide-react';
const defaultInputs = {
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeWebhookPublicKey: '',
WaffoPancakeWebhookTestKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
};
const toBoolean = (value) => value === true || value === 'true';
export default function SettingsPaymentGatewayWaffoPancake(props) {
const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle
@ -58,26 +47,9 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
if (!props.options || !formApiRef.current) return;
const currentInputs = {
WaffoPancakeEnabled: toBoolean(props.options.WaffoPancakeEnabled),
WaffoPancakeSandbox: toBoolean(props.options.WaffoPancakeSandbox),
WaffoPancakeMerchantID: props.options.WaffoPancakeMerchantID || '',
WaffoPancakePrivateKey: props.options.WaffoPancakePrivateKey || '',
WaffoPancakeWebhookPublicKey:
props.options.WaffoPancakeWebhookPublicKey || '',
WaffoPancakeWebhookTestKey:
props.options.WaffoPancakeWebhookTestKey || '',
WaffoPancakeStoreID: props.options.WaffoPancakeStoreID || '',
WaffoPancakeProductID: props.options.WaffoPancakeProductID || '',
WaffoPancakeReturnURL: props.options.WaffoPancakeReturnURL || '',
WaffoPancakeCurrency: props.options.WaffoPancakeCurrency || 'USD',
WaffoPancakeUnitPrice:
props.options.WaffoPancakeUnitPrice !== undefined
? parseFloat(props.options.WaffoPancakeUnitPrice)
: 1.0,
WaffoPancakeMinTopUp:
props.options.WaffoPancakeMinTopUp !== undefined
? parseFloat(props.options.WaffoPancakeMinTopUp)
: 1,
};
setInputs(currentInputs);
@ -93,90 +65,23 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
...inputs,
...(formApiRef.current?.getValues?.() || {}),
};
values.WaffoPancakeEnabled = toBoolean(values.WaffoPancakeEnabled);
values.WaffoPancakeSandbox = toBoolean(values.WaffoPancakeSandbox);
const currentWebhookField = values.WaffoPancakeSandbox
? 'WaffoPancakeWebhookTestKey'
: 'WaffoPancakeWebhookPublicKey';
const currentWebhookLabel = values.WaffoPancakeSandbox
? t('Webhook 公钥(测试环境)')
: t('Webhook 公钥(生产环境)');
if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) {
showError(t('请输入商户 ID'));
return;
}
if (values.WaffoPancakeEnabled && !values.WaffoPancakeStoreID.trim()) {
showError(t('请输入 Store ID'));
return;
}
if (values.WaffoPancakeEnabled && !values.WaffoPancakeProductID.trim()) {
showError(t('请输入 Product ID'));
return;
}
if (
values.WaffoPancakeEnabled &&
!String(values[currentWebhookField] || '').trim()
) {
showError(currentWebhookLabel);
return;
}
if (
values.WaffoPancakeEnabled &&
Number(values.WaffoPancakeUnitPrice) <= 0
) {
showError(t('充值价格必须大于 0'));
return;
}
if (values.WaffoPancakeEnabled && Number(values.WaffoPancakeMinTopUp) < 1) {
showError(t('最低充值美元数量必须大于 0'));
return;
}
setLoading(true);
try {
// Classic admin only persists the three operator-typed fields.
// Store/Product binding is handled exclusively by the default
// frontend's catalog flow (see waffo-pancake-settings-section.tsx)
// because picking entities from a live catalog needs the Select +
// dependent-dropdown UX that the classic Semi-UI page doesn't have.
const options = [
{
key: 'WaffoPancakeEnabled',
value: values.WaffoPancakeEnabled ? 'true' : 'false',
},
{
key: 'WaffoPancakeSandbox',
value: values.WaffoPancakeSandbox ? 'true' : 'false',
},
{
key: 'WaffoPancakeMerchantID',
value: values.WaffoPancakeMerchantID || '',
},
{
key: 'WaffoPancakeStoreID',
value: values.WaffoPancakeStoreID || '',
},
{
key: 'WaffoPancakeProductID',
value: values.WaffoPancakeProductID || '',
},
{
key: 'WaffoPancakeReturnURL',
value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''),
},
{
key: 'WaffoPancakeCurrency',
value: values.WaffoPancakeCurrency || 'USD',
},
{
key: 'WaffoPancakeUnitPrice',
value: String(values.WaffoPancakeUnitPrice),
},
{
key: 'WaffoPancakeMinTopUp',
value: String(values.WaffoPancakeMinTopUp),
},
];
if ((values.WaffoPancakePrivateKey || '').trim()) {
@ -186,20 +91,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
});
}
if ((values.WaffoPancakeWebhookPublicKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookPublicKey',
value: values.WaffoPancakeWebhookPublicKey,
});
}
if ((values.WaffoPancakeWebhookTestKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookTestKey',
value: values.WaffoPancakeWebhookTestKey,
});
}
const results = await Promise.all(
options.map((opt) =>
API.put('/api/option/', {
@ -237,103 +128,43 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
icon={<BookOpen size={16} />}
description={
<>
Waffo Pancake 的商户商品和签名密钥请
Waffo Pancake 商户 ID 与私钥请在
<a
href='https://docs.waffo.ai'
href='https://pancake.waffo.ai/merchant/dashboard'
target='_blank'
rel='noreferrer'
>
点击此处
Waffo Pancake 控制台
</a>
获取建议先在测试环境完成联调
获取保存后系统会自动在该商户名下创建 Store + Product无需手动配置
环境test / 生产由你粘贴的 API 私钥本身决定
请在 Pancake 控制台把下面两个回调地址分别注册到 Test Mode Production Mode
两个 webhook 位置分开走避免测试流量污染生产数据
<br />
{t('回调地址')}
{t('Test 回调地址')}
{props.options.ServerAddress
? removeTrailingSlash(props.options.ServerAddress)
: t('网站地址')}
/api/waffo-pancake/webhook
/api/waffo-pancake/webhook/test
<br />
{t('Production 回调地址')}
{props.options.ServerAddress
? removeTrailingSlash(props.options.ServerAddress)
: t('网站地址')}
/api/waffo-pancake/webhook/prod
</>
}
style={{ marginBottom: 12 }}
/>
<Banner
type='warning'
icon={<TriangleAlert size={16} />}
description={t(
'请确认 Merchant、Store、Product 和所选环境密钥一致。',
)}
style={{ marginBottom: 16 }}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoPancakeEnabled'
label={t('启用 Waffo Pancake')}
checkedText=''
uncheckedText=''
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoPancakeSandbox'
label={t('沙盒模式')}
checkedText=''
uncheckedText=''
extraText={t('用于切换当前下单和回调校验所使用的环境')}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeCurrency'
label={t('货币')}
placeholder='USD'
extraText={t('默认使用 USD 结算')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeMerchantID'
label={t('商户 ID')}
placeholder={t('例如MER_xxx')}
extraText={t('请填写当前环境对应的商户 ID')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeStoreID'
label={t('Store ID')}
placeholder={t('例如STO_xxx')}
extraText={t('请填写当前环境对应的 Store ID')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeProductID'
label={t('Product ID')}
placeholder={t('例如PROD_xxx')}
extraText={t('请填写当前环境对应的 Product ID')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoPancakePrivateKey'
label={t('API 私钥')}
placeholder={t('填写后覆盖当前私钥,留空表示保持当前不变')}
extraText={t('保存后不会回显,请填写当前环境对应的 API 私钥')}
type='password'
autosize={{ minRows: 4, maxRows: 8 }}
<Form.Input
field='WaffoPancakeMerchantID'
label={t('商户 ID')}
placeholder={t('例如MER_xxx')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
@ -341,7 +172,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
field='WaffoPancakeReturnURL'
label={t('支付返回地址')}
placeholder={t('例如https://example.com/console/topup')}
extraText={t('留空则自动使用当前站点的默认充值页地址')}
/>
</Col>
</Row>
@ -350,55 +180,16 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Col xs={24}>
<Form.TextArea
field='WaffoPancakeWebhookPublicKey'
label={t('Webhook 公钥(生产环境)')}
placeholder={t(
'填写后覆盖当前生产环境 Webhook 公钥,留空表示保持当前不变',
)}
extraText={t('用于校验生产环境的 Waffo Pancake Webhook 签名')}
field='WaffoPancakePrivateKey'
label={t('API 私钥')}
placeholder={t('填写后覆盖当前私钥,留空表示保持当前不变')}
extraText={t('⚠ 测试 / 生产环境由你粘进来的 API 私钥本身决定——集成阶段用 Test Key正式上线时再换成 Production Key')}
type='password'
autosize={{ minRows: 4, maxRows: 8 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoPancakeWebhookTestKey'
label={t('Webhook 公钥(测试环境)')}
placeholder={t(
'填写后覆盖当前测试环境 Webhook 公钥,留空表示保持当前不变',
)}
extraText={t('用于校验测试环境的 Waffo Pancake Webhook 签名')}
type='password'
autosize={{ minRows: 4, maxRows: 8 }}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoPancakeUnitPrice'
precision={2}
label={t('充值价格x元/美金)')}
placeholder={t('例如7就是7元/美金')}
extraText={t('按 1 美元对应的站内价格填写')}
min={0}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoPancakeMinTopUp'
label={t('最低充值美元数量')}
placeholder={t('例如2就是最低充值2$')}
extraText={t('用户单次最少可充值的美元数量')}
min={1}
/>
</Col>
</Row>
<Button onClick={submitWaffoPancakeSetting}>

View File

@ -0,0 +1,5 @@
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8965 17.8787L11.2995 12.6132L10.1344 8.7762C9.97497 8.25193 9.4909 7.89355 8.94204 7.89355H4.41838C3.89937 7.89355 3.52838 8.39249 3.67767 8.88637L6.0675 16.7643C6.37941 17.7907 7.32849 18.4928 8.40398 18.4928H12.4398C12.7599 18.4922 12.9893 18.1845 12.8965 17.8787ZM7.47396 10.6301C7.11059 10.7302 6.71038 10.4345 6.58079 9.96909C6.4512 9.50371 6.64177 9.04403 7.00514 8.94399C7.36851 8.84395 7.76745 9.13964 7.89641 9.60502C8.026 10.0717 7.83733 10.5301 7.47396 10.6301Z" fill="white"/>
<path d="M13.0281 18.269C12.8777 18.4077 12.6794 18.4926 12.4646 18.4927H12.4382C12.7588 18.4926 12.9887 18.1847 12.8962 17.8784L11.2996 12.6128L11.3054 12.5923L13.0281 18.269ZM14.5144 13.771V13.7729L13.2615 17.9028C13.2401 17.973 13.2071 18.0369 13.1697 18.0972L11.4021 12.271L12.4626 8.77588C12.6221 8.25169 13.1063 7.89317 13.655 7.89307H16.2976L14.5144 13.771Z" fill="white"/>
<path d="M19.5133 18.4932H16.8707C16.3221 18.493 15.8378 18.135 15.6783 17.6104L14.61 14.0859L16.3883 8.19336L17.7311 12.6133L19.1617 7.89355H22.7291L19.5133 18.4932Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,5 @@
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8967 17.8789L11.2996 12.6135L10.1346 8.77644C9.97511 8.25218 9.49104 7.8938 8.94218 7.8938H4.4185C3.8995 7.8938 3.5285 8.39274 3.67779 8.88662L6.06763 16.7646C6.37954 17.7909 7.32862 18.4931 8.40411 18.4931H12.4399C12.7601 18.4925 12.9894 18.1848 12.8967 17.8789ZM7.47409 10.6304C7.11073 10.7304 6.71051 10.4347 6.58092 9.96934C6.45133 9.50396 6.64191 9.04428 7.00527 8.94423C7.36864 8.84419 7.76758 9.13989 7.89654 9.60527C8.02613 10.0719 7.83746 10.5303 7.47409 10.6304Z" fill="black"/>
<path d="M13.0278 18.2703C12.8774 18.4086 12.679 18.4929 12.4643 18.4929H12.4379C12.7587 18.4929 12.9886 18.1851 12.8959 17.8787L11.2993 12.613L11.3051 12.5925L13.0278 18.2703ZM16.2973 7.89331L14.5151 13.7712V13.7732L13.2612 17.9031C13.2397 17.9736 13.207 18.0379 13.1694 18.0984L11.4018 12.2712L12.4633 8.77612C12.6228 8.25186 13.1068 7.89331 13.6557 7.89331H16.2973Z" fill="black"/>
<path d="M22.7283 7.89478L19.5134 18.4934H16.8718C16.323 18.4934 15.8389 18.1355 15.6794 17.6106L14.6101 14.0862L16.3884 8.19458L17.7312 12.6145L19.1619 7.89478H22.7283Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -16,11 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMemo } from 'react'
import { Fragment, useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useSystemConfig } from '@/hooks/use-system-config'
import { useStatus } from '@/hooks/use-status'
interface FooterLink {
text: string
@ -74,23 +75,75 @@ function FooterLinkItem(props: { link: FooterLink }) {
)
}
function ProjectAttribution(props: { currentYear: number }) {
// Renders User Agreement / Privacy Policy links inline with the parent's
// copyright row when either is configured in System Settings → Site. Emits
// fragmented siblings so the parent flex container's gap controls spacing.
function LegalLinks(props: { leadingSeparator?: boolean }) {
const { t } = useTranslation()
const { status } = useStatus()
const items: { key: string; label: string; href: string }[] = []
if (status?.user_agreement_enabled) {
items.push({
key: 'user-agreement',
label: t('User Agreement'),
href: '/user-agreement',
})
}
if (status?.privacy_policy_enabled) {
items.push({
key: 'privacy-policy',
label: t('Privacy Policy'),
href: '/privacy-policy',
})
}
if (items.length === 0) {
return null
}
return (
<>
{items.map((item, index) => (
<Fragment key={item.key}>
{(props.leadingSeparator || index > 0) && (
<span aria-hidden='true' className='text-muted-foreground/30'>
·
</span>
)}
<Link
to={item.href}
className='hover:text-foreground transition-colors duration-200'
>
{item.label}
</Link>
</Fragment>
))}
</>
)
}
// inline=true returns just the inner span for composition in a parent flex
// row. inline=false wraps in a centered/right-aligned div (default).
function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
const { t } = useTranslation()
const content = (
<span className='text-muted-foreground/45'>
&copy; {props.currentYear}{' '}
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
>
{t('New API')}
</a>
. {t(NEW_API_FOOTER_ATTRIBUTION_KEY)}
</span>
)
if (props.inline) {
return content
}
return (
<div className='text-muted-foreground/45 text-center text-xs sm:text-right'>
<span className='text-muted-foreground/45'>
&copy; {props.currentYear}{' '}
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
>
{t('New API')}
</a>
. {t(NEW_API_FOOTER_ATTRIBUTION_KEY)}
</span>
{content}
</div>
)
}
@ -182,8 +235,9 @@ export function Footer(props: FooterProps) {
className='custom-footer text-muted-foreground min-w-0 text-center text-sm sm:text-left'
dangerouslySetInnerHTML={{ __html: footerHtml }}
/>
<div className='border-border/60 w-full border-t pt-4 sm:w-auto sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
<ProjectAttribution currentYear={currentYear} />
<div className='border-border/60 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-muted-foreground/45 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
<LegalLinks />
<ProjectAttribution currentYear={currentYear} inline />
</div>
</div>
</div>
@ -235,12 +289,16 @@ export function Footer(props: FooterProps) {
)}
</div>
{/* Bottom section */}
<div className='border-border/30 mt-12 flex flex-col items-center justify-between gap-3 border-t pt-6 sm:flex-row'>
<p className='text-muted-foreground/40 text-xs'>
&copy; {currentYear} {displayName}.{' '}
{props.copyright ?? t('footer.defaultCopyright')}
</p>
{/* Copyright + optional legal links inline on the left, project
attribution on the right; wraps on narrow screens. */}
<div className='border-border/30 mt-12 flex flex-col items-center justify-between gap-x-3 gap-y-2 border-t pt-6 sm:flex-row'>
<div className='text-muted-foreground/40 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs sm:justify-start'>
<span>
&copy; {currentYear} {displayName}.{' '}
{props.copyright ?? t('footer.defaultCopyright')}
</span>
<LegalLinks leadingSeparator />
</div>
<ProjectAttribution currentYear={currentYear} />
</div>
</div>

View File

@ -122,6 +122,42 @@ export async function paySubscriptionCreem(
return res.data
}
export async function paySubscriptionWaffoPancake(
data: SubscriptionPayRequest
): Promise<SubscriptionPayResponse> {
const res = await api.post('/api/subscription/waffo-pancake/pay', data)
return res.data
}
// Mints a Pancake OnetimeProduct (see controller for the OnetimeProduct vs
// SubscriptionProduct rationale) using persisted creds + StoreID.
export async function createWaffoPancakeSubscriptionProduct(data: {
name: string
amount: string
}): Promise<
ApiResponse<{ product_id: string; product_name: string; store_id: string }>
> {
const res = await api.post(
'/api/option/waffo-pancake/subscription-product',
data
)
return res.data
}
// Returns the OnetimeProducts in the saved Pancake store; empty when the
// gateway isn't fully configured.
export async function listWaffoPancakeSubscriptionProductOptions(): Promise<
ApiResponse<{
store_id: string
products: { id: string; name: string; status: string }[]
}>
> {
const res = await api.post(
'/api/option/waffo-pancake/subscription-product-options'
)
return res.data
}
export async function paySubscriptionEpay(
data: SubscriptionPayRequest & { payment_method: string }
): Promise<SubscriptionPayResponse & { url?: string }> {

View File

@ -42,6 +42,7 @@ import {
paySubscriptionStripe,
paySubscriptionCreem,
paySubscriptionEpay,
paySubscriptionWaffoPancake,
} from '../../api'
import { formatDuration, formatResetPeriod } from '../../lib'
import type { PlanRecord } from '../../types'
@ -57,6 +58,7 @@ interface Props {
plan: PlanRecord | null
enableStripe?: boolean
enableCreem?: boolean
enableWaffoPancake?: boolean
enableOnlineTopUp?: boolean
epayMethods?: PaymentMethod[]
purchaseLimit?: number
@ -81,9 +83,11 @@ export function SubscriptionPurchaseDialog(props: Props) {
const hasStripe = props.enableStripe && !!plan.stripe_price_id
const hasCreem = props.enableCreem && !!plan.creem_product_id
const hasWaffoPancake =
props.enableWaffoPancake && !!plan.waffo_pancake_product_id
const hasEpay =
props.enableOnlineTopUp && (props.epayMethods || []).length > 0
const hasAnyPayment = hasStripe || hasCreem || hasEpay
const hasAnyPayment = hasStripe || hasCreem || hasWaffoPancake || hasEpay
const selectedEpayMethodLabel =
(props.epayMethods || []).find((m) => m.type === selectedEpayMethod)
?.name ||
@ -139,6 +143,29 @@ export function SubscriptionPurchaseDialog(props: Props) {
}
}
// In-tab redirect (not window.open) — user-gesture context is lost
// across the await, so a popup would be blocked. Same as the wallet hook.
const handlePayWaffoPancake = async () => {
setPaying(true)
try {
const res = await paySubscriptionWaffoPancake({ plan_id: plan.id })
if (res.message === 'success' && res.data?.checkout_url) {
toast.success(t('Redirecting to payment page...'))
window.location.href = res.data.checkout_url
} else {
toast.error(
res.message && res.message !== 'success'
? res.message
: t('Payment request failed')
)
}
} catch {
toast.error(t('Payment request failed'))
} finally {
setPaying(false)
}
}
const isSafari =
typeof navigator !== 'undefined' &&
/^((?!chrome|android).)*safari/i.test(navigator.userAgent)
@ -262,7 +289,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
<p className='text-muted-foreground text-xs'>
{t('Select payment method')}
</p>
{(hasStripe || hasCreem) && (
{(hasStripe || hasCreem || hasWaffoPancake) && (
<div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && (
<Button
@ -284,6 +311,16 @@ export function SubscriptionPurchaseDialog(props: Props) {
Creem
</Button>
)}
{hasWaffoPancake && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayWaffoPancake}
disabled={paying || limitReached}
>
Waffo Pancake
</Button>
)}
</div>
)}
{hasEpay && (

View File

@ -162,6 +162,13 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{plan.creem_product_id && (
<StatusBadge label='Creem' variant='neutral' copyable={false} />
)}
{plan.waffo_pancake_product_id && (
<StatusBadge
label='Waffo Pancake'
variant='neutral'
copyable={false}
/>
)}
</div>
)
},

View File

@ -51,7 +51,13 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { createPlan, updatePlan, getGroups } from '../api'
import {
createPlan,
updatePlan,
getGroups,
createWaffoPancakeSubscriptionProduct,
listWaffoPancakeSubscriptionProductOptions,
} from '../api'
import { getDurationUnitOptions, getResetPeriodOptions } from '../constants'
import {
getPlanFormSchema,
@ -79,6 +85,10 @@ export function SubscriptionsMutateDrawer({
const { triggerRefresh } = useSubscriptions()
const [isSubmitting, setIsSubmitting] = useState(false)
const [groupOptions, setGroupOptions] = useState<string[]>([])
const [creatingPancakeProduct, setCreatingPancakeProduct] = useState(false)
const [pancakeProducts, setPancakeProducts] = useState<
{ id: string; name: string; status: string }[]
>([])
const schema = getPlanFormSchema(t)
const form = useForm<PlanFormValues>({
@ -98,11 +108,35 @@ export function SubscriptionsMutateDrawer({
if (res.success) setGroupOptions(res.data || [])
})
.catch(() => {})
// Best-effort — empty list still lets the operator use "+ Create".
listWaffoPancakeSubscriptionProductOptions()
.then((res) => {
if (
res.message === 'success' &&
typeof res.data === 'object' &&
res.data &&
Array.isArray((res.data as { products?: unknown }).products)
) {
setPancakeProducts(
(res.data as { products: typeof pancakeProducts }).products
)
} else {
setPancakeProducts([])
}
})
.catch(() => setPancakeProducts([]))
}
}, [open, currentRow, form])
const durationUnit = form.watch('duration_unit')
const resetPeriod = form.watch('quota_reset_period')
// Gate "+ Create on Pancake" on the same checks the mint handler runs.
const watchedTitle = form.watch('title')
const watchedPrice = form.watch('price_amount')
const pancakeCreateReady =
typeof watchedTitle === 'string' &&
watchedTitle.trim().length > 0 &&
Number(watchedPrice ?? 0) > 0
const onSubmit = async (values: PlanFormValues) => {
setIsSubmitting(true)
@ -130,6 +164,72 @@ export function SubscriptionsMutateDrawer({
}
}
// Mints a Pancake OnetimeProduct (not SubscriptionProduct — see
// controller) using persisted creds + the form's title/price, then
// pins the returned PROD_ ID into the form field.
const handleCreatePancakeProduct = async () => {
const title = form.getValues('title').trim()
const priceAmount = Number(form.getValues('price_amount') || 0)
if (!title) {
toast.error(t('Plan title is required'))
return
}
if (priceAmount <= 0) {
toast.error(t('Plan price must be greater than zero'))
return
}
setCreatingPancakeProduct(true)
try {
const res = await createWaffoPancakeSubscriptionProduct({
name: title,
amount: priceAmount.toFixed(2),
})
if (
res.message === 'success' &&
typeof res.data === 'object' &&
res.data
) {
const created = res.data as { product_id: string; product_name: string }
form.setValue('waffo_pancake_product_id', created.product_id, {
shouldDirty: true,
})
// Refetch from GraphQL so the dropdown reflects authoritative state.
try {
const refresh = await listWaffoPancakeSubscriptionProductOptions()
if (
refresh.message === 'success' &&
typeof refresh.data === 'object' &&
refresh.data &&
Array.isArray((refresh.data as { products?: unknown }).products)
) {
setPancakeProducts(
(refresh.data as { products: typeof pancakeProducts }).products
)
}
} catch {
// Best-effort — form value already points at the new product;
// raw-ID fallback covers the missing label.
}
toast.success(
`${t('Waffo Pancake product created')}: ${created.product_id}`
)
} else {
const reason = typeof res.data === 'string' ? res.data : undefined
toast.error(
reason
? `${t('Waffo Pancake product creation failed')}: ${reason}`
: t('Waffo Pancake product creation failed')
)
}
} catch (err) {
toast.error(
`${t('Waffo Pancake product creation failed')}: ${err instanceof Error ? err.message : String(err)}`
)
} finally {
setCreatingPancakeProduct(false)
}
}
const durationUnitOpts = getDurationUnitOptions(t)
const resetPeriodOpts = getResetPeriodOptions(t)
@ -546,6 +646,67 @@ export function SubscriptionsMutateDrawer({
</FormItem>
)}
/>
<FormField
control={form.control}
name='waffo_pancake_product_id'
render={({ field }) => {
// Raw-ID fallback for IDs not yet in the catalog.
const items = pancakeProducts.map((p) => ({
value: p.id,
label: `${p.name} (${p.id})`,
}))
if (
field.value &&
!pancakeProducts.some((p) => p.id === field.value)
) {
items.push({ value: field.value, label: field.value })
}
return (
<FormItem>
<FormLabel>Waffo Pancake Product ID</FormLabel>
<div className='flex gap-2'>
<Select
items={items}
value={field.value || ''}
onValueChange={(v) => field.onChange(v)}
disabled={items.length === 0}
>
<SelectTrigger className='w-full flex-1'>
<SelectValue
placeholder={t('Select a product')}
/>
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
onClick={handleCreatePancakeProduct}
disabled={creatingPancakeProduct || !pancakeCreateReady}
className='shrink-0'
>
{creatingPancakeProduct
? t('Creating...')
: `+ ${t('Create')}`}
</Button>
</div>
<FormDescription>
{t(
'Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)
}}
/>
</div>
</form>
</Form>

View File

@ -43,6 +43,7 @@ export function getPlanFormSchema(t: TFunction) {
upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
creem_product_id: z.string().optional(),
waffo_pancake_product_id: z.string().optional(),
})
}
@ -64,6 +65,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
upgrade_group: '',
stripe_price_id: '',
creem_product_id: '',
waffo_pancake_product_id: '',
}
export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
@ -83,6 +85,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
upgrade_group: plan.upgrade_group || '',
stripe_price_id: plan.stripe_price_id || '',
creem_product_id: plan.creem_product_id || '',
waffo_pancake_product_id: plan.waffo_pancake_product_id || '',
}
}

View File

@ -40,6 +40,7 @@ export const subscriptionPlanSchema = z.object({
upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
creem_product_id: z.string().optional(),
waffo_pancake_product_id: z.string().optional(),
})
export type SubscriptionPlan = z.infer<typeof subscriptionPlanSchema>
@ -94,8 +95,17 @@ export interface SubscriptionPayResponse {
success: boolean
message?: string
data?: {
// Stripe-style hosted checkout link.
pay_link?: string
// Waffo Pancake / Creem hosted checkout URL.
checkout_url?: string
// Pancake-only: order metadata + self-service buyer session token,
// surfaced for future flows (refund / cancel from new-api's own UI).
session_id?: string
expires_at?: number | string
order_id?: string
token?: string
token_expires_at?: number | string
}
url?: string
}

View File

@ -96,18 +96,11 @@ const defaultBillingSettings: BillingSettings = {
WaffoNotifyUrl: '',
WaffoReturnUrl: '',
WaffoPayMethods: '[]',
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeWebhookPublicKey: '',
WaffoPancakeWebhookTestKey: '',
WaffoPancakeReturnURL: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1,
WaffoPancakeMinTopUp: 1,
'checkin_setting.enabled': false,
'checkin_setting.min_quota': 1000,
'checkin_setting.max_quota': 10000,

View File

@ -177,20 +177,12 @@ const BILLING_SECTIONS = [
WaffoPayMethods: settings.WaffoPayMethods ?? '[]',
}}
waffoPancakeDefaultValues={{
WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false,
WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false,
WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '',
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
WaffoPancakeWebhookPublicKey:
settings.WaffoPancakeWebhookPublicKey ?? '',
WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '',
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD',
WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1,
WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1,
}}
waffoPancakeProvisionedStoreID={settings.WaffoPancakeStoreID ?? ''}
waffoPancakeProvisionedProductID={settings.WaffoPancakeProductID ?? ''}
complianceDefaults={{
confirmed: settings['payment_setting.compliance_confirmed'] ?? false,
termsVersion:

View File

@ -149,6 +149,8 @@ type PaymentSettingsSectionProps = {
defaultValues: PaymentFormValues
waffoDefaultValues: WaffoSettingsValues
waffoPancakeDefaultValues: WaffoPancakeSettingsValues
waffoPancakeProvisionedStoreID?: string
waffoPancakeProvisionedProductID?: string
complianceDefaults: PaymentComplianceDefaults
}
@ -156,6 +158,8 @@ export function PaymentSettingsSection({
defaultValues,
waffoDefaultValues,
waffoPancakeDefaultValues,
waffoPancakeProvisionedStoreID,
waffoPancakeProvisionedProductID,
complianceDefaults,
}: PaymentSettingsSectionProps) {
const { t } = useTranslation()
@ -1468,11 +1472,15 @@ export function PaymentSettingsSection({
<Separator />
<WaffoSettingsSection defaultValues={waffoDefaultValues} />
<WaffoPancakeSettingsSection
defaultValues={waffoPancakeDefaultValues}
provisionedStoreID={waffoPancakeProvisionedStoreID}
provisionedProductID={waffoPancakeProvisionedProductID}
/>
<Separator />
<WaffoPancakeSettingsSection defaultValues={waffoPancakeDefaultValues} />
<WaffoSettingsSection defaultValues={waffoDefaultValues} />
{/* eslint-enable react-hooks/refs */}
</SettingsSection>
)

View File

@ -0,0 +1,102 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
// Catalog / pair / save admin endpoints. Match
// controller/topup_waffo_pancake.go: empty body creds make the backend
// fall back to persisted OptionMap values, so returning admins don't
// have to re-paste the private key (stripped from GET /api/option/).
export interface CatalogProduct {
id: string
name: string
status: string
}
export interface CatalogStore {
id: string
name: string
status: string
prodEnabled: boolean
onetimeProducts: CatalogProduct[]
}
export interface PairResult {
store_id: string
store_name: string
product_id: string
product_name: string
}
export interface PairOrphanError {
error?: string
orphan_store?: boolean
store_id?: string
store_name?: string
}
interface BackendBody<T> {
message?: string
data?: T | string
}
export type CatalogResponse = BackendBody<{ stores: CatalogStore[] }>
export type PairResponse = BackendBody<PairResult>
export type SaveResponse = BackendBody<{ product_id: string; store_id: string }>
export async function listWaffoPancakeCatalog(
merchantID: string,
privateKey: string
): Promise<CatalogResponse> {
const res = await api.post<CatalogResponse>(
'/api/option/waffo-pancake/catalog',
{ merchant_id: merchantID, private_key: privateKey }
)
return res.data
}
export async function createWaffoPancakePair(params: {
merchantID: string
privateKey: string
returnURL: string
}): Promise<PairResponse> {
const res = await api.post<PairResponse>('/api/option/waffo-pancake/pair', {
merchant_id: params.merchantID,
private_key: params.privateKey,
return_url: params.returnURL,
})
return res.data
}
export async function saveWaffoPancakeConfig(params: {
merchantID: string
privateKey: string
returnURL: string
storeID: string
productID: string
}): Promise<SaveResponse> {
const res = await api.post<SaveResponse>('/api/option/waffo-pancake/save', {
merchant_id: params.merchantID,
private_key: params.privateKey,
return_url: params.returnURL,
store_id: params.storeID,
product_id: params.productID,
})
return res.data
}

View File

@ -16,293 +16,714 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import * as React from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { removeTrailingSlash } from './utils'
import {
type CatalogStore,
type PairOrphanError,
type PairResult,
createWaffoPancakePair,
listWaffoPancakeCatalog,
saveWaffoPancakeConfig,
} from './waffo-pancake-api'
export interface WaffoPancakeSettingsValues {
WaffoPancakeEnabled: boolean
WaffoPancakeSandbox: boolean
WaffoPancakeMerchantID: string
WaffoPancakePrivateKey: string
WaffoPancakeWebhookPublicKey: string
WaffoPancakeWebhookTestKey: string
WaffoPancakeStoreID: string
WaffoPancakeProductID: string
// Only operator-typed fields. Nothing else lands in OptionMap until Save.
const waffoPancakeSchema = z.object({
WaffoPancakeMerchantID: z.string(),
WaffoPancakePrivateKey: z.string(),
})
export type WaffoPancakeSettingsValues = z.infer<typeof waffoPancakeSchema> & {
WaffoPancakeReturnURL: string
WaffoPancakeCurrency: string
WaffoPancakeUnitPrice: number
WaffoPancakeMinTopUp: number
}
interface Props {
defaultValues: WaffoPancakeSettingsValues
provisionedStoreID?: string
provisionedProductID?: string
}
const PANCAKE_DASHBOARD_URL = 'https://pancake.waffo.ai/merchant/dashboard'
const DEFAULT_NEW_STORE_NAME = 'new-api-store'
const DEFAULT_NEW_PRODUCT_NAME = 'new-api-charge-product'
const DEFAULT_NEW_PAIR_NAME = `${DEFAULT_NEW_STORE_NAME} + ${DEFAULT_NEW_PRODUCT_NAME}`
export function WaffoPancakeSettingsSection(props: Props) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [loading, setLoading] = useState(false)
const form = useForm<WaffoPancakeSettingsValues>({
defaultValues: props.defaultValues,
const [storeID, setStoreID] = React.useState(
props.provisionedStoreID ?? ''
)
const [productID, setProductID] = React.useState(
props.provisionedProductID ?? ''
)
const [phase, setPhase] = React.useState<'idle' | 'verifying' | 'saving'>(
'idle'
)
const [catalog, setCatalog] = React.useState<CatalogStore[]>([])
// Seed dropdowns from saved bindings so they render on first paint instead
// of waiting for the async catalog fetch to confirm them.
const [chosenStoreID, setChosenStoreID] = React.useState<string>(
props.provisionedStoreID ?? ''
)
const [chosenProductID, setChosenProductID] = React.useState<string>(
props.provisionedProductID ?? ''
)
const [returnURL, setReturnURL] = React.useState(
props.defaultValues.WaffoPancakeReturnURL ?? ''
)
const [creatingPair, setCreatingPair] = React.useState(false)
const initialRef = React.useRef(props.defaultValues)
const defaultsSignature = React.useMemo(
() => JSON.stringify(props.defaultValues),
[props.defaultValues]
)
// "merchantID|privateKey" of the last verified pair; debounced verify
// skips when nothing changed.
const lastVerifiedSignature = React.useRef('')
const fetchSerialRef = React.useRef(0)
const form = useForm({
resolver: zodResolver(waffoPancakeSchema),
mode: 'onChange',
defaultValues: {
WaffoPancakeMerchantID: props.defaultValues.WaffoPancakeMerchantID,
WaffoPancakePrivateKey: props.defaultValues.WaffoPancakePrivateKey,
},
})
useEffect(() => {
form.reset(props.defaultValues)
}, [props.defaultValues, form])
// Mount-only — never re-sync from props after the first render. The
// backend strips PrivateKey from GET /api/option/, so a re-sync would
// wipe whatever the operator just typed.
const didMountRef = React.useRef(false)
React.useEffect(() => {
const parsed = JSON.parse(defaultsSignature) as WaffoPancakeSettingsValues
initialRef.current = parsed
if (didMountRef.current) return
didMountRef.current = true
form.reset({
WaffoPancakeMerchantID: parsed.WaffoPancakeMerchantID,
WaffoPancakePrivateKey: parsed.WaffoPancakePrivateKey,
})
setReturnURL(parsed.WaffoPancakeReturnURL ?? '')
lastVerifiedSignature.current = `${parsed.WaffoPancakeMerchantID.trim()}|${parsed.WaffoPancakePrivateKey.trim()}`
}, [defaultsSignature, form])
const handleSave = async () => {
const values = form.getValues()
const enabled = !!values.WaffoPancakeEnabled
const sandbox = !!values.WaffoPancakeSandbox
React.useEffect(() => {
setStoreID(props.provisionedStoreID ?? '')
}, [props.provisionedStoreID])
if (enabled && !values.WaffoPancakeMerchantID.trim()) {
toast.error(t('Merchant ID is required'))
return
React.useEffect(() => {
setProductID(props.provisionedProductID ?? '')
}, [props.provisionedProductID])
const productsForChosenStore = React.useMemo(() => {
if (!chosenStoreID) return []
return catalog.find((s) => s.id === chosenStoreID)?.onetimeProducts ?? []
}, [catalog, chosenStoreID])
// Raw-ID fallback items render the trigger before the catalog loads or
// when the saved entity has been deleted upstream.
const storeSelectItems = React.useMemo(() => {
const items = catalog.map((s) => ({
value: s.id,
label: `${s.name} (${s.id})`,
}))
if (chosenStoreID && !catalog.some((s) => s.id === chosenStoreID)) {
items.push({ value: chosenStoreID, label: chosenStoreID })
}
if (enabled && !values.WaffoPancakeStoreID.trim()) {
toast.error(t('Store ID is required'))
return
return items
}, [catalog, chosenStoreID])
const productSelectItems = React.useMemo(() => {
const items = productsForChosenStore.map((p) => ({
value: p.id,
label: `${p.name} (${p.id})`,
}))
if (
chosenProductID &&
!productsForChosenStore.some((p) => p.id === chosenProductID)
) {
items.push({ value: chosenProductID, label: chosenProductID })
}
return items
}, [productsForChosenStore, chosenProductID])
if (enabled && !values.WaffoPancakeProductID.trim()) {
toast.error(t('Product ID is required'))
return
}
// Verifies typed creds against Pancake (via /catalog) and refreshes the
// dropdown options. `preselect` overrides the post-load anchor selection;
// omitting it defaults to: saved binding → first store with products.
const verifyAndFetchCatalog = React.useCallback(
async (
merchantID: string,
privateKey: string,
preselect?: { storeID?: string; productID?: string }
) => {
const serial = ++fetchSerialRef.current
let stores: CatalogStore[] = []
try {
const body = await listWaffoPancakeCatalog(merchantID, privateKey)
if (serial !== fetchSerialRef.current) return
if (
body?.message === 'success' &&
typeof body.data === 'object' &&
body.data
) {
stores = (body.data as { stores: CatalogStore[] }).stores ?? []
} else {
const reason = typeof body?.data === 'string' ? body.data : undefined
toast.error(
reason
? `${t('Credentials verification failed')}: ${reason}`
: t(
'Credentials verification failed — double-check Merchant ID and API private key.'
)
)
setPhase('idle')
return
}
} catch (err) {
if (serial !== fetchSerialRef.current) return
toast.error(
`${t('Credentials verification failed')}: ${
err instanceof Error ? err.message : String(err)
}`
)
setPhase('idle')
return
}
if (serial !== fetchSerialRef.current) return
const requiredWebhookKey = sandbox
? values.WaffoPancakeWebhookTestKey
: values.WaffoPancakeWebhookPublicKey
if (enabled && !String(requiredWebhookKey || '').trim()) {
setCatalog(stores)
if (preselect) {
setChosenStoreID(preselect.storeID ?? '')
setChosenProductID(preselect.productID ?? '')
} else {
// Default anchor: bound product if found, else first product of
// the first store with any — saves a click for new operators.
const boundStore = stores.find((s) =>
s.onetimeProducts.some((p) => p.id === productID)
)
if (boundStore && productID) {
setChosenStoreID(boundStore.id)
setChosenProductID(productID)
} else {
const storeWithProducts = stores.find(
(s) => s.onetimeProducts.length > 0
)
if (storeWithProducts) {
setChosenStoreID(storeWithProducts.id)
setChosenProductID(storeWithProducts.onetimeProducts[0].id)
} else {
setChosenStoreID('')
setChosenProductID('')
}
}
}
setPhase('idle')
},
[productID, t]
)
const watchedMerchantID = form.watch('WaffoPancakeMerchantID') || ''
const watchedPrivateKey = form.watch('WaffoPancakePrivateKey') || ''
React.useEffect(() => {
const m = watchedMerchantID.trim()
const k = watchedPrivateKey.trim()
if (!m || !k) return
const signature = `${m}|${k}`
if (signature === lastVerifiedSignature.current) return
const timer = setTimeout(() => {
lastVerifiedSignature.current = signature
setPhase('verifying')
void verifyAndFetchCatalog(m, k)
}, 800)
return () => clearTimeout(timer)
}, [watchedMerchantID, watchedPrivateKey, verifyAndFetchCatalog])
// Initial-load verify: GET /api/option/ strips PrivateKey so a returning
// admin opens the page with empty key. Send blank creds in the body —
// the catalog controller falls back to the persisted OptionMap creds.
const initialLoadRef = React.useRef(false)
React.useEffect(() => {
if (initialLoadRef.current) return
if (!props.defaultValues.WaffoPancakeMerchantID.trim()) return
initialLoadRef.current = true
setPhase('verifying')
void verifyAndFetchCatalog('', '')
}, [props.defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog])
// Returns typed creds when the operator edited either field; otherwise
// blanks so the backend falls back to persisted creds. Without this,
// returning admins (saved merchant ID but empty key field) would send
// a mixed-state body that the backend rejects.
const readCreds = () => {
const formMerchant = (
form.getValues('WaffoPancakeMerchantID') || ''
).trim()
const formKey = (form.getValues('WaffoPancakePrivateKey') || '').trim()
const saved = (props.defaultValues.WaffoPancakeMerchantID || '').trim()
const edited = formMerchant !== saved || formKey.length > 0
if (!edited) return { merchantID: '', privateKey: '' }
return { merchantID: formMerchant, privateKey: formKey }
}
// The minted product's SuccessURL is pinned to the current Return URL
// field, so we prompt before creating when that field is empty.
const handleCreatePair = async () => {
if (!credsReady) {
toast.error(
sandbox
? t('Webhook public key (sandbox) is required')
: t('Webhook public key (production) is required')
t('Fill in both Merchant ID and API Private Key before creating.')
)
return
}
if (enabled && Number(values.WaffoPancakeUnitPrice) <= 0) {
toast.error(t('Unit price must be greater than 0'))
return
const { merchantID, privateKey } = readCreds()
const trimmedReturn = removeTrailingSlash(returnURL.trim())
if (!trimmedReturn) {
if (
!window.confirm(
t(
'Payment return URL is empty. Create the product without a SuccessURL redirect?'
)
)
) {
return
}
}
if (enabled && Number(values.WaffoPancakeMinTopUp) < 1) {
toast.error(t('Minimum top-up amount must be at least 1'))
return
}
setLoading(true)
setCreatingPair(true)
try {
const options: { key: string; value: string }[] = [
{ key: 'WaffoPancakeEnabled', value: enabled ? 'true' : 'false' },
{ key: 'WaffoPancakeSandbox', value: sandbox ? 'true' : 'false' },
{
key: 'WaffoPancakeMerchantID',
value: values.WaffoPancakeMerchantID || '',
},
{
key: 'WaffoPancakeStoreID',
value: values.WaffoPancakeStoreID || '',
},
{
key: 'WaffoPancakeProductID',
value: values.WaffoPancakeProductID || '',
},
{
key: 'WaffoPancakeReturnURL',
value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''),
},
{
key: 'WaffoPancakeCurrency',
value: values.WaffoPancakeCurrency || 'USD',
},
{
key: 'WaffoPancakeUnitPrice',
value: String(values.WaffoPancakeUnitPrice ?? 1),
},
{
key: 'WaffoPancakeMinTopUp',
value: String(values.WaffoPancakeMinTopUp ?? 1),
},
]
if ((values.WaffoPancakePrivateKey || '').trim()) {
options.push({
key: 'WaffoPancakePrivateKey',
value: values.WaffoPancakePrivateKey,
const body = await createWaffoPancakePair({
merchantID,
privateKey,
returnURL: trimmedReturn,
})
if (
body?.message === 'success' &&
typeof body.data === 'object' &&
body.data
) {
const created = body.data as PairResult
// Refetch from GraphQL rather than trusting the response body so the
// dropdowns reflect authoritative state, then anchor on minted IDs.
setPhase('verifying')
await verifyAndFetchCatalog(merchantID, privateKey, {
storeID: created.store_id,
productID: created.product_id,
})
toast.success(
`${t('Store + product created')}: ${created.store_id} / ${created.product_id}`
)
return
}
const errData =
body && typeof body.data === 'object' && body.data !== null
? (body.data as PairOrphanError)
: null
if (errData?.orphan_store && errData.store_id) {
setPhase('verifying')
await verifyAndFetchCatalog(merchantID, privateKey, {
storeID: errData.store_id,
productID: '',
})
}
if ((values.WaffoPancakeWebhookPublicKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookPublicKey',
value: values.WaffoPancakeWebhookPublicKey,
})
}
if ((values.WaffoPancakeWebhookTestKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookTestKey',
value: values.WaffoPancakeWebhookTestKey,
})
}
for (const option of options) {
await updateOption.mutateAsync(option)
}
toast.success(t('Updated successfully'))
} catch {
toast.error(t('Update failed'))
const reason =
errData?.error ??
(typeof body?.data === 'string' ? body.data : undefined)
toast.error(
reason ? `${t('Creation failed')}: ${reason}` : t('Creation failed')
)
} catch (err) {
toast.error(
`${t('Creation failed')}: ${err instanceof Error ? err.message : String(err)}`
)
} finally {
setLoading(false)
setCreatingPair(false)
}
}
const handleSave = async () => {
// Sends raw form values (not readCreds): SaveWaffoPancakeConfig already
// treats a blank PrivateKey as "keep existing", and MerchantID stays
// populated from props for returning admins.
const merchantID = (
form.getValues('WaffoPancakeMerchantID') || ''
).trim()
const privateKey = (
form.getValues('WaffoPancakePrivateKey') || ''
).trim()
if (!merchantID) {
toast.error(t('Merchant ID is required'))
return
}
if (!chosenStoreID || !chosenProductID) {
toast.error(t('Pick or create both a store and a product before saving.'))
return
}
setPhase('saving')
try {
const body = await saveWaffoPancakeConfig({
merchantID,
privateKey,
returnURL: removeTrailingSlash(returnURL.trim()),
storeID: chosenStoreID,
productID: chosenProductID,
})
if (
body?.message === 'success' &&
typeof body.data === 'object' &&
body.data
) {
const saved = body.data as { product_id: string; store_id: string }
setStoreID(saved.store_id)
setProductID(saved.product_id)
toast.success(t('Waffo Pancake settings saved'))
} else {
const reason = typeof body?.data === 'string' ? body.data : undefined
toast.error(
reason
? `${t('Waffo Pancake save failed')}: ${reason}`
: t('Waffo Pancake save failed')
)
}
} catch (err) {
toast.error(
`${t('Waffo Pancake save failed')}: ${
err instanceof Error ? err.message : String(err)
}`
)
} finally {
setPhase('idle')
}
}
const verifying = phase === 'verifying'
const saving = phase === 'saving'
// "Not edited" = MerchantID unchanged AND PrivateKey field blank, in
// which case the backend falls back to persisted creds. Otherwise we
// require both fields filled (mixed states would fail signature check).
const savedMerchantID = (
props.defaultValues.WaffoPancakeMerchantID || ''
).trim()
const formMerchantID = watchedMerchantID.trim()
const formPrivateKey = watchedPrivateKey.trim()
const credsEdited =
formMerchantID !== savedMerchantID || formPrivateKey.length > 0
const hasSavedCreds = savedMerchantID.length > 0
const credsReady = credsEdited
? formMerchantID.length > 0 && formPrivateKey.length > 0
: hasSavedCreds
const hasCatalog = catalog.length > 0
let bindStatusMessage: string
if (!credsReady) {
bindStatusMessage = t('Fill in the credentials above to begin.')
} else if (verifying) {
bindStatusMessage = t(
'Verifying credentials and pulling stores from your Pancake account...'
)
} else if (hasCatalog) {
bindStatusMessage = t(
'Mint a fresh pair below — or pick an existing one further down. Click Save when ready.'
)
} else {
bindStatusMessage = t(
'No stores on this merchant yet. Set a return URL and click Create to mint your first pair.'
)
}
return (
<SettingsSection
title={t('Waffo Pancake Payment Gateway')}
description={t(
'Configure Waffo Pancake hosted checkout integration for USD-priced top-ups'
)}
>
<Alert>
<AlertDescription className='text-xs'>
<div className='space-y-4 pt-4'>
<div>
<h3 className='text-lg font-medium'>{t('Waffo Pancake MoR')}</h3>
<p className='text-muted-foreground text-sm'>
{t(
'Obtain the merchant, store, product and signing keys from your Waffo dashboard. Webhook URL: <ServerAddress>/api/waffo-pancake/webhook'
'Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.'
)}
</AlertDescription>
</Alert>
<div className='grid grid-cols-3 gap-4'>
<div className='flex items-center gap-2'>
<Switch
checked={form.watch('WaffoPancakeEnabled')}
onCheckedChange={(value) =>
form.setValue('WaffoPancakeEnabled', value)
}
/>
<Label>{t('Enable Waffo Pancake')}</Label>
</div>
<div className='flex items-center gap-2'>
<Switch
checked={form.watch('WaffoPancakeSandbox')}
onCheckedChange={(value) =>
form.setValue('WaffoPancakeSandbox', value)
}
/>
<Label>{t('Sandbox mode')}</Label>
</div>
<div className='grid gap-1.5'>
<Label>{t('Currency')}</Label>
<Input placeholder='USD' {...form.register('WaffoPancakeCurrency')} />
</div>
</p>
</div>
<Form {...form}>
<form
onSubmit={(e) => e.preventDefault()}
className='space-y-4'
data-no-autosubmit='true'
>
{/* Blue box — webhook configuration only. */}
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
<p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL (Test):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
{'<ServerAddress>/api/waffo-pancake/webhook/test'}
</code>
</li>
<li>
{t('Webhook URL (Production):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
{'<ServerAddress>/api/waffo-pancake/webhook/prod'}
</code>
</li>
<li>
{t(
'Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.'
)}
</li>
<li>
{t('Configure at:')}{' '}
<a
href={PANCAKE_DASHBOARD_URL}
target='_blank'
rel='noreferrer'
className='underline hover:no-underline'
>
{t('Waffo Pancake Dashboard')}
</a>
</li>
</ul>
</div>
<div className='grid grid-cols-3 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('Merchant ID')}</Label>
<Input
placeholder='MER_xxx'
{...form.register('WaffoPancakeMerchantID')}
<FormField
control={form.control}
name='WaffoPancakeMerchantID'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Merchant ID')}</FormLabel>
<FormControl>
<Input
placeholder='MER_xxx'
autoComplete='off'
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Store ID')}</Label>
<Input
placeholder='STO_xxx'
{...form.register('WaffoPancakeStoreID')}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Product ID')}</Label>
<Input
placeholder='PROD_xxx'
{...form.register('WaffoPancakeProductID')}
/>
</div>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('API Private Key')}</Label>
<Textarea
rows={3}
placeholder={t('Leave blank to keep the existing key')}
{...form.register('WaffoPancakePrivateKey')}
className='font-mono text-xs'
<FormField
control={form.control}
name='WaffoPancakePrivateKey'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API Private Key')}</FormLabel>
<FormControl>
<Textarea
rows={4}
placeholder={t('Leave blank to keep the existing key')}
autoComplete='new-password'
{...field}
onChange={(event) => field.onChange(event.target.value)}
className='font-mono text-xs'
/>
</FormControl>
<p className='text-muted-foreground text-xs'>
{t(
'The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.'
)}
</p>
<FormMessage />
</FormItem>
)}
/>
<p className='text-muted-foreground text-xs'>
{t('Stored value is not echoed back for security')}
</p>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment return URL')}</Label>
<Input
placeholder='https://example.com/console/topup'
{...form.register('WaffoPancakeReturnURL')}
/>
<p className='text-muted-foreground text-xs'>
{t('Defaults to the wallet page when empty')}
</p>
</div>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('Webhook public key (production)')}</Label>
<Textarea
rows={3}
placeholder={t('Leave blank to keep the existing key')}
{...form.register('WaffoPancakeWebhookPublicKey')}
className='font-mono text-xs'
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Webhook public key (sandbox)')}</Label>
<Textarea
rows={3}
placeholder={t('Leave blank to keep the existing key')}
{...form.register('WaffoPancakeWebhookTestKey')}
className='font-mono text-xs'
/>
</div>
</div>
{/*
Binding section split into two visually distinct paths:
(A) "Use existing" pair from the loaded catalog only rendered when
the merchant actually has stores, so first-time setup isn't
cluttered by dead dropdowns.
(B) "Create a fresh pair" always available, paired with the
return URL field that's only meaningful here.
The two paths are split by an "or" divider so the operator never has
to wonder which field belongs to which intent.
*/}
<div className='space-y-4 pt-2'>
<div>
<h4 className='font-medium'>
{t('Bind a Pancake store + product')}
</h4>
<p className='text-muted-foreground text-xs'>
{bindStatusMessage}
</p>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('Unit price (local currency / USD)')}</Label>
<Input
type='number'
step={0.01}
min={0}
{...form.register('WaffoPancakeUnitPrice', { valueAsNumber: true })}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Minimum top-up (USD)')}</Label>
<Input
type='number'
min={1}
{...form.register('WaffoPancakeMinTopUp', { valueAsNumber: true })}
/>
</div>
</div>
{/*
Operator-facing explainer: why only ONE store + product needs
to be bound at the gateway level, and what each piece is used
for. Subscriptions reuse the same Store but get their own
per-plan product, configured in the Subscriptions admin.
*/}
<div className='rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100'>
<p className='mb-1 font-medium'>
{t('Why only one store + product?')}
</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t(
'The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.'
)}
</li>
<li>
{t(
'The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.'
)}
</li>
<li>
{t(
'Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the "+ Create" button there).'
)}
</li>
</ul>
</div>
<Button onClick={handleSave} disabled={loading}>
{loading ? t('Saving...') : t('Save Waffo Pancake settings')}
</Button>
</SettingsSection>
{/* Create section — first, since creating auto-fills the pick-existing dropdowns below. */}
<div className='space-y-1.5'>
<Label>{t('Payment return URL')}</Label>
<div className='flex gap-2'>
<Input
placeholder='https://example.com/console/topup'
value={returnURL}
onChange={(event) => setReturnURL(event.target.value)}
className='flex-1'
/>
<Button
type='button'
variant='outline'
onClick={handleCreatePair}
disabled={creatingPair || verifying || !credsReady}
className='shrink-0'
>
{creatingPair
? t('Creating...')
: `+ ${t('Create')} ${DEFAULT_NEW_PAIR_NAME}`}
</Button>
</div>
<p className='text-muted-foreground text-xs'>
{t(
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank."
)}
</p>
</div>
{hasCatalog ? (
<>
<div className='relative flex items-center py-1'>
<div className='flex-1 border-t' />
<span className='text-muted-foreground px-3 text-[10px] font-medium tracking-[0.2em] uppercase'>
{t('or pick existing')}
</span>
<div className='flex-1 border-t' />
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='grid gap-1.5'>
<Label>{t('Store')}</Label>
<Select
items={storeSelectItems}
value={chosenStoreID}
onValueChange={(value) => {
// Base UI Select can deliver null on deselect.
setChosenStoreID(value ?? '')
setChosenProductID('')
}}
>
<SelectTrigger className='w-full'>
<SelectValue placeholder={t('Select a store')} />
</SelectTrigger>
<SelectContent>
{storeSelectItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label>{t('Product')}</Label>
<Select
items={productSelectItems}
value={chosenProductID}
onValueChange={(value) => setChosenProductID(value ?? '')}
disabled={
!chosenStoreID || productSelectItems.length === 0
}
>
<SelectTrigger className='w-full'>
<SelectValue placeholder={t('Select a product')} />
</SelectTrigger>
<SelectContent>
{productSelectItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
) : null}
<div className='flex items-center gap-3'>
<Button
type='button'
onClick={handleSave}
disabled={saving || !chosenStoreID || !chosenProductID}
>
{saving ? t('Saving...') : t('Save Waffo Pancake settings')}
</Button>
{storeID || productID ? (
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
{storeID ? (
<span>
{t('Bound store:')}{' '}
<code className='bg-muted rounded px-1 py-0.5'>
{storeID}
</code>
</span>
) : null}
{productID ? (
<span>
{t('Bound product:')}{' '}
<code className='bg-muted rounded px-1 py-0.5'>
{productID}
</code>
</span>
) : null}
</div>
) : null}
</div>
</div>
</form>
</Form>
</div>
)
}

View File

@ -43,7 +43,6 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
export interface WaffoSettingsValues {
@ -212,12 +211,17 @@ export function WaffoSettingsSection(props: Props) {
return (
<>
<SettingsSection
title={t('Waffo Payment Gateway')}
description={t(
'Configure Waffo payment aggregation platform integration'
)}
>
<div className='space-y-4 pt-4'>
<div>
<h3 className='text-lg font-medium'>
{t('Waffo Aggregator Gateway')}
</h3>
<p className='text-muted-foreground text-sm'>
{t(
'Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.'
)}
</p>
</div>
<Alert>
<AlertDescription className='text-xs'>
{t(
@ -416,7 +420,7 @@ export function WaffoSettingsSection(props: Props) {
<Button onClick={handleSave} disabled={loading}>
{loading ? t('Saving...') : t('Save Changes')}
</Button>
</SettingsSection>
</div>
<Dialog open={methodDialogOpen} onOpenChange={setMethodDialogOpen}>
<DialogContent>

View File

@ -256,18 +256,13 @@ export type BillingSettings = {
WaffoNotifyUrl: string
WaffoReturnUrl: string
WaffoPayMethods: string
WaffoPancakeEnabled: boolean
WaffoPancakeSandbox: boolean
WaffoPancakeMerchantID: string
WaffoPancakePrivateKey: string
WaffoPancakeWebhookPublicKey: string
WaffoPancakeWebhookTestKey: string
WaffoPancakeReturnURL: string
// Bound by the operator through the catalog flow in the admin Pancake
// section (saved via /api/option/waffo-pancake/save).
WaffoPancakeStoreID: string
WaffoPancakeProductID: string
WaffoPancakeReturnURL: string
WaffoPancakeCurrency: string
WaffoPancakeUnitPrice: number
WaffoPancakeMinTopUp: number
'checkin_setting.enabled': boolean
'checkin_setting.min_quota': number
'checkin_setting.max_quota': number

View File

@ -111,6 +111,7 @@ export function SubscriptionPlansCard({
const enableStripe = !!topupInfo?.enable_stripe_topup
const enableCreem = !!topupInfo?.enable_creem_topup
const enableWaffoPancake = !!topupInfo?.enable_waffo_pancake_topup
const enableOnlineTopUp = !!topupInfo?.enable_online_topup
const epayMethods = useMemo(
() => getEpayMethods(topupInfo?.pay_methods),
@ -629,6 +630,7 @@ export function SubscriptionPlansCard({
plan={selectedPlan}
enableStripe={enableStripe}
enableCreem={enableCreem}
enableWaffoPancake={enableWaffoPancake}
enableOnlineTopUp={enableOnlineTopUp}
epayMethods={epayMethods}
purchaseLimit={

View File

@ -59,11 +59,10 @@ function getErrorMessage(message: string | undefined, data: unknown): string {
}
/**
* Hook for handling Waffo Pancake payment processing
* Hook for the Waffo Pancake hosted-checkout flow.
*
* Pancake uses a hosted checkout URL flow rather than the generic epay form
* submission, so we open the returned URL in a new tab once the backend
* returns a successful response.
* Same-tab redirect (window.location.href) rather than window.open: the
* user-gesture context is lost across the await, so popups get blocked.
*/
export function useWaffoPancakePayment() {
const [processing, setProcessing] = useState(false)
@ -85,8 +84,8 @@ export function useWaffoPancakePayment() {
toast.error(i18next.t('Invalid payment redirect URL'))
return false
}
window.open(checkoutUrl, '_blank', 'noopener,noreferrer')
toast.success(i18next.t('Redirecting to payment page...'))
window.location.href = checkoutUrl
return true
}
}

View File

@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { type ReactNode } from 'react'
import { CreditCard, Landmark } from 'lucide-react'
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'
import i18next from 'i18next'
import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants'
// ============================================================================
@ -121,11 +122,25 @@ export function getPaymentIcon(
/>
)
case PAYMENT_TYPES.WAFFO_PANCAKE:
// The W glyph fills only ~40% of its viewBox vertically (wide and
// short letterform); scale(2) brings its rendered height in line
// with Stripe's S and Creem's Landmark.
return (
<CreditCard
className={className}
style={{ color: PAYMENT_ICON_COLORS[PAYMENT_TYPES.WAFFO_PANCAKE] }}
/>
<span
className={`inline-flex items-center justify-center leading-none ${className}`}
style={{ transform: 'scale(2)' }}
>
<img
src='/waffo-logo-light.svg'
alt={i18next.t('Waffo')}
className='block h-full w-full object-contain dark:hidden'
/>
<img
src='/waffo-logo-dark.svg'
alt={i18next.t('Waffo')}
className='hidden h-full w-full object-contain dark:block'
/>
</span>
)
default:
return <CreditCard className={className} />

View File

@ -51,6 +51,11 @@ export type WaffoPancakePaymentResponse = ApiResponse<
session_id?: string
expires_at?: number | string
order_id?: string
// Self-service session token + expiry — surfaced by the backend so
// future flows (refund / cancel from new-api's own UI) can use them
// without re-issuing checkout. Not consumed by the current handler.
token?: string
token_expires_at?: number | string
}
| string
>

View File

@ -530,6 +530,7 @@
"Billing Process": "Billing Process",
"Billing Source": "Billing Source",
"Bind": "Bind",
"Bind a Pancake store + product": "Bind a Pancake store + product",
"Bind an email address to your account.": "Bind an email address to your account.",
"Bind Email": "Bind Email",
"Bind Telegram Account": "Bind Telegram Account",
@ -556,6 +557,8 @@
"Bound": "Bound",
"Bound Channels": "Bound Channels",
"Bound Only": "Bound Only",
"Bound product:": "Bound product:",
"Bound store:": "Bound store:",
"Bring channels back online after successful checks": "Bring channels back online after successful checks",
"Broadcast a global banner to users. Markdown is supported.": "Broadcast a global banner to users. Markdown is supported.",
"Broadcast short system notices on the dashboard": "Broadcast short system notices on the dashboard",
@ -1022,9 +1025,13 @@
"Create, revoke, and audit API tokens.": "Create, revoke, and audit API tokens.",
"Created": "Created",
"Created At": "Created At",
"Creating...": "Creating...",
"Creation failed": "Creation failed",
"Credential generated": "Credential generated",
"Credential refreshed": "Credential refreshed",
"Credentials": "Credentials",
"Credentials verification failed": "Credentials verification failed",
"Credentials verification failed — double-check Merchant ID and API private key.": "Credentials verification failed — double-check Merchant ID and API private key.",
"Credit remaining": "Credit remaining",
"Creem API key (leave blank unless updating)": "Creem API key (leave blank unless updating)",
"Creem Gateway": "Creem Gateway",
@ -1716,6 +1723,8 @@
"Fill Codex CLI / Claude CLI Templates": "Fill Codex CLI / Claude CLI Templates",
"Fill example (all channels)": "Fill example (all channels)",
"Fill example (specific channels)": "Fill example (specific channels)",
"Fill in both Merchant ID and API Private Key before creating.": "Fill in both Merchant ID and API Private Key before creating.",
"Fill in the credentials above to begin.": "Fill in the credentials above to begin.",
"Fill in the following info to create a new subscription plan": "Fill in the following info to create a new subscription plan",
"Fill Related Models": "Fill Related Models",
"Fill Template": "Fill Template",
@ -2323,6 +2332,8 @@
"Minimum Trust Level": "Minimum Trust Level",
"Minimum:": "Minimum:",
"Minor blips in the last 30 days": "Minor blips in the last 30 days",
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "Mint a fresh pair below — or pick an existing one further down. Click Save when ready.",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.",
"Minute": "Minute",
"minutes": "minutes",
"Missing code": "Missing code",
@ -2619,6 +2630,7 @@
"No rules yet. Add a group below to get started.": "No rules yet. Add a group below to get started.",
"No separate media pricing configured.": "No separate media pricing configured.",
"No status code mappings configured.": "No status code mappings configured.",
"No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "No stores on this merchant yet. Set a return URL and click Create to mint your first pair.",
"No subscription plans yet": "No subscription plans yet",
"No subscription records": "No subscription records",
"No Sync": "No Sync",
@ -2762,6 +2774,7 @@
"Opus Model": "Opus Model",
"Or continue with": "Or continue with",
"Or enter this key manually:": "Or enter this key manually:",
"or pick existing": "or pick existing",
"Order completed successfully": "Order completed successfully",
"Order History": "Order History",
"Order Payment Method": "Order Payment Method",
@ -2796,6 +2809,7 @@
"Page {{current}} of {{total}}": "Page {{current}} of {{total}}",
"PaLM": "PaLM",
"Pan": "Pan",
"Pancake": "Pancake",
"Param Override": "Param Override",
"Parameter": "Parameter",
"Parameter configuration error": "Parameter configuration error",
@ -2859,6 +2873,7 @@
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring",
"Payment": "Payment",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.",
"Payment Channel": "Payment Channel",
"Payment Gateway": "Payment Gateway",
"Payment initiated": "Payment initiated",
@ -2872,6 +2887,7 @@
"Payment page opened": "Payment page opened",
"Payment request failed": "Payment request failed",
"Payment return URL": "Payment return URL",
"Payment return URL is empty. Create the product without a SuccessURL redirect?": "Payment return URL is empty. Create the product without a SuccessURL redirect?",
"Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.": "Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.",
"Peak": "Peak",
"Peak throughput": "Peak throughput",
@ -2915,11 +2931,14 @@
"Personal use": "Personal use",
"Personal use mode": "Personal use mode",
"Pick a date": "Pick a date",
"Pick or create both a store and a product before saving.": "Pick or create both a store and a product before saving.",
"Ping Interval (seconds)": "Ping Interval (seconds)",
"Plan": "Plan",
"Plan Name": "Plan Name",
"Plan price must be greater than zero": "Plan price must be greater than zero",
"Plan Subtitle": "Plan Subtitle",
"Plan Title": "Plan Title",
"Plan title is required": "Plan title is required",
"Planned maintenance on Friday at 22:00 UTC...": "Planned maintenance on Friday at 22:00 UTC...",
"Platform": "Platform",
"Playground": "Playground",
@ -3225,6 +3244,7 @@
"Regex": "Regex",
"Regex Pattern": "Regex Pattern",
"Regex Replace": "Regex Replace",
"Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.": "Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.",
"Register Passkey": "Register Passkey",
"Registration Enabled": "Registration Enabled",
"Registry (optional)": "Registry (optional)",
@ -3504,8 +3524,10 @@
"Select a group type": "Select a group type",
"Select a model to edit pricing": "Select a model to edit pricing",
"Select a preset...": "Select a preset...",
"Select a product": "Select a product",
"Select a role": "Select a role",
"Select a rule to edit.": "Select a rule to edit.",
"Select a store": "Select a store",
"Select a timestamp before clearing logs.": "Select a timestamp before clearing logs.",
"Select a usage mode to continue": "Select a usage mode to continue",
"Select a verification method first": "Select a verification method first",
@ -3700,6 +3722,7 @@
"Standard price": "Standard price",
"Start": "Start",
"Start a conversation to see messages here": "Start a conversation to see messages here",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.",
"Start for free with generous limits. No credit card required.": "Start for free with generous limits. No credit card required.",
"Start Time": "Start Time",
"Static page describing the platform.": "Static page describing the platform.",
@ -3720,6 +3743,8 @@
"Step": "Step",
"Stop": "Stop",
"Stop Retry": "Stop Retry",
"Store": "Store",
"Store + product created": "Store + product created",
"Store ID": "Store ID",
"Store ID is required": "Store ID is required",
"Stored value is not echoed back for security": "Stored value is not echoed back for security",
@ -3755,6 +3780,7 @@
"Subscription Only": "Subscription Only",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.",
"Subscription Plans": "Subscription Plans",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).",
"Subtract": "Subtract",
"Success": "Success",
"Success rate": "Success rate",
@ -3879,8 +3905,11 @@
"The administrator has not configured a user agreement yet.": "The administrator has not configured a user agreement yet.",
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.",
"The binding will complete automatically after authorization": "The binding will complete automatically after authorization",
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "The effective domain for Passkey registration. Must match the current domain or be its parent domain.",
"The entered text does not match the required text.": "The entered text does not match the required text.",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.",
"The exact model identifier as used in API requests.": "The exact model identifier as used in API requests.",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:",
@ -4239,6 +4268,7 @@
"used": "used",
"Used": "Used",
"Used / Remaining": "Used / Remaining",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.",
"Used for load balancing. Higher weight = more requests": "Used for load balancing. Higher weight = more requests",
"Used in URLs and API routes": "Used in URLs and API routes",
"Used Quota": "Used Quota",
@ -4313,6 +4343,7 @@
"Verify routing with Playground or your client": "Verify routing with Playground or your client",
"Verify Setup": "Verify Setup",
"Verify your database connection": "Verify your database connection",
"Verifying credentials and pulling stores from your Pancake account...": "Verifying credentials and pulling stores from your Pancake account...",
"Version Overrides": "Version Overrides",
"Vertex AI": "Vertex AI",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.",
@ -4362,7 +4393,14 @@
"Visual Parameter Override": "Visual Parameter Override",
"VolcEngine": "VolcEngine",
"vs. previous": "vs. previous",
"Waffo Aggregator Gateway": "Waffo Aggregator Gateway",
"Waffo Pancake Dashboard": "Waffo Pancake Dashboard",
"Waffo Pancake MoR": "Waffo Pancake MoR",
"Waffo Pancake Payment Gateway": "Waffo Pancake payment gateway",
"Waffo Pancake product created": "Waffo Pancake product created",
"Waffo Pancake product creation failed": "Waffo Pancake product creation failed",
"Waffo Pancake save failed": "Waffo Pancake save failed",
"Waffo Pancake settings saved": "Waffo Pancake settings saved",
"Waffo Payment": "Waffo Payment",
"Waffo Payment Gateway": "Waffo Payment Gateway",
"Waffo Public Key (Production)": "Waffo Public Key (Production)",
@ -4393,6 +4431,8 @@
"Webhook Secret": "Webhook Secret",
"Webhook signing secret (leave blank unless updating)": "Webhook signing secret (leave blank unless updating)",
"Webhook URL": "Webhook URL",
"Webhook URL (Production):": "Webhook URL (Production):",
"Webhook URL (Test):": "Webhook URL (Test):",
"Webhook URL:": "Webhook URL:",
"Website is under maintenance!": "Website is under maintenance!",
"WeChat": "WeChat",
@ -4432,6 +4472,7 @@
"Whitelist (Only allow listed domains)": "Whitelist (Only allow listed domains)",
"Whitelist (Only allow listed IPs)": "Whitelist (Only allow listed IPs)",
"whsec_xxx": "whsec_xxx",
"Why only one store + product?": "Why only one store + product?",
"Window:": "Window:",
"Wire encoding for the embedding vectors": "Wire encoding for the embedding vectors",
"with conflicts": "with conflicts",

View File

@ -530,6 +530,7 @@
"Billing Process": "计费过程",
"Billing Source": "计费来源",
"Bind": "绑定",
"Bind a Pancake store + product": "绑定 Pancake 店铺 + 商品",
"Bind an email address to your account.": "将邮箱地址绑定到您的账户。",
"Bind Email": "绑定邮箱",
"Bind Telegram Account": "绑定 Telegram 账户",
@ -556,6 +557,8 @@
"Bound": "已绑定",
"Bound Channels": "绑定渠道",
"Bound Only": "仅已绑定",
"Bound product:": "已绑定产品:",
"Bound store:": "已绑定店铺:",
"Bring channels back online after successful checks": "检查成功后使渠道恢复在线",
"Broadcast a global banner to users. Markdown is supported.": "向用户广播全局横幅。支持 Markdown。",
"Broadcast short system notices on the dashboard": "在仪表板上广播简短的系统通知",
@ -1022,9 +1025,13 @@
"Create, revoke, and audit API tokens.": "创建、撤销和审计 API 令牌。",
"Created": "创建时间",
"Created At": "创建时间",
"Creating...": "创建中...",
"Creation failed": "创建失败",
"Credential generated": "凭据已生成",
"Credential refreshed": "凭据已刷新",
"Credentials": "凭证",
"Credentials verification failed": "凭证验证失败",
"Credentials verification failed — double-check Merchant ID and API private key.": "凭证验证失败——请仔细核对商户 ID 和 API 私钥。",
"Credit remaining": "剩余额度",
"Creem API key (leave blank unless updating)": "Creem API 密钥(除非更新,否则留空)",
"Creem Gateway": "Creem 网关",
@ -1716,6 +1723,8 @@
"Fill Codex CLI / Claude CLI Templates": "填充 Codex CLI / Claude CLI 模板",
"Fill example (all channels)": "填充示例(全部频道)",
"Fill example (specific channels)": "填充示例(指定频道)",
"Fill in both Merchant ID and API Private Key before creating.": "请先填写商户 ID 和 API 私钥再进行创建。",
"Fill in the credentials above to begin.": "请先填写上方凭证。",
"Fill in the following info to create a new subscription plan": "填写以下信息创建新的订阅套餐",
"Fill Related Models": "填入相关模型",
"Fill Template": "填入模板",
@ -2323,6 +2332,8 @@
"Minimum Trust Level": "最低信任级别",
"Minimum:": "最低:",
"Minor blips in the last 30 days": "近 30 天内有轻微抖动",
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "下方可新建一对——或继续下滑选择已有的。完成后点击保存。",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "在已保存的店铺中用此套餐的标题和价格创建一个 Pancake 商品。需要先在支付设置中完成 Waffo Pancake 配置。",
"Minute": "分钟",
"minutes": "分钟",
"Missing code": "缺少代码",
@ -2619,6 +2630,7 @@
"No rules yet. Add a group below to get started.": "暂无规则。请在下方添加分组以开始。",
"No separate media pricing configured.": "未单独配置多模态价格。",
"No status code mappings configured.": "未配置状态码映射。",
"No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "该商户暂无店铺。填写支付返回地址后点击「创建」生成第一对。",
"No subscription plans yet": "暂无订阅套餐",
"No subscription records": "暂无订阅记录",
"No Sync": "不同步",
@ -2762,6 +2774,7 @@
"Opus Model": "Opus 模型",
"Or continue with": "或继续使用",
"Or enter this key manually:": "或手动输入此密钥:",
"or pick existing": "或选择已有",
"Order completed successfully": "订单已成功完成",
"Order History": "订单历史",
"Order Payment Method": "订单支付方式",
@ -2796,6 +2809,7 @@
"Page {{current}} of {{total}}": "第 {{current}} 页,共 {{total}} 页",
"PaLM": "PaLM",
"Pan": "平移",
"Pancake": "煎饼",
"Param Override": "参数覆盖",
"Parameter": "参数",
"Parameter configuration error": "参数配置有误",
@ -2859,6 +2873,7 @@
"Pay": "支付",
"Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况",
"Payment": "支付",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "支付聚合模式——使用你自己的注册公司(海外主体)来入驻,适合 Enterprise 企业",
"Payment Channel": "支付渠道",
"Payment Gateway": "支付网关",
"Payment initiated": "已发起支付",
@ -2872,6 +2887,7 @@
"Payment page opened": "已打开支付页面",
"Payment request failed": "支付请求失败",
"Payment return URL": "支付返回地址",
"Payment return URL is empty. Create the product without a SuccessURL redirect?": "支付返回地址为空。是否在不绑定 SuccessURL 的情况下创建商品?",
"Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.": "确认前,支付、兑换码、订阅计划和邀请返利功能将保持锁定。",
"Peak": "峰值",
"Peak throughput": "峰值吞吐",
@ -2915,11 +2931,14 @@
"Personal use": "个人使用",
"Personal use mode": "个人使用模式",
"Pick a date": "选择日期",
"Pick or create both a store and a product before saving.": "保存前请先选择或新建店铺和商品。",
"Ping Interval (seconds)": "Ping 间隔(秒)",
"Plan": "套餐",
"Plan Name": "套餐名称",
"Plan price must be greater than zero": "套餐价格必须大于 0",
"Plan Subtitle": "套餐副标题",
"Plan Title": "套餐标题",
"Plan title is required": "请填写套餐标题",
"Planned maintenance on Friday at 22:00 UTC...": "计划于周五 22:00 UTC 进行维护...",
"Platform": "平台",
"Playground": "游乐场",
@ -3225,6 +3244,7 @@
"Regex": "正则",
"Regex Pattern": "正则表达式",
"Regex Replace": "正则替换",
"Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.": "将上述 URL 分别注册到 Pancake 控制台的测试模式和生产模式 Webhook 槽位中。独立的端点可以防止测试流量误充值到生产账户。",
"Register Passkey": "注册 Passkey",
"Registration Enabled": "注册已启用",
"Registry (optional)": "注册表 (可选)",
@ -3504,8 +3524,10 @@
"Select a group type": "选择分组类型",
"Select a model to edit pricing": "选择一个模型编辑定价",
"Select a preset...": "选择一个预设...",
"Select a product": "选择商品",
"Select a role": "选择角色",
"Select a rule to edit.": "请选择一条规则进行编辑。",
"Select a store": "选择店铺",
"Select a timestamp before clearing logs.": "清除日志前请选择一个时间戳。",
"Select a usage mode to continue": "选择使用模式以继续",
"Select a verification method first": "请先选择验证方式",
@ -3700,6 +3722,7 @@
"Standard price": "标准价格",
"Start": "开始",
"Start a conversation to see messages here": "开始对话以在此处查看消息",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "无需注册公司即可开始全球收款,适合个人 / OPC 一人公司 / Startup。Waffo Pancake 作为你的 Merchant of Record替你承担全球收款的合规责任消费税、开票Invoice、订阅管理、退款与拒付处理。独立开发者可以直接上线专注产品而非合规。极速入驻一个 Prompt 完成集成。",
"Start for free with generous limits. No credit card required.": "免费开始使用,额度充足,无需绑定信用卡。",
"Start Time": "起始时间",
"Static page describing the platform.": "描述平台的静态页面。",
@ -3720,6 +3743,8 @@
"Step": "步骤",
"Stop": "停止",
"Stop Retry": "停止重试",
"Store": "店铺",
"Store + product created": "店铺 + 商品已创建",
"Store ID": "商店 ID",
"Store ID is required": "商店 ID 为必填项",
"Stored value is not echoed back for security": "出于安全考虑,已存储的值不会回显",
@ -3755,6 +3780,7 @@
"Subscription Only": "仅用订阅",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "订阅套餐创建和变更已锁定,管理员需先在支付设置中确认合规声明。",
"Subscription Plans": "订阅套餐",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "订阅套餐不使用这里绑定的商品 —— 每个套餐在「订阅」管理页有自己专属的 Pancake 商品,可以手动填写或点 \"+ 新建\" 一键创建。",
"Subtract": "减少",
"Success": "成功",
"Success rate": "成功率",
@ -3879,8 +3905,11 @@
"The administrator has not configured a user agreement yet.": "管理员尚未配置用户协议。",
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "管理员尚未配置任何关于内容。您可以在设置页面中进行设置,支持 HTML 或 URL。",
"The binding will complete automatically after authorization": "授权后绑定将自动完成",
"The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "绑定的商品用于钱包充值用户输入任意金额时new-api 用这一个 Pancake 商品发起结账并按用户输入动态设置价格 —— 不需要预先创建 $1 / $5 / $10 等一堆 SKU。",
"The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "绑定的店铺是 new-api 从此处创建的所有 Pancake 商品(钱包充值商品 + 各订阅套餐商品)的父容器。一个店铺足够使用;只有在确实需要使用独立的 Pancake 商品目录时,才需要绑定到不同的店铺。",
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用于 Passkey 注册的有效域。必须与当前域匹配或为其父域。",
"The entered text does not match the required text.": "输入内容与要求文案不一致。",
"The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "测试 / 生产环境由你粘进来的 API 私钥本身决定——集成阶段用 Test Key正式上线时再换成 Production Key。",
"The exact model identifier as used in API requests.": "API 请求中使用的确切模型标识符。",
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下模型存在计费类型冲突(固定价格 vs 比例计费)。确认以继续更改。",
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "模型重定向里的下列模型尚未添加到\"模型\"列表,调用时会因为缺少可用模型而失败:",
@ -4239,6 +4268,7 @@
"used": "已使用",
"Used": "已使用",
"Used / Remaining": "已使用 / 剩余",
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "作为新商品的 SuccessURL。留空时会再次提示确认。",
"Used for load balancing. Higher weight = more requests": "用于负载均衡。权重越高 = 请求越多",
"Used in URLs and API routes": "用于URL和API路由",
"Used Quota": "消耗额度",
@ -4313,6 +4343,7 @@
"Verify routing with Playground or your client": "使用 Playground 或你的客户端验证路由",
"Verify Setup": "验证设置",
"Verify your database connection": "验证数据库连接",
"Verifying credentials and pulling stores from your Pancake account...": "正在验证凭证并从你的 Pancake 账户拉取店铺...",
"Version Overrides": "版本覆盖",
"Vertex AI": "Vertex AI",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段",
@ -4362,7 +4393,14 @@
"Visual Parameter Override": "可视化参数覆盖",
"VolcEngine": "字节火山方舟、豆包通用",
"vs. previous": "相较上期",
"Waffo Aggregator Gateway": "Waffo 支付聚合网关",
"Waffo Pancake Dashboard": "Waffo Pancake 控制台",
"Waffo Pancake MoR": "Waffo Pancake MoR",
"Waffo Pancake Payment Gateway": "Waffo Pancake 支付网关",
"Waffo Pancake product created": "Waffo Pancake 产品已创建",
"Waffo Pancake product creation failed": "Waffo Pancake 产品创建失败",
"Waffo Pancake save failed": "Waffo Pancake 保存失败",
"Waffo Pancake settings saved": "Waffo Pancake 设置已保存",
"Waffo Payment": "Waffo 支付",
"Waffo Payment Gateway": "Waffo 支付网关",
"Waffo Public Key (Production)": "Waffo 公钥(生产)",
@ -4393,6 +4431,8 @@
"Webhook Secret": "Webhook 密钥",
"Webhook signing secret (leave blank unless updating)": "Webhook 签名密钥(除非更新,否则留空)",
"Webhook URL": "Webhook 地址",
"Webhook URL (Production):": "Webhook URL生产",
"Webhook URL (Test):": "Webhook URL测试",
"Webhook URL:": "Webhook URL:",
"Website is under maintenance!": "网站正在维护中!",
"WeChat": "微信",
@ -4432,6 +4472,7 @@
"Whitelist (Only allow listed domains)": "白名单(仅允许列出的域)",
"Whitelist (Only allow listed IPs)": "白名单(仅允许列出的 IP",
"whsec_xxx": "whsec_xxx",
"Why only one store + product?": "为什么只绑定一个店铺 + 商品?",
"Window:": "窗口:",
"Wire encoding for the embedding vectors": "向量传输的编码格式",
"with conflicts": "有冲突",