[Feature Request] Waffo Pancake gateway — full integration with subscription support + admin catalog binding flow (#4935)
This commit is contained in:
parent
8e5e89bb5b
commit
19f1821fc8
@ -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{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
125
controller/subscription_payment_waffo_pancake.go
Normal file
125
controller/subscription_payment_waffo_pancake.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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
2
go.mod
@ -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
4
go.sum
@ -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=
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
5
web/classic/public/waffo-logo-dark.svg
vendored
Normal file
5
web/classic/public/waffo-logo-dark.svg
vendored
Normal 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 |
5
web/classic/public/waffo-logo-light.svg
vendored
Normal file
5
web/classic/public/waffo-logo-light.svg
vendored
Normal 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 |
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
25
web/classic/src/components/topup/index.jsx
vendored
25
web/classic/src/components/topup/index.jsx
vendored
@ -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('支付请求失败'));
|
||||
}
|
||||
|
||||
1
web/classic/src/i18n/locales/en.json
vendored
1
web/classic/src/i18n/locales/en.json
vendored
@ -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",
|
||||
|
||||
1
web/classic/src/i18n/locales/zh.json
vendored
1
web/classic/src/i18n/locales/zh.json
vendored
@ -1154,6 +1154,7 @@
|
||||
"支付方式": "支付方式",
|
||||
"支付设置": "支付设置",
|
||||
"支付请求失败": "支付请求失败",
|
||||
"支付跳转地址不安全": "支付跳转地址不安全",
|
||||
"支付金额": "支付金额",
|
||||
"支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片",
|
||||
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。",
|
||||
|
||||
@ -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}>
|
||||
|
||||
5
web/default/public/waffo-logo-dark.svg
vendored
Normal file
5
web/default/public/waffo-logo-dark.svg
vendored
Normal 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 |
5
web/default/public/waffo-logo-light.svg
vendored
Normal file
5
web/default/public/waffo-logo-light.svg
vendored
Normal 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 |
@ -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'>
|
||||
© {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'>
|
||||
© {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'>
|
||||
© {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>
|
||||
© {currentYear} {displayName}.{' '}
|
||||
{props.copyright ?? t('footer.defaultCopyright')}
|
||||
</span>
|
||||
<LegalLinks leadingSeparator />
|
||||
</div>
|
||||
<ProjectAttribution currentYear={currentYear} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
36
web/default/src/features/subscriptions/api.ts
vendored
36
web/default/src/features/subscriptions/api.ts
vendored
@ -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 }> {
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@ -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 plan’s title and price. Requires Waffo Pancake to be fully configured in Payment settings first.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
web/default/src/features/subscriptions/types.ts
vendored
10
web/default/src/features/subscriptions/types.ts
vendored
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
102
web/default/src/features/system-settings/integrations/waffo-pancake-api.ts
vendored
Normal file
102
web/default/src/features/system-settings/integrations/waffo-pancake-api.ts
vendored
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
23
web/default/src/features/wallet/lib/ui.tsx
vendored
23
web/default/src/features/wallet/lib/ui.tsx
vendored
@ -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} />
|
||||
|
||||
5
web/default/src/features/wallet/types.ts
vendored
5
web/default/src/features/wallet/types.ts
vendored
@ -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
|
||||
>
|
||||
|
||||
41
web/default/src/i18n/locales/en.json
vendored
41
web/default/src/i18n/locales/en.json
vendored
@ -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 plan’s title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Creates a Pancake product in the saved store using this plan’s 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",
|
||||
|
||||
41
web/default/src/i18n/locales/zh.json
vendored
41
web/default/src/i18n/locales/zh.json
vendored
@ -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 plan’s 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": "有冲突",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user