Commit 19f1821f by Hill-waffo Committed by GitHub

[Feature Request] Waffo Pancake gateway — full integration with subscription…

[Feature Request] Waffo Pancake gateway — full integration with subscription support + admin catalog binding flow (#4935)
parent 8e5e89bb
...@@ -42,15 +42,6 @@ func isPositiveOptionValue(value string) bool { ...@@ -42,15 +42,6 @@ func isPositiveOptionValue(value string) bool {
return err == nil && floatValue > 0 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{}) { func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
if strings.TrimSpace(raw) == "" { if strings.TrimSpace(raw) == "" {
return return
...@@ -95,7 +86,7 @@ func GetOptions(c *gin.Context) { ...@@ -95,7 +86,7 @@ func GetOptions(c *gin.Context) {
strings.HasSuffix(k, "Key") || strings.HasSuffix(k, "Key") ||
strings.HasSuffix(k, "secret") || strings.HasSuffix(k, "secret") ||
strings.HasSuffix(k, "api_key") strings.HasSuffix(k, "api_key")
if isSensitiveKey && !isVisiblePublicKeyOption(k) { if isSensitiveKey {
continue continue
} }
options = append(options, &model.Option{ options = append(options, &model.Option{
......
...@@ -77,24 +77,15 @@ func isWaffoPancakeTopUpEnabled() bool { ...@@ -77,24 +77,15 @@ func isWaffoPancakeTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() { if !isPaymentComplianceConfirmed() {
return false return false
} }
if !setting.WaffoPancakeEnabled { // Presence-of-credentials = enabled. Webhook public keys ship inside
return false // the SDK; mode (test/prod) is read from each event.
} return strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
return isWaffoPancakeWebhookConfigured() &&
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" && strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
strings.TrimSpace(setting.WaffoPancakeProductID) != "" strings.TrimSpace(setting.WaffoPancakeProductID) != ""
} }
func isWaffoPancakeWebhookConfigured() bool { func isWaffoPancakeWebhookConfigured() bool {
currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey) return isWaffoPancakeTopUpEnabled()
if setting.WaffoPancakeSandbox {
currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
}
return currentWebhookKey != ""
} }
func isWaffoPancakeWebhookEnabled() bool { func isWaffoPancakeWebhookEnabled() bool {
......
...@@ -114,47 +114,32 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { ...@@ -114,47 +114,32 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t) confirmPaymentComplianceForTest(t)
originalEnabled := setting.WaffoPancakeEnabled
originalSandbox := setting.WaffoPancakeSandbox
originalMerchantID := setting.WaffoPancakeMerchantID originalMerchantID := setting.WaffoPancakeMerchantID
originalPrivateKey := setting.WaffoPancakePrivateKey originalPrivateKey := setting.WaffoPancakePrivateKey
originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey
originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey
originalStoreID := setting.WaffoPancakeStoreID
originalProductID := setting.WaffoPancakeProductID originalProductID := setting.WaffoPancakeProductID
t.Cleanup(func() { t.Cleanup(func() {
setting.WaffoPancakeEnabled = originalEnabled
setting.WaffoPancakeSandbox = originalSandbox
setting.WaffoPancakeMerchantID = originalMerchantID setting.WaffoPancakeMerchantID = originalMerchantID
setting.WaffoPancakePrivateKey = originalPrivateKey setting.WaffoPancakePrivateKey = originalPrivateKey
setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey
setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey
setting.WaffoPancakeStoreID = originalStoreID
setting.WaffoPancakeProductID = originalProductID setting.WaffoPancakeProductID = originalProductID
}) })
setting.WaffoPancakeEnabled = true // Presence of all three credentials enables the gateway. Webhook public
setting.WaffoPancakeSandbox = false // keys are bundled in the SDK and there is no separate Enabled toggle —
setting.WaffoPancakeMerchantID = "merchant" // clear any of the three fields to disable.
setting.WaffoPancakeMerchantID = ""
setting.WaffoPancakePrivateKey = "private" setting.WaffoPancakePrivateKey = "private"
setting.WaffoPancakeStoreID = "store"
setting.WaffoPancakeProductID = "product" setting.WaffoPancakeProductID = "product"
setting.WaffoPancakeWebhookPublicKey = ""
require.False(t, isWaffoPancakeWebhookEnabled()) require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeWebhookPublicKey = "public" setting.WaffoPancakeMerchantID = "merchant"
require.True(t, isWaffoPancakeWebhookEnabled()) require.True(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeEnabled = false setting.WaffoPancakeProductID = ""
require.False(t, isWaffoPancakeWebhookEnabled()) require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeEnabled = true setting.WaffoPancakeProductID = "product"
setting.WaffoPancakeSandbox = true setting.WaffoPancakePrivateKey = ""
setting.WaffoPancakeWebhookTestKey = ""
require.False(t, isWaffoPancakeWebhookEnabled()) require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeWebhookTestKey = "test_public"
require.True(t, isWaffoPancakeWebhookEnabled())
} }
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
......
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) { ...@@ -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 支付,添加到支付方法列表 // 如果启用了 Waffo 支付,添加到支付方法列表
enableWaffo := isWaffoTopUpEnabled() enableWaffo := isWaffoTopUpEnabled()
if enableWaffo { if enableWaffo {
...@@ -74,26 +95,6 @@ func GetTopUpInfo(c *gin.Context) { ...@@ -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{ data := gin.H{
"enable_online_topup": isEpayTopUpEnabled(), "enable_online_topup": isEpayTopUpEnabled(),
"enable_stripe_topup": isStripeTopUpEnabled(), "enable_stripe_topup": isStripeTopUpEnabled(),
......
...@@ -60,6 +60,8 @@ require ( ...@@ -60,6 +60,8 @@ require (
gorm.io/gorm v1.25.2 gorm.io/gorm v1.25.2
) )
require github.com/waffo-com/waffo-pancake-sdk-go v0.2.0
require ( require (
github.com/DmitriyVTitov/size v1.5.0 // indirect github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
......
...@@ -308,6 +308,10 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E ...@@ -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/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 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw=
github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g= 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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
......
...@@ -399,6 +399,7 @@ func ensureSubscriptionPlanTableSQLite() error { ...@@ -399,6 +399,7 @@ func ensureSubscriptionPlanTableSQLite() error {
` + "`sort_order`" + ` integer DEFAULT 0, ` + "`sort_order`" + ` integer DEFAULT 0,
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '', ` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
` + "`creem_product_id`" + ` varchar(128) DEFAULT '', ` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
` + "`max_purchase_per_user`" + ` integer DEFAULT 0, ` + "`max_purchase_per_user`" + ` integer DEFAULT 0,
` + "`upgrade_group`" + ` varchar(64) DEFAULT '', ` + "`upgrade_group`" + ` varchar(64) DEFAULT '',
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0, ` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
...@@ -432,6 +433,7 @@ PRIMARY KEY (` + "`id`" + `) ...@@ -432,6 +433,7 @@ PRIMARY KEY (` + "`id`" + `)
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"}, {Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"}, {Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
{Name: "creem_product_id", DDL: "`creem_product_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: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"},
{Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"}, {Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"},
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"}, {Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},
......
...@@ -12,6 +12,7 @@ import ( ...@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/setting/performance_setting" "github.com/QuantumNous/new-api/setting/performance_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/system_setting" "github.com/QuantumNous/new-api/setting/system_setting"
"gorm.io/gorm"
) )
type Option struct { type Option struct {
...@@ -106,18 +107,13 @@ func InitOptionMap() { ...@@ -106,18 +107,13 @@ func InitOptionMap() {
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64) common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp) common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString() 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["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID
common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey 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["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL
common.OptionMap["WaffoPancakeCurrency"] = setting.WaffoPancakeCurrency
common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64) common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp) common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp)
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
...@@ -222,6 +218,39 @@ func UpdateOption(key string, value string) error { ...@@ -222,6 +218,39 @@ func UpdateOption(key string, value string) error {
return updateOptionMap(key, value) 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) { func updateOptionMap(key string, value string) (err error) {
common.OptionMapRWMutex.Lock() common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock() defer common.OptionMapRWMutex.Unlock()
...@@ -419,26 +448,16 @@ func updateOptionMap(key string, value string) (err error) { ...@@ -419,26 +448,16 @@ func updateOptionMap(key string, value string) (err error) {
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64) setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoMinTopUp": case "WaffoMinTopUp":
setting.WaffoMinTopUp, _ = strconv.Atoi(value) setting.WaffoMinTopUp, _ = strconv.Atoi(value)
case "WaffoPancakeEnabled":
setting.WaffoPancakeEnabled = value == "true"
case "WaffoPancakeSandbox":
setting.WaffoPancakeSandbox = value == "true"
case "WaffoPancakeMerchantID": case "WaffoPancakeMerchantID":
setting.WaffoPancakeMerchantID = value setting.WaffoPancakeMerchantID = value
case "WaffoPancakePrivateKey": case "WaffoPancakePrivateKey":
setting.WaffoPancakePrivateKey = value setting.WaffoPancakePrivateKey = value
case "WaffoPancakeWebhookPublicKey": case "WaffoPancakeReturnURL":
setting.WaffoPancakeWebhookPublicKey = value setting.WaffoPancakeReturnURL = value
case "WaffoPancakeWebhookTestKey":
setting.WaffoPancakeWebhookTestKey = value
case "WaffoPancakeStoreID": case "WaffoPancakeStoreID":
setting.WaffoPancakeStoreID = value setting.WaffoPancakeStoreID = value
case "WaffoPancakeProductID": case "WaffoPancakeProductID":
setting.WaffoPancakeProductID = value setting.WaffoPancakeProductID = value
case "WaffoPancakeReturnURL":
setting.WaffoPancakeReturnURL = value
case "WaffoPancakeCurrency":
setting.WaffoPancakeCurrency = value
case "WaffoPancakeUnitPrice": case "WaffoPancakeUnitPrice":
setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64) setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoPancakeMinTopUp": case "WaffoPancakeMinTopUp":
......
...@@ -161,6 +161,7 @@ type SubscriptionPlan struct { ...@@ -161,6 +161,7 @@ type SubscriptionPlan struct {
StripePriceId string `json:"stripe_price_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:''"` 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) // Max purchases per user (0 = unlimited)
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"` MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
......
...@@ -56,7 +56,9 @@ func SetApiRouter(router *gin.Engine) { ...@@ -56,7 +56,9 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) 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 // Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
...@@ -100,8 +102,8 @@ func SetApiRouter(router *gin.Engine) { ...@@ -100,8 +102,8 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay)
selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount) selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount)
selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay) selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay)
//selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount)
//selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay) selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting) selfRoute.PUT("/setting", controller.UpdateUserSetting)
...@@ -154,6 +156,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -154,6 +156,7 @@ func SetApiRouter(router *gin.Engine) {
subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay) subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay)
subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay) subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay)
subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay) subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay)
subscriptionRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestWaffoPancakePay)
} }
subscriptionAdminRoute := apiRouter.Group("/subscription/admin") subscriptionAdminRoute := apiRouter.Group("/subscription/admin")
subscriptionAdminRoute.Use(middleware.AdminAuth()) subscriptionAdminRoute.Use(middleware.AdminAuth())
...@@ -186,6 +189,11 @@ func SetApiRouter(router *gin.Engine) { ...@@ -186,6 +189,11 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache) optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 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) // Custom OAuth provider management (root only)
......
...@@ -8,7 +8,6 @@ import ( ...@@ -8,7 +8,6 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm" "gorm.io/gorm"
...@@ -29,7 +28,7 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB { ...@@ -29,7 +28,7 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
model.DB = db model.DB = db
model.LOG_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() { t.Cleanup(func() {
sqlDB, err := db.DB() sqlDB, err := db.DB()
...@@ -41,21 +40,6 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB { ...@@ -41,21 +40,6 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
return 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) { func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) {
db := setupWaffoPancakeTestDB(t) db := setupWaffoPancakeTestDB(t)
...@@ -65,20 +49,78 @@ func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *te ...@@ -65,20 +49,78 @@ func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *te
Money: 29, Money: 29,
TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
PaymentMethod: model.PaymentMethodWaffoPancake, PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(), CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending, Status: common.TopUpStatusPending,
} }
require.NoError(t, db.Create(topUp).Error) require.NoError(t, db.Create(topUp).Error)
tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{ tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
Data: waffoPancakeWebhookData{ Data: WaffoPancakeWebhookData{
OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(topUp.UserId),
}, },
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "ORD_5dXBtmF2HLlHfbPNm0Wcnz", tradeNo) 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) { func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) {
db := setupWaffoPancakeTestDB(t) db := setupWaffoPancakeTestDB(t)
...@@ -96,13 +138,14 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. ...@@ -96,13 +138,14 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.
Money: 29, Money: 29,
TradeNo: "WAFFO_PANCAKE-42-123456-abc123", TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
PaymentMethod: model.PaymentMethodWaffoPancake, PaymentMethod: model.PaymentMethodWaffoPancake,
PaymentProvider: model.PaymentProviderWaffoPancake,
CreateTime: time.Now().Unix(), CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending, Status: common.TopUpStatusPending,
} }
require.NoError(t, db.Create(topUp).Error) require.NoError(t, db.Create(topUp).Error)
tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{ tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
Data: waffoPancakeWebhookData{ Data: WaffoPancakeWebhookData{
OrderID: "ORD_unknown", OrderID: "ORD_unknown",
BuyerEmail: user.Email, BuyerEmail: user.Email,
Amount: "29.00", Amount: "29.00",
...@@ -112,46 +155,107 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. ...@@ -112,46 +155,107 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.
require.Empty(t, tradeNo) require.Empty(t, tradeNo)
} }
func TestResolveWaffoPancakeWebhookEnvironment(t *testing.T) { // Parity tests for ResolveWaffoPancakeSubscriptionTradeNo — same four cases
originalSandbox := setting.WaffoPancakeSandbox // as the TopUp resolver above, exercised against SubscriptionOrder records.
t.Cleanup(func() { // Drift between the two webhook flows is a real risk because they share
setting.WaffoPancakeSandbox = originalSandbox // the same buyer-identity defence-in-depth pattern.
})
testCases := []struct { func TestResolveWaffoPancakeSubscriptionTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) {
name string db := setupWaffoPancakeTestDB(t)
payload string
expected string order := &model.SubscriptionOrder{
sandbox bool UserId: 1,
}{ PlanId: 5,
{ Money: 29,
name: "test mode", TradeNo: "WAFFO_PANCAKE_SUB-1-1700000000-abc123",
payload: `{"mode":"test"}`, PaymentMethod: model.PaymentMethodWaffoPancake,
expected: "test", PaymentProvider: model.PaymentProviderWaffoPancake,
}, CreateTime: time.Now().Unix(),
{ Status: common.TopUpStatusPending,
name: "prod mode", }
payload: `{"mode":"prod"}`, require.NoError(t, db.Create(order).Error)
expected: "prod",
}, tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
{ Data: WaffoPancakeWebhookData{
name: "missing mode falls back to sandbox", OrderID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123",
payload: `{}`, MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(order.UserId),
expected: "test",
sandbox: true,
}, },
{ })
name: "invalid mode falls back to prod", require.NoError(t, err)
payload: `{"mode":"staging"}`, require.Equal(t, "WAFFO_PANCAKE_SUB-1-1700000000-abc123", tradeNo)
expected: "prod", }
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)
for _, tc := range testCases { tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
t.Run(tc.name, func(t *testing.T) { Data: WaffoPancakeWebhookData{
setting.WaffoPancakeSandbox = tc.sandbox OrderID: "WAFFO_PANCAKE_SUB-7-missing-identity",
environment := resolveWaffoPancakeWebhookEnvironment(tc.payload) },
require.Equal(t, tc.expected, environment)
}) })
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)
} }
package setting 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 ( var (
WaffoPancakeEnabled bool
WaffoPancakeSandbox bool
WaffoPancakeMerchantID string WaffoPancakeMerchantID string
WaffoPancakePrivateKey string WaffoPancakePrivateKey string
WaffoPancakeWebhookPublicKey string
WaffoPancakeWebhookTestKey string
WaffoPancakeStoreID string
WaffoPancakeProductID string
WaffoPancakeReturnURL string WaffoPancakeReturnURL string
WaffoPancakeCurrency string = "USD"
WaffoPancakeUnitPrice float64 = 1.0 WaffoPancakeUnitPrice float64 = 1.0
WaffoPancakeMinTopUp int = 1 WaffoPancakeMinTopUp int = 1
WaffoPancakeStoreID string
WaffoPancakeProductID string
) )
<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>
<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>
...@@ -53,16 +53,9 @@ const PaymentSetting = () => { ...@@ -53,16 +53,9 @@ const PaymentSetting = () => {
StripeMinTopUp: 1, StripeMinTopUp: 1,
StripePromotionCodesEnabled: false, StripePromotionCodesEnabled: false,
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '', WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '', WaffoPancakePrivateKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '', WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
'payment_setting.compliance_confirmed': false, 'payment_setting.compliance_confirmed': false,
'payment_setting.compliance_terms_version': '', 'payment_setting.compliance_terms_version': '',
'payment_setting.compliance_confirmed_at': 0, 'payment_setting.compliance_confirmed_at': 0,
...@@ -171,21 +164,13 @@ const PaymentSetting = () => { ...@@ -171,21 +164,13 @@ const PaymentSetting = () => {
case 'MinTopUp': case 'MinTopUp':
case 'StripeUnitPrice': case 'StripeUnitPrice':
case 'StripeMinTopUp': case 'StripeMinTopUp':
case 'WaffoPancakeUnitPrice':
case 'WaffoPancakeMinTopUp':
newInputs[item.key] = parseFloat(item.value); newInputs[item.key] = parseFloat(item.value);
break; break;
case 'WaffoPancakeMerchantID': case 'WaffoPancakeMerchantID':
case 'WaffoPancakePrivateKey': case 'WaffoPancakePrivateKey':
case 'WaffoPancakeStoreID':
case 'WaffoPancakeProductID':
case 'WaffoPancakeReturnURL': case 'WaffoPancakeReturnURL':
case 'WaffoPancakeCurrency':
newInputs[item.key] = item.value; newInputs[item.key] = item.value;
break; break;
case 'WaffoPancakeSandbox':
newInputs[item.key] = toBoolean(item.value);
break;
default: default:
if (item.key.endsWith('Enabled')) { if (item.key.endsWith('Enabled')) {
newInputs[item.key] = toBoolean(item.value); newInputs[item.key] = toBoolean(item.value);
...@@ -320,6 +305,13 @@ const PaymentSetting = () => { ...@@ -320,6 +305,13 @@ const PaymentSetting = () => {
hideSectionTitle hideSectionTitle
/> />
</Tabs.TabPane> </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'> <Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
<SettingsPaymentGatewayWaffo <SettingsPaymentGatewayWaffo
options={inputs} options={inputs}
...@@ -327,13 +319,6 @@ const PaymentSetting = () => { ...@@ -327,13 +319,6 @@ const PaymentSetting = () => {
hideSectionTitle hideSectionTitle
/> />
</Tabs.TabPane> </Tabs.TabPane>
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
{/* <SettingsPaymentGatewayWaffoPancake*/}
{/* options={inputs}*/}
{/* refresh={onRefresh}*/}
{/* hideSectionTitle*/}
{/* />*/}
{/*</Tabs.TabPane>*/}
</Tabs> </Tabs>
</div> </div>
</Card> </Card>
......
...@@ -47,6 +47,7 @@ import { ...@@ -47,6 +47,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { IconGift } from '@douyinfe/semi-icons'; import { IconGift } from '@douyinfe/semi-icons';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime'; import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { useActualTheme } from '../../context/Theme';
import { getCurrencyConfig } from '../../helpers/render'; import { getCurrencyConfig } from '../../helpers/render';
import SubscriptionPlansCard from './SubscriptionPlansCard'; import SubscriptionPlansCard from './SubscriptionPlansCard';
...@@ -102,6 +103,7 @@ const RechargeCard = ({ ...@@ -102,6 +103,7 @@ const RechargeCard = ({
const redeemFormApiRef = useRef(null); const redeemFormApiRef = useRef(null);
const initialTabSetRef = useRef(false); const initialTabSetRef = useRef(false);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading); const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
const actualTheme = useActualTheme();
const [activeTab, setActiveTab] = useState('topup'); const [activeTab, setActiveTab] = useState('topup');
const shouldShowSubscription = const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0; !subscriptionLoading && subscriptionPlans.length > 0;
...@@ -355,9 +357,18 @@ const RechargeCard = ({ ...@@ -355,9 +357,18 @@ const RechargeCard = ({
}} }}
/> />
) : payMethod.type === 'waffo_pancake' ? ( ) : payMethod.type === 'waffo_pancake' ? (
<CreditCard <img
size={18} src={
color='var(--semi-color-primary)' actualTheme === 'dark'
? '/waffo-logo-dark.svg'
: '/waffo-logo-light.svg'
}
alt='Waffo'
style={{
width: 18,
height: 18,
objectFit: 'contain',
}}
/> />
) : ( ) : (
<CreditCard <CreditCard
......
...@@ -40,6 +40,23 @@ import TransferModal from './modals/TransferModal'; ...@@ -40,6 +40,23 @@ import TransferModal from './modals/TransferModal';
import PaymentConfirmModal from './modals/PaymentConfirmModal'; import PaymentConfirmModal from './modals/PaymentConfirmModal';
import TopupHistoryModal from './modals/TopupHistoryModal'; 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 TopUp = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
...@@ -454,8 +471,12 @@ const TopUp = () => { ...@@ -454,8 +471,12 @@ const TopUp = () => {
const { message, data } = res.data; const { message, data } = res.data;
if (message === 'success') { if (message === 'success') {
const checkoutUrl = data?.checkout_url || ''; const checkoutUrl = data?.checkout_url || '';
if (checkoutUrl) { if (checkoutUrl && isSafeHttpCheckoutUrl(checkoutUrl)) {
window.open(checkoutUrl, '_blank'); // 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 { } else {
showError(t('支付请求失败')); showError(t('支付请求失败'));
} }
......
...@@ -1720,6 +1720,7 @@ ...@@ -1720,6 +1720,7 @@
"支付渠道": "Payment Channels", "支付渠道": "Payment Channels",
"支付设置": "Payment", "支付设置": "Payment",
"支付请求失败": "Payment request failed", "支付请求失败": "Payment request failed",
"支付跳转地址不安全": "Unsafe payment redirect URL",
"支付返回地址": "Return URL", "支付返回地址": "Return URL",
"支付金额": "Payment Amount", "支付金额": "Payment Amount",
"支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images", "支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images",
......
...@@ -1154,6 +1154,7 @@ ...@@ -1154,6 +1154,7 @@
"支付方式": "支付方式", "支付方式": "支付方式",
"支付设置": "支付设置", "支付设置": "支付设置",
"支付请求失败": "支付请求失败", "支付请求失败": "支付请求失败",
"支付跳转地址不安全": "支付跳转地址不安全",
"支付金额": "支付金额", "支付金额": "支付金额",
"支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片", "支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片",
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。",
......
<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>
<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>
...@@ -16,11 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,11 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useMemo } from 'react' import { Fragment, useMemo } from 'react'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSystemConfig } from '@/hooks/use-system-config' import { useSystemConfig } from '@/hooks/use-system-config'
import { useStatus } from '@/hooks/use-status'
interface FooterLink { interface FooterLink {
text: string text: string
...@@ -74,11 +75,56 @@ function FooterLinkItem(props: { link: FooterLink }) { ...@@ -74,11 +75,56 @@ 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 { 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 ( return (
<div className='text-muted-foreground/45 text-center text-xs sm:text-right'> <>
{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'> <span className='text-muted-foreground/45'>
&copy; {props.currentYear}{' '} &copy; {props.currentYear}{' '}
<a <a
...@@ -91,6 +137,13 @@ function ProjectAttribution(props: { currentYear: number }) { ...@@ -91,6 +137,13 @@ function ProjectAttribution(props: { currentYear: number }) {
</a> </a>
. {t(NEW_API_FOOTER_ATTRIBUTION_KEY)} . {t(NEW_API_FOOTER_ATTRIBUTION_KEY)}
</span> </span>
)
if (props.inline) {
return content
}
return (
<div className='text-muted-foreground/45 text-center text-xs sm:text-right'>
{content}
</div> </div>
) )
} }
...@@ -182,8 +235,9 @@ export function Footer(props: FooterProps) { ...@@ -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' className='custom-footer text-muted-foreground min-w-0 text-center text-sm sm:text-left'
dangerouslySetInnerHTML={{ __html: footerHtml }} 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'> <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'>
<ProjectAttribution currentYear={currentYear} /> <LegalLinks />
<ProjectAttribution currentYear={currentYear} inline />
</div> </div>
</div> </div>
</div> </div>
...@@ -235,12 +289,16 @@ export function Footer(props: FooterProps) { ...@@ -235,12 +289,16 @@ export function Footer(props: FooterProps) {
)} )}
</div> </div>
{/* Bottom section */} {/* Copyright + optional legal links inline on the left, project
<div className='border-border/30 mt-12 flex flex-col items-center justify-between gap-3 border-t pt-6 sm:flex-row'> attribution on the right; wraps on narrow screens. */}
<p className='text-muted-foreground/40 text-xs'> <div className='border-border/30 mt-12 flex flex-col items-center justify-between gap-x-3 gap-y-2 border-t pt-6 sm:flex-row'>
<div className='text-muted-foreground/40 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs sm:justify-start'>
<span>
&copy; {currentYear} {displayName}.{' '} &copy; {currentYear} {displayName}.{' '}
{props.copyright ?? t('footer.defaultCopyright')} {props.copyright ?? t('footer.defaultCopyright')}
</p> </span>
<LegalLinks leadingSeparator />
</div>
<ProjectAttribution currentYear={currentYear} /> <ProjectAttribution currentYear={currentYear} />
</div> </div>
</div> </div>
......
...@@ -122,6 +122,42 @@ export async function paySubscriptionCreem( ...@@ -122,6 +122,42 @@ export async function paySubscriptionCreem(
return res.data 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( export async function paySubscriptionEpay(
data: SubscriptionPayRequest & { payment_method: string } data: SubscriptionPayRequest & { payment_method: string }
): Promise<SubscriptionPayResponse & { url?: string }> { ): Promise<SubscriptionPayResponse & { url?: string }> {
......
...@@ -42,6 +42,7 @@ import { ...@@ -42,6 +42,7 @@ import {
paySubscriptionStripe, paySubscriptionStripe,
paySubscriptionCreem, paySubscriptionCreem,
paySubscriptionEpay, paySubscriptionEpay,
paySubscriptionWaffoPancake,
} from '../../api' } from '../../api'
import { formatDuration, formatResetPeriod } from '../../lib' import { formatDuration, formatResetPeriod } from '../../lib'
import type { PlanRecord } from '../../types' import type { PlanRecord } from '../../types'
...@@ -57,6 +58,7 @@ interface Props { ...@@ -57,6 +58,7 @@ interface Props {
plan: PlanRecord | null plan: PlanRecord | null
enableStripe?: boolean enableStripe?: boolean
enableCreem?: boolean enableCreem?: boolean
enableWaffoPancake?: boolean
enableOnlineTopUp?: boolean enableOnlineTopUp?: boolean
epayMethods?: PaymentMethod[] epayMethods?: PaymentMethod[]
purchaseLimit?: number purchaseLimit?: number
...@@ -81,9 +83,11 @@ export function SubscriptionPurchaseDialog(props: Props) { ...@@ -81,9 +83,11 @@ export function SubscriptionPurchaseDialog(props: Props) {
const hasStripe = props.enableStripe && !!plan.stripe_price_id const hasStripe = props.enableStripe && !!plan.stripe_price_id
const hasCreem = props.enableCreem && !!plan.creem_product_id const hasCreem = props.enableCreem && !!plan.creem_product_id
const hasWaffoPancake =
props.enableWaffoPancake && !!plan.waffo_pancake_product_id
const hasEpay = const hasEpay =
props.enableOnlineTopUp && (props.epayMethods || []).length > 0 props.enableOnlineTopUp && (props.epayMethods || []).length > 0
const hasAnyPayment = hasStripe || hasCreem || hasEpay const hasAnyPayment = hasStripe || hasCreem || hasWaffoPancake || hasEpay
const selectedEpayMethodLabel = const selectedEpayMethodLabel =
(props.epayMethods || []).find((m) => m.type === selectedEpayMethod) (props.epayMethods || []).find((m) => m.type === selectedEpayMethod)
?.name || ?.name ||
...@@ -139,6 +143,29 @@ export function SubscriptionPurchaseDialog(props: Props) { ...@@ -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 = const isSafari =
typeof navigator !== 'undefined' && typeof navigator !== 'undefined' &&
/^((?!chrome|android).)*safari/i.test(navigator.userAgent) /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
...@@ -262,7 +289,7 @@ export function SubscriptionPurchaseDialog(props: Props) { ...@@ -262,7 +289,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t('Select payment method')} {t('Select payment method')}
</p> </p>
{(hasStripe || hasCreem) && ( {(hasStripe || hasCreem || hasWaffoPancake) && (
<div className='grid grid-cols-2 gap-2 sm:flex'> <div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && ( {hasStripe && (
<Button <Button
...@@ -284,6 +311,16 @@ export function SubscriptionPurchaseDialog(props: Props) { ...@@ -284,6 +311,16 @@ export function SubscriptionPurchaseDialog(props: Props) {
Creem Creem
</Button> </Button>
)} )}
{hasWaffoPancake && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayWaffoPancake}
disabled={paying || limitReached}
>
Waffo Pancake
</Button>
)}
</div> </div>
)} )}
{hasEpay && ( {hasEpay && (
......
...@@ -162,6 +162,13 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] { ...@@ -162,6 +162,13 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{plan.creem_product_id && ( {plan.creem_product_id && (
<StatusBadge label='Creem' variant='neutral' copyable={false} /> <StatusBadge label='Creem' variant='neutral' copyable={false} />
)} )}
{plan.waffo_pancake_product_id && (
<StatusBadge
label='Waffo Pancake'
variant='neutral'
copyable={false}
/>
)}
</div> </div>
) )
}, },
......
...@@ -51,7 +51,13 @@ import { ...@@ -51,7 +51,13 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch' 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 { getDurationUnitOptions, getResetPeriodOptions } from '../constants'
import { import {
getPlanFormSchema, getPlanFormSchema,
...@@ -79,6 +85,10 @@ export function SubscriptionsMutateDrawer({ ...@@ -79,6 +85,10 @@ export function SubscriptionsMutateDrawer({
const { triggerRefresh } = useSubscriptions() const { triggerRefresh } = useSubscriptions()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [groupOptions, setGroupOptions] = useState<string[]>([]) 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 schema = getPlanFormSchema(t)
const form = useForm<PlanFormValues>({ const form = useForm<PlanFormValues>({
...@@ -98,11 +108,35 @@ export function SubscriptionsMutateDrawer({ ...@@ -98,11 +108,35 @@ export function SubscriptionsMutateDrawer({
if (res.success) setGroupOptions(res.data || []) if (res.success) setGroupOptions(res.data || [])
}) })
.catch(() => {}) .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]) }, [open, currentRow, form])
const durationUnit = form.watch('duration_unit') const durationUnit = form.watch('duration_unit')
const resetPeriod = form.watch('quota_reset_period') 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) => { const onSubmit = async (values: PlanFormValues) => {
setIsSubmitting(true) setIsSubmitting(true)
...@@ -130,6 +164,72 @@ export function SubscriptionsMutateDrawer({ ...@@ -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 durationUnitOpts = getDurationUnitOptions(t)
const resetPeriodOpts = getResetPeriodOptions(t) const resetPeriodOpts = getResetPeriodOptions(t)
...@@ -546,6 +646,67 @@ export function SubscriptionsMutateDrawer({ ...@@ -546,6 +646,67 @@ export function SubscriptionsMutateDrawer({
</FormItem> </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> </div>
</form> </form>
</Form> </Form>
......
...@@ -43,6 +43,7 @@ export function getPlanFormSchema(t: TFunction) { ...@@ -43,6 +43,7 @@ export function getPlanFormSchema(t: TFunction) {
upgrade_group: z.string().optional(), upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(), stripe_price_id: z.string().optional(),
creem_product_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 = { ...@@ -64,6 +65,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
upgrade_group: '', upgrade_group: '',
stripe_price_id: '', stripe_price_id: '',
creem_product_id: '', creem_product_id: '',
waffo_pancake_product_id: '',
} }
export function planToFormValues(plan: SubscriptionPlan): PlanFormValues { export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
...@@ -83,6 +85,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues { ...@@ -83,6 +85,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
upgrade_group: plan.upgrade_group || '', upgrade_group: plan.upgrade_group || '',
stripe_price_id: plan.stripe_price_id || '', stripe_price_id: plan.stripe_price_id || '',
creem_product_id: plan.creem_product_id || '', creem_product_id: plan.creem_product_id || '',
waffo_pancake_product_id: plan.waffo_pancake_product_id || '',
} }
} }
......
...@@ -40,6 +40,7 @@ export const subscriptionPlanSchema = z.object({ ...@@ -40,6 +40,7 @@ export const subscriptionPlanSchema = z.object({
upgrade_group: z.string().optional(), upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(), stripe_price_id: z.string().optional(),
creem_product_id: z.string().optional(), creem_product_id: z.string().optional(),
waffo_pancake_product_id: z.string().optional(),
}) })
export type SubscriptionPlan = z.infer<typeof subscriptionPlanSchema> export type SubscriptionPlan = z.infer<typeof subscriptionPlanSchema>
...@@ -94,8 +95,17 @@ export interface SubscriptionPayResponse { ...@@ -94,8 +95,17 @@ export interface SubscriptionPayResponse {
success: boolean success: boolean
message?: string message?: string
data?: { data?: {
// Stripe-style hosted checkout link.
pay_link?: string pay_link?: string
// Waffo Pancake / Creem hosted checkout URL.
checkout_url?: string 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 url?: string
} }
......
...@@ -96,18 +96,11 @@ const defaultBillingSettings: BillingSettings = { ...@@ -96,18 +96,11 @@ const defaultBillingSettings: BillingSettings = {
WaffoNotifyUrl: '', WaffoNotifyUrl: '',
WaffoReturnUrl: '', WaffoReturnUrl: '',
WaffoPayMethods: '[]', WaffoPayMethods: '[]',
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '', WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '', WaffoPancakePrivateKey: '',
WaffoPancakeWebhookPublicKey: '', WaffoPancakeReturnURL: '',
WaffoPancakeWebhookTestKey: '',
WaffoPancakeStoreID: '', WaffoPancakeStoreID: '',
WaffoPancakeProductID: '', WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1,
WaffoPancakeMinTopUp: 1,
'checkin_setting.enabled': false, 'checkin_setting.enabled': false,
'checkin_setting.min_quota': 1000, 'checkin_setting.min_quota': 1000,
'checkin_setting.max_quota': 10000, 'checkin_setting.max_quota': 10000,
......
...@@ -177,20 +177,12 @@ const BILLING_SECTIONS = [ ...@@ -177,20 +177,12 @@ const BILLING_SECTIONS = [
WaffoPayMethods: settings.WaffoPayMethods ?? '[]', WaffoPayMethods: settings.WaffoPayMethods ?? '[]',
}} }}
waffoPancakeDefaultValues={{ waffoPancakeDefaultValues={{
WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false,
WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false,
WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '', WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '',
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '', WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
WaffoPancakeWebhookPublicKey:
settings.WaffoPancakeWebhookPublicKey ?? '',
WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '',
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '', WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD',
WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1,
WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1,
}} }}
waffoPancakeProvisionedStoreID={settings.WaffoPancakeStoreID ?? ''}
waffoPancakeProvisionedProductID={settings.WaffoPancakeProductID ?? ''}
complianceDefaults={{ complianceDefaults={{
confirmed: settings['payment_setting.compliance_confirmed'] ?? false, confirmed: settings['payment_setting.compliance_confirmed'] ?? false,
termsVersion: termsVersion:
......
...@@ -149,6 +149,8 @@ type PaymentSettingsSectionProps = { ...@@ -149,6 +149,8 @@ type PaymentSettingsSectionProps = {
defaultValues: PaymentFormValues defaultValues: PaymentFormValues
waffoDefaultValues: WaffoSettingsValues waffoDefaultValues: WaffoSettingsValues
waffoPancakeDefaultValues: WaffoPancakeSettingsValues waffoPancakeDefaultValues: WaffoPancakeSettingsValues
waffoPancakeProvisionedStoreID?: string
waffoPancakeProvisionedProductID?: string
complianceDefaults: PaymentComplianceDefaults complianceDefaults: PaymentComplianceDefaults
} }
...@@ -156,6 +158,8 @@ export function PaymentSettingsSection({ ...@@ -156,6 +158,8 @@ export function PaymentSettingsSection({
defaultValues, defaultValues,
waffoDefaultValues, waffoDefaultValues,
waffoPancakeDefaultValues, waffoPancakeDefaultValues,
waffoPancakeProvisionedStoreID,
waffoPancakeProvisionedProductID,
complianceDefaults, complianceDefaults,
}: PaymentSettingsSectionProps) { }: PaymentSettingsSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
...@@ -1468,11 +1472,15 @@ export function PaymentSettingsSection({ ...@@ -1468,11 +1472,15 @@ export function PaymentSettingsSection({
<Separator /> <Separator />
<WaffoSettingsSection defaultValues={waffoDefaultValues} /> <WaffoPancakeSettingsSection
defaultValues={waffoPancakeDefaultValues}
provisionedStoreID={waffoPancakeProvisionedStoreID}
provisionedProductID={waffoPancakeProvisionedProductID}
/>
<Separator /> <Separator />
<WaffoPancakeSettingsSection defaultValues={waffoPancakeDefaultValues} /> <WaffoSettingsSection defaultValues={waffoDefaultValues} />
{/* eslint-enable react-hooks/refs */} {/* eslint-enable react-hooks/refs */}
</SettingsSection> </SettingsSection>
) )
......
/*
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
}
...@@ -43,7 +43,6 @@ import { ...@@ -43,7 +43,6 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option' import { useUpdateOption } from '../hooks/use-update-option'
export interface WaffoSettingsValues { export interface WaffoSettingsValues {
...@@ -212,12 +211,17 @@ export function WaffoSettingsSection(props: Props) { ...@@ -212,12 +211,17 @@ export function WaffoSettingsSection(props: Props) {
return ( return (
<> <>
<SettingsSection <div className='space-y-4 pt-4'>
title={t('Waffo Payment Gateway')} <div>
description={t( <h3 className='text-lg font-medium'>
'Configure Waffo payment aggregation platform integration' {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> <Alert>
<AlertDescription className='text-xs'> <AlertDescription className='text-xs'>
{t( {t(
...@@ -416,7 +420,7 @@ export function WaffoSettingsSection(props: Props) { ...@@ -416,7 +420,7 @@ export function WaffoSettingsSection(props: Props) {
<Button onClick={handleSave} disabled={loading}> <Button onClick={handleSave} disabled={loading}>
{loading ? t('Saving...') : t('Save Changes')} {loading ? t('Saving...') : t('Save Changes')}
</Button> </Button>
</SettingsSection> </div>
<Dialog open={methodDialogOpen} onOpenChange={setMethodDialogOpen}> <Dialog open={methodDialogOpen} onOpenChange={setMethodDialogOpen}>
<DialogContent> <DialogContent>
......
...@@ -256,18 +256,13 @@ export type BillingSettings = { ...@@ -256,18 +256,13 @@ export type BillingSettings = {
WaffoNotifyUrl: string WaffoNotifyUrl: string
WaffoReturnUrl: string WaffoReturnUrl: string
WaffoPayMethods: string WaffoPayMethods: string
WaffoPancakeEnabled: boolean
WaffoPancakeSandbox: boolean
WaffoPancakeMerchantID: string WaffoPancakeMerchantID: string
WaffoPancakePrivateKey: string WaffoPancakePrivateKey: string
WaffoPancakeWebhookPublicKey: string WaffoPancakeReturnURL: string
WaffoPancakeWebhookTestKey: string // Bound by the operator through the catalog flow in the admin Pancake
// section (saved via /api/option/waffo-pancake/save).
WaffoPancakeStoreID: string WaffoPancakeStoreID: string
WaffoPancakeProductID: string WaffoPancakeProductID: string
WaffoPancakeReturnURL: string
WaffoPancakeCurrency: string
WaffoPancakeUnitPrice: number
WaffoPancakeMinTopUp: number
'checkin_setting.enabled': boolean 'checkin_setting.enabled': boolean
'checkin_setting.min_quota': number 'checkin_setting.min_quota': number
'checkin_setting.max_quota': number 'checkin_setting.max_quota': number
......
...@@ -111,6 +111,7 @@ export function SubscriptionPlansCard({ ...@@ -111,6 +111,7 @@ export function SubscriptionPlansCard({
const enableStripe = !!topupInfo?.enable_stripe_topup const enableStripe = !!topupInfo?.enable_stripe_topup
const enableCreem = !!topupInfo?.enable_creem_topup const enableCreem = !!topupInfo?.enable_creem_topup
const enableWaffoPancake = !!topupInfo?.enable_waffo_pancake_topup
const enableOnlineTopUp = !!topupInfo?.enable_online_topup const enableOnlineTopUp = !!topupInfo?.enable_online_topup
const epayMethods = useMemo( const epayMethods = useMemo(
() => getEpayMethods(topupInfo?.pay_methods), () => getEpayMethods(topupInfo?.pay_methods),
...@@ -629,6 +630,7 @@ export function SubscriptionPlansCard({ ...@@ -629,6 +630,7 @@ export function SubscriptionPlansCard({
plan={selectedPlan} plan={selectedPlan}
enableStripe={enableStripe} enableStripe={enableStripe}
enableCreem={enableCreem} enableCreem={enableCreem}
enableWaffoPancake={enableWaffoPancake}
enableOnlineTopUp={enableOnlineTopUp} enableOnlineTopUp={enableOnlineTopUp}
epayMethods={epayMethods} epayMethods={epayMethods}
purchaseLimit={ purchaseLimit={
......
...@@ -59,11 +59,10 @@ function getErrorMessage(message: string | undefined, data: unknown): string { ...@@ -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 * Same-tab redirect (window.location.href) rather than window.open: the
* submission, so we open the returned URL in a new tab once the backend * user-gesture context is lost across the await, so popups get blocked.
* returns a successful response.
*/ */
export function useWaffoPancakePayment() { export function useWaffoPancakePayment() {
const [processing, setProcessing] = useState(false) const [processing, setProcessing] = useState(false)
...@@ -85,8 +84,8 @@ export function useWaffoPancakePayment() { ...@@ -85,8 +84,8 @@ export function useWaffoPancakePayment() {
toast.error(i18next.t('Invalid payment redirect URL')) toast.error(i18next.t('Invalid payment redirect URL'))
return false return false
} }
window.open(checkoutUrl, '_blank', 'noopener,noreferrer')
toast.success(i18next.t('Redirecting to payment page...')) toast.success(i18next.t('Redirecting to payment page...'))
window.location.href = checkoutUrl
return true return true
} }
} }
......
...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { CreditCard, Landmark } from 'lucide-react' import { CreditCard, Landmark } from 'lucide-react'
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si' import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'
import i18next from 'i18next'
import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants' import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants'
// ============================================================================ // ============================================================================
...@@ -121,11 +122,25 @@ export function getPaymentIcon( ...@@ -121,11 +122,25 @@ export function getPaymentIcon(
/> />
) )
case PAYMENT_TYPES.WAFFO_PANCAKE: 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 ( return (
<CreditCard <span
className={className} className={`inline-flex items-center justify-center leading-none ${className}`}
style={{ color: PAYMENT_ICON_COLORS[PAYMENT_TYPES.WAFFO_PANCAKE] }} 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: default:
return <CreditCard className={className} /> return <CreditCard className={className} />
......
...@@ -51,6 +51,11 @@ export type WaffoPancakePaymentResponse = ApiResponse< ...@@ -51,6 +51,11 @@ export type WaffoPancakePaymentResponse = ApiResponse<
session_id?: string session_id?: string
expires_at?: number | string expires_at?: number | string
order_id?: 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 | string
> >
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment