diff --git a/controller/token_test.go b/controller/token_test.go
index 0c0f504b347..6375ce7f9db 100644
--- a/controller/token_test.go
+++ b/controller/token_test.go
@@ -48,21 +48,21 @@ type sqliteColumnInfo struct {
}
type legacyToken struct {
- Id int `gorm:"primaryKey"`
- UserId int `gorm:"index"`
- Key string `gorm:"column:key;type:char(48);uniqueIndex"`
- Status int `gorm:"default:1"`
- Name string `gorm:"index"`
- CreatedTime int64 `gorm:"bigint"`
- AccessedTime int64 `gorm:"bigint"`
- ExpiredTime int64 `gorm:"bigint;default:-1"`
- RemainQuota int `gorm:"default:0"`
+ Id int `gorm:"primaryKey"`
+ UserId int `gorm:"index"`
+ Key string `gorm:"column:key;type:char(48);uniqueIndex"`
+ Status int `gorm:"default:1"`
+ Name string `gorm:"index"`
+ CreatedTime int64 `gorm:"bigint"`
+ AccessedTime int64 `gorm:"bigint"`
+ ExpiredTime int64 `gorm:"bigint;default:-1"`
+ RemainQuota int `gorm:"default:0"`
UnlimitedQuota bool
ModelLimitsEnabled bool
- ModelLimits string `gorm:"type:text"`
- AllowIps *string `gorm:"default:''"`
- UsedQuota int `gorm:"default:0"`
- Group string `gorm:"column:group;default:''"`
+ ModelLimits string `gorm:"type:text"`
+ AllowIps *string `gorm:"default:''"`
+ UsedQuota int `gorm:"default:0"`
+ Group string `gorm:"column:group;default:''"`
CrossGroupRetry bool
DeletedAt gorm.DeletedAt `gorm:"index"`
}
diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go
index 11c581fa699..d50614c0703 100644
--- a/controller/topup_waffo_pancake.go
+++ b/controller/topup_waffo_pancake.go
@@ -52,9 +52,6 @@ func RequestWaffoPancakeAmount(c *gin.Context) {
func getWaffoPancakePayMoney(amount int64, group string) float64 {
dAmount := decimal.NewFromInt(amount)
- if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
- dAmount = dAmount.Div(decimal.NewFromFloat(common.QuotaPerUnit))
- }
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
@@ -74,20 +71,6 @@ func getWaffoPancakePayMoney(amount int64, group string) float64 {
return payMoney.InexactFloat64()
}
-func normalizeWaffoPancakeTopUpAmount(amount int64) int64 {
- if operation_setting.GetQuotaDisplayType() != operation_setting.QuotaDisplayTypeTokens {
- return amount
- }
-
- normalized := decimal.NewFromInt(amount).
- Div(decimal.NewFromFloat(common.QuotaPerUnit)).
- IntPart()
- if normalized < 1 {
- return 1
- }
- return normalized
-}
-
func formatWaffoPancakeAmount(payMoney float64) string {
return decimal.NewFromFloat(payMoney).StringFixed(2)
}
@@ -157,9 +140,10 @@ func RequestWaffoPancakePay(c *gin.Context) {
}
tradeNo := fmt.Sprintf("WAFFO_PANCAKE-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
+ currency := strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency))
topUp := &model.TopUp{
UserId: id,
- Amount: normalizeWaffoPancakeTopUpAmount(req.Amount),
+ Amount: req.Amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: model.PaymentMethodWaffoPancake,
@@ -178,7 +162,7 @@ func RequestWaffoPancakePay(c *gin.Context) {
StoreID: setting.WaffoPancakeStoreID,
ProductID: setting.WaffoPancakeProductID,
ProductType: "onetime",
- Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)),
+ Currency: currency,
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
Amount: formatWaffoPancakeAmount(payMoney),
TaxIncluded: false,
@@ -187,6 +171,14 @@ func RequestWaffoPancakePay(c *gin.Context) {
BuyerEmail: getWaffoPancakeBuyerEmail(user),
SuccessURL: getWaffoPancakeReturnURL(),
ExpiresInSeconds: &expiresInSeconds,
+ Metadata: buildWaffoPancakeOrderMetadata(
+ id,
+ tradeNo,
+ req.Amount,
+ req.Amount,
+ payMoney,
+ currency,
+ ),
})
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
@@ -208,6 +200,26 @@ func RequestWaffoPancakePay(c *gin.Context) {
})
}
+func buildWaffoPancakeOrderMetadata(userID int, tradeNo string, requestedAmount int64, storedAmount int64, money float64, currency string) *service.WaffoPancakeOrderMetadata {
+ mode := "prod"
+ if setting.WaffoPancakeSandbox {
+ mode = "test"
+ }
+
+ return &service.WaffoPancakeOrderMetadata{
+ TradeNo: tradeNo,
+ UserID: userID,
+ PaymentMethod: model.PaymentMethodWaffoPancake,
+ RequestedAmount: requestedAmount,
+ StoredAmount: storedAmount,
+ Money: formatWaffoPancakeAmount(money),
+ Currency: currency,
+ StoreID: setting.WaffoPancakeStoreID,
+ ProductID: setting.WaffoPancakeProductID,
+ Mode: mode,
+ }
+}
+
func WaffoPancakeWebhook(c *gin.Context) {
if !isWaffoPancakeWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
@@ -248,7 +260,7 @@ func WaffoPancakeWebhook(c *gin.Context) {
LockOrder(tradeNo)
defer UnlockOrder(tradeNo)
- if err := model.RechargeWaffoPancake(tradeNo); err != nil {
+ if err := model.RechargeWaffoPancake(tradeNo, c.ClientIP()); 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
diff --git a/controller/topup_waffo_pancake_test.go b/controller/topup_waffo_pancake_test.go
index 483dd7b793f..71c5ae4ef97 100644
--- a/controller/topup_waffo_pancake_test.go
+++ b/controller/topup_waffo_pancake_test.go
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/stretchr/testify/require"
@@ -27,6 +28,34 @@ func TestFormatWaffoPancakeAmount_UsesDisplayPriceString(t *testing.T) {
}
}
+func TestBuildWaffoPancakeOrderMetadata(t *testing.T) {
+ originalSandbox := setting.WaffoPancakeSandbox
+ originalStoreID := setting.WaffoPancakeStoreID
+ originalProductID := setting.WaffoPancakeProductID
+ t.Cleanup(func() {
+ setting.WaffoPancakeSandbox = originalSandbox
+ setting.WaffoPancakeStoreID = originalStoreID
+ setting.WaffoPancakeProductID = originalProductID
+ })
+
+ setting.WaffoPancakeSandbox = true
+ setting.WaffoPancakeStoreID = "store_123"
+ setting.WaffoPancakeProductID = "product_456"
+
+ metadata := buildWaffoPancakeOrderMetadata(42, "WAFFO_PANCAKE-42-123456-abc123", 1000, 10, 29, "USD")
+
+ require.Equal(t, "WAFFO_PANCAKE-42-123456-abc123", metadata.TradeNo)
+ require.Equal(t, 42, metadata.UserID)
+ require.Equal(t, model.PaymentMethodWaffoPancake, metadata.PaymentMethod)
+ require.Equal(t, int64(1000), metadata.RequestedAmount)
+ require.Equal(t, int64(10), metadata.StoredAmount)
+ require.Equal(t, "29.00", metadata.Money)
+ require.Equal(t, "USD", metadata.Currency)
+ require.Equal(t, "store_123", metadata.StoreID)
+ require.Equal(t, "product_456", metadata.ProductID)
+ require.Equal(t, "test", metadata.Mode)
+}
+
func TestGetWaffoPancakePayMoney(t *testing.T) {
originalUnitPrice := setting.WaffoPancakeUnitPrice
originalQuotaDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
@@ -66,11 +95,11 @@ func TestGetWaffoPancakePayMoney(t *testing.T) {
expected: 24,
},
{
- name: "tokens display converts quota to display units before pricing",
- amount: int64(common.QuotaPerUnit * 3),
+ name: "quota display type does not affect Waffo Pancake pricing",
+ amount: 10,
group: "vip",
quotaDisplayType: operation_setting.QuotaDisplayTypeTokens,
- expected: 4.5,
+ expected: 24,
},
{
name: "non-positive discount falls back to no discount",
diff --git a/dto/gemini.go b/dto/gemini.go
index 489ebea534b..a8314d87483 100644
--- a/dto/gemini.go
+++ b/dto/gemini.go
@@ -44,9 +44,9 @@ func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
}
type ToolConfig struct {
- FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
- RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
- IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
+ FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
+ RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
+ IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
}
type FunctionCallingConfig struct {
diff --git a/model/log.go b/model/log.go
index 7edf24b4091..dca4c3fbf0e 100644
--- a/model/log.go
+++ b/model/log.go
@@ -17,24 +17,24 @@ import (
)
type Log struct {
- Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
- UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
- CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"`
- Type int `json:"type" gorm:"index:idx_created_at_type"`
- Content string `json:"content"`
- Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
- TokenName string `json:"token_name" gorm:"index;default:''"`
- ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
- Quota int `json:"quota" gorm:"default:0"`
- PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
- CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
- UseTime int `json:"use_time" gorm:"default:0"`
- IsStream bool `json:"is_stream"`
- ChannelId int `json:"channel" gorm:"index"`
- ChannelName string `json:"channel_name" gorm:"->"`
- TokenId int `json:"token_id" gorm:"default:0;index"`
- Group string `json:"group" gorm:"index"`
- Ip string `json:"ip" gorm:"index;default:''"`
+ Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
+ UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
+ CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"`
+ Type int `json:"type" gorm:"index:idx_created_at_type"`
+ Content string `json:"content"`
+ Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
+ TokenName string `json:"token_name" gorm:"index;default:''"`
+ ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
+ Quota int `json:"quota" gorm:"default:0"`
+ PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
+ CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
+ UseTime int `json:"use_time" gorm:"default:0"`
+ IsStream bool `json:"is_stream"`
+ ChannelId int `json:"channel" gorm:"index"`
+ ChannelName string `json:"channel_name" gorm:"->"`
+ TokenId int `json:"token_id" gorm:"default:0;index"`
+ Group string `json:"group" gorm:"index"`
+ Ip string `json:"ip" gorm:"index;default:''"`
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
Other string `json:"other"`
diff --git a/model/payment_method_guard_test.go b/model/payment_method_guard_test.go
index 7f4f15cc34e..b3b6fc72584 100644
--- a/model/payment_method_guard_test.go
+++ b/model/payment_method_guard_test.go
@@ -93,7 +93,7 @@ func TestRechargeWaffoPancake_RejectsMismatchedPaymentMethod(t *testing.T) {
insertUserForPaymentGuardTest(t, 101, 0)
insertTopUpForPaymentGuardTest(t, "waffo-pancake-guard", 101, PaymentProviderStripe)
- err := RechargeWaffoPancake("waffo-pancake-guard")
+ err := RechargeWaffoPancake("waffo-pancake-guard", "203.0.113.10")
require.Error(t, err)
topUp := GetTopUpByTradeNo("waffo-pancake-guard")
@@ -102,6 +102,31 @@ func TestRechargeWaffoPancake_RejectsMismatchedPaymentMethod(t *testing.T) {
assert.Equal(t, 0, getUserQuotaForPaymentGuardTest(t, 101))
}
+func TestRechargeWaffoPancake_RecordsTopupAuditInfo(t *testing.T) {
+ truncateTables(t)
+
+ insertUserForPaymentGuardTest(t, 102, 0)
+ insertTopUpForPaymentGuardTest(t, "waffo-pancake-audit", 102, PaymentProviderWaffoPancake)
+
+ err := RechargeWaffoPancake("waffo-pancake-audit", "203.0.113.11")
+ require.NoError(t, err)
+
+ var log Log
+ require.NoError(t, LOG_DB.Where("user_id = ? AND type = ?", 102, LogTypeTopup).First(&log).Error)
+ assert.Equal(t, "203.0.113.11", log.Ip)
+
+ var other map[string]any
+ require.NoError(t, common.Unmarshal([]byte(log.Other), &other))
+ adminInfo, ok := other["admin_info"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "203.0.113.11", adminInfo["caller_ip"])
+ assert.Equal(t, PaymentMethodWaffoPancake, adminInfo["payment_method"])
+ assert.Equal(t, PaymentMethodWaffoPancake, adminInfo["callback_payment_method"])
+ assert.NotEmpty(t, adminInfo["server_ip"])
+ assert.NotNil(t, adminInfo["node_name"])
+ assert.NotEmpty(t, adminInfo["version"])
+}
+
func TestUpdatePendingTopUpStatus_RejectsMismatchedPaymentProvider(t *testing.T) {
testCases := []struct {
name string
diff --git a/model/topup.go b/model/topup.go
index c071b77b543..9b31e9a4c9b 100644
--- a/model/topup.go
+++ b/model/topup.go
@@ -525,7 +525,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
return nil
}
-func RechargeWaffoPancake(tradeNo string) (err error) {
+func RechargeWaffoPancake(tradeNo string, callerIp string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
@@ -580,7 +580,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
}
if quotaToAdd > 0 {
- RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
+ RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffoPancake)
}
return nil
diff --git a/pkg/billingexpr/compile.go b/pkg/billingexpr/compile.go
index c41aed75c71..a6c7b8f7221 100644
--- a/pkg/billingexpr/compile.go
+++ b/pkg/billingexpr/compile.go
@@ -39,30 +39,30 @@ var (
// compileEnvPrototypeV1 is the v1 type-checking prototype used at compile time.
var compileEnvPrototypeV1 = map[string]interface{}{
- "p": float64(0),
- "c": float64(0),
- "len": float64(0),
- "cr": float64(0),
- "cc": float64(0),
- "cc1h": float64(0),
- "img": float64(0),
- "img_o": float64(0),
- "ai": float64(0),
- "ao": float64(0),
- "tier": func(string, float64) float64 { return 0 },
- "header": func(string) string { return "" },
- "param": func(string) interface{} { return nil },
- "has": func(interface{}, string) bool { return false },
- "hour": func(string) int { return 0 },
- "minute": func(string) int { return 0 },
- "weekday": func(string) int { return 0 },
- "month": func(string) int { return 0 },
- "day": func(string) int { return 0 },
- "max": math.Max,
- "min": math.Min,
- "abs": math.Abs,
- "ceil": math.Ceil,
- "floor": math.Floor,
+ "p": float64(0),
+ "c": float64(0),
+ "len": float64(0),
+ "cr": float64(0),
+ "cc": float64(0),
+ "cc1h": float64(0),
+ "img": float64(0),
+ "img_o": float64(0),
+ "ai": float64(0),
+ "ao": float64(0),
+ "tier": func(string, float64) float64 { return 0 },
+ "header": func(string) string { return "" },
+ "param": func(string) interface{} { return nil },
+ "has": func(interface{}, string) bool { return false },
+ "hour": func(string) int { return 0 },
+ "minute": func(string) int { return 0 },
+ "weekday": func(string) int { return 0 },
+ "month": func(string) int { return 0 },
+ "day": func(string) int { return 0 },
+ "max": math.Max,
+ "min": math.Min,
+ "abs": math.Abs,
+ "ceil": math.Ceil,
+ "floor": math.Floor,
}
func getCompileEnv(version int) map[string]interface{} {
diff --git a/pkg/billingexpr/run.go b/pkg/billingexpr/run.go
index d477d44e91d..7c0f2ecdd80 100644
--- a/pkg/billingexpr/run.go
+++ b/pkg/billingexpr/run.go
@@ -53,16 +53,16 @@ func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (flo
headers := normalizeHeaders(request.Headers)
env := map[string]interface{}{
- "p": params.P,
- "c": params.C,
- "len": params.Len,
- "cr": params.CR,
- "cc": params.CC,
- "cc1h": params.CC1h,
- "img": params.Img,
+ "p": params.P,
+ "c": params.C,
+ "len": params.Len,
+ "cr": params.CR,
+ "cc": params.CC,
+ "cc1h": params.CC1h,
+ "img": params.Img,
"img_o": params.ImgO,
- "ai": params.AI,
- "ao": params.AO,
+ "ai": params.AI,
+ "ao": params.AO,
"tier": func(name string, value float64) float64 {
trace.MatchedTier = name
trace.Cost = value
@@ -94,10 +94,10 @@ func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (flo
"month": func(tz string) int { return int(timeInZone(tz).Month()) },
"day": func(tz string) int { return timeInZone(tz).Day() },
"max": math.Max,
- "min": math.Min,
- "abs": math.Abs,
- "ceil": math.Ceil,
- "floor": math.Floor,
+ "min": math.Min,
+ "abs": math.Abs,
+ "ceil": math.Ceil,
+ "floor": math.Floor,
}
out, err := expr.Run(prog, env)
diff --git a/router/api-router.go b/router/api-router.go
index 64ccbe15595..85e12aca0ff 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -56,7 +56,7 @@ 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)
+ apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
@@ -100,8 +100,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)
diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go
index 9033c37f37d..6e189274059 100644
--- a/service/waffo_pancake.go
+++ b/service/waffo_pancake.go
@@ -35,6 +35,19 @@ type WaffoPancakePriceSnapshot struct {
TaxCategory string `json:"taxCategory"`
}
+type WaffoPancakeOrderMetadata struct {
+ TradeNo string `json:"trade_no"`
+ UserID int `json:"user_id"`
+ PaymentMethod string `json:"payment_method"`
+ RequestedAmount int64 `json:"requested_amount"`
+ StoredAmount int64 `json:"stored_amount"`
+ Money string `json:"money"`
+ Currency string `json:"currency"`
+ StoreID string `json:"store_id"`
+ ProductID string `json:"product_id"`
+ Mode string `json:"mode"`
+}
+
type WaffoPancakeCreateSessionParams struct {
StoreID string `json:"storeId"`
ProductID string `json:"productId"`
@@ -44,6 +57,7 @@ type WaffoPancakeCreateSessionParams struct {
BuyerEmail string `json:"buyerEmail,omitempty"`
SuccessURL string `json:"successUrl,omitempty"`
ExpiresInSeconds *int `json:"expiresInSeconds,omitempty"`
+ Metadata *WaffoPancakeOrderMetadata `json:"metadata,omitempty"`
}
type WaffoPancakeCheckoutSession struct {
@@ -64,13 +78,14 @@ type waffoPancakeCreateSessionResponse struct {
}
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"`
+ 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"`
+ OrderMetadata WaffoPancakeOrderMetadata `json:"orderMetadata"`
}
type waffoPancakeWebhookEvent struct {
@@ -157,6 +172,9 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake
func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*waffoPancakeWebhookEvent, error) {
environment := resolveWaffoPancakeWebhookEnvironment(payload)
+ if !isWaffoPancakeWebhookEnvironmentAllowed(environment) {
+ return nil, fmt.Errorf("webhook environment mismatch")
+ }
return verifyWaffoPancakeWebhook(payload, signatureHeader, environment)
}
@@ -165,15 +183,24 @@ func ResolveWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) (string, error)
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
- }
- return "", fmt.Errorf("waffo pancake order not found for webhook orderId=%s", tradeNo)
+ metadata := event.Data.OrderMetadata
+ tradeNo := strings.TrimSpace(metadata.TradeNo)
+ if tradeNo == "" {
+ return "", fmt.Errorf("missing webhook orderMetadata.trade_no")
+ }
+
+ topUp := model.GetTopUpByTradeNo(tradeNo)
+ if topUp == nil {
+ return "", fmt.Errorf("waffo pancake order not found for metadata trade_no=%s", tradeNo)
+ }
+ if topUp.PaymentMethod != model.PaymentMethodWaffoPancake {
+ return "", fmt.Errorf("waffo pancake payment method mismatch trade_no=%s", tradeNo)
+ }
+ if metadata.UserID != topUp.UserId {
+ return "", fmt.Errorf("webhook orderMetadata.user_id mismatch")
}
- return "", fmt.Errorf("missing webhook orderId")
+ return tradeNo, nil
}
func normalizeRSAPrivateKey(raw string) (string, error) {
@@ -352,6 +379,13 @@ func resolveWaffoPancakeWebhookPublicKey(environment string) string {
return strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
}
+func isWaffoPancakeWebhookEnvironmentAllowed(environment string) bool {
+ if setting.WaffoPancakeSandbox {
+ return environment == "test"
+ }
+ return environment == "prod"
+}
+
func verifyWaffoPancakeWebhookWithKey(signatureInput string, signaturePart string, rawPublicKey string) error {
publicKeyPEM, err := normalizeRSAPublicKey(rawPublicKey)
if err != nil {
diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go
index eeb1012b076..6f798ef2662 100644
--- a/service/waffo_pancake_test.go
+++ b/service/waffo_pancake_test.go
@@ -1,7 +1,15 @@
package service
import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
"fmt"
+ "strconv"
"strings"
"testing"
"time"
@@ -56,7 +64,7 @@ func TestWaffoPancakeCreateSessionResponseParsesDocumentedPayload(t *testing.T)
require.Empty(t, result.Data.OrderID)
}
-func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) {
+func TestResolveWaffoPancakeTradeNo_RejectsWebhookOrderIDOnly(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
topUp := &model.TopUp{
@@ -75,11 +83,116 @@ func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *te
OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
},
})
+ require.Error(t, err)
+ require.Empty(t, tradeNo)
+}
+
+func TestResolveWaffoPancakeTradeNo_UsesOrderMetadataTradeNo(t *testing.T) {
+ db := setupWaffoPancakeTestDB(t)
+
+ topUp := &model.TopUp{
+ UserId: 42,
+ Amount: 10,
+ Money: 29,
+ TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
+ PaymentMethod: model.PaymentMethodWaffoPancake,
+ CreateTime: time.Now().Unix(),
+ Status: common.TopUpStatusPending,
+ }
+ require.NoError(t, db.Create(topUp).Error)
+
+ var event waffoPancakeWebhookEvent
+ require.NoError(t, common.Unmarshal([]byte(`{
+ "storeId": "store_123",
+ "mode": "test",
+ "data": {
+ "orderId": "ORD_remote_123",
+ "currency": "USD",
+ "amount": "29.00",
+ "orderMetadata": {
+ "trade_no": "WAFFO_PANCAKE-42-123456-abc123",
+ "user_id": 42,
+ "payment_method": "waffo_pancake",
+ "stored_amount": 10,
+ "money": "29.00",
+ "currency": "USD",
+ "store_id": "store_123",
+ "product_id": "product_456",
+ "mode": "test"
+ }
+ }
+ }`), &event))
+
+ tradeNo, err := ResolveWaffoPancakeTradeNo(&event)
require.NoError(t, err)
- require.Equal(t, "ORD_5dXBtmF2HLlHfbPNm0Wcnz", tradeNo)
+ require.Equal(t, "WAFFO_PANCAKE-42-123456-abc123", tradeNo)
+}
+
+func TestResolveWaffoPancakeTradeNo_RejectsMetadataUserMismatch(t *testing.T) {
+ db := setupWaffoPancakeTestDB(t)
+
+ topUp := &model.TopUp{
+ UserId: 42,
+ Amount: 10,
+ Money: 29,
+ TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
+ PaymentMethod: model.PaymentMethodWaffoPancake,
+ CreateTime: time.Now().Unix(),
+ Status: common.TopUpStatusPending,
+ }
+ require.NoError(t, db.Create(topUp).Error)
+
+ var event waffoPancakeWebhookEvent
+ require.NoError(t, common.Unmarshal([]byte(`{
+ "storeId": "store_123",
+ "mode": "test",
+ "data": {
+ "orderId": "WAFFO_PANCAKE-42-123456-abc123",
+ "currency": "USD",
+ "amount": "29.00",
+ "orderMetadata": {
+ "trade_no": "WAFFO_PANCAKE-42-123456-abc123",
+ "user_id": 99,
+ "payment_method": "waffo_pancake",
+ "stored_amount": 10,
+ "money": "29.00",
+ "currency": "USD",
+ "store_id": "store_123",
+ "product_id": "product_456",
+ "mode": "test"
+ }
+ }
+ }`), &event))
+
+ tradeNo, err := ResolveWaffoPancakeTradeNo(&event)
+ require.Error(t, err)
+ require.Empty(t, tradeNo)
+}
+
+func TestResolveWaffoPancakeTradeNo_RejectsWebhookWithoutMetadata(t *testing.T) {
+ db := setupWaffoPancakeTestDB(t)
+
+ topUp := &model.TopUp{
+ UserId: 42,
+ Amount: 10,
+ Money: 29,
+ TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
+ PaymentMethod: model.PaymentMethodWaffoPancake,
+ CreateTime: time.Now().Unix(),
+ Status: common.TopUpStatusPending,
+ }
+ require.NoError(t, db.Create(topUp).Error)
+
+ tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{
+ Data: waffoPancakeWebhookData{
+ OrderID: "WAFFO_PANCAKE-42-123456-abc123",
+ },
+ })
+ require.Error(t, err)
+ require.Empty(t, tradeNo)
}
-func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) {
+func TestResolveWaffoPancakeTradeNo_FailsWhenMetadataTradeNoIsUnknown(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
user := &model.User{
@@ -102,10 +215,24 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.
require.NoError(t, db.Create(topUp).Error)
tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{
+ StoreID: "store_123",
+ Mode: "test",
Data: waffoPancakeWebhookData{
- OrderID: "ORD_unknown",
+ OrderID: "ORD_remote_123",
BuyerEmail: user.Email,
+ Currency: "USD",
Amount: "29.00",
+ OrderMetadata: WaffoPancakeOrderMetadata{
+ TradeNo: "WAFFO_PANCAKE-42-unknown",
+ UserID: 42,
+ PaymentMethod: model.PaymentMethodWaffoPancake,
+ StoredAmount: int64(10),
+ Money: "29.00",
+ Currency: "USD",
+ StoreID: "store_123",
+ ProductID: "product_456",
+ Mode: "test",
+ },
},
})
require.Error(t, err)
@@ -155,3 +282,177 @@ func TestResolveWaffoPancakeWebhookEnvironment(t *testing.T) {
})
}
}
+
+func TestVerifyConfiguredWaffoPancakeWebhook_VerifiesXWaffoSignature(t *testing.T) {
+ privateKey, publicKey := generateWaffoPancakeWebhookKeyPair(t)
+ restoreWaffoPancakeWebhookSettings(t)
+ setting.WaffoPancakeSandbox = false
+ setting.WaffoPancakeWebhookPublicKey = publicKey
+
+ payload := testWaffoPancakeWebhookPayload()
+ signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10))
+
+ event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature)
+ require.NoError(t, err)
+ require.NotNil(t, event)
+ require.Equal(t, "order.completed", event.EventType)
+ require.Equal(t, "WAFFO_PANCAKE-42-123456-abc123", event.Data.OrderMetadata.TradeNo)
+}
+
+func TestVerifyConfiguredWaffoPancakeWebhook_RejectsMissingSignature(t *testing.T) {
+ _, publicKey := generateWaffoPancakeWebhookKeyPair(t)
+ restoreWaffoPancakeWebhookSettings(t)
+ setting.WaffoPancakeSandbox = false
+ setting.WaffoPancakeWebhookPublicKey = publicKey
+
+ event, err := VerifyConfiguredWaffoPancakeWebhook(testWaffoPancakeWebhookPayload(), "")
+ require.Error(t, err)
+ require.Nil(t, event)
+ require.Contains(t, err.Error(), "missing X-Waffo-Signature header")
+}
+
+func TestVerifyConfiguredWaffoPancakeWebhook_RejectsTamperedPayload(t *testing.T) {
+ privateKey, publicKey := generateWaffoPancakeWebhookKeyPair(t)
+ restoreWaffoPancakeWebhookSettings(t)
+ setting.WaffoPancakeSandbox = false
+ setting.WaffoPancakeWebhookPublicKey = publicKey
+
+ payload := testWaffoPancakeWebhookPayload()
+ signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10))
+ tamperedPayload := strings.Replace(payload, "WAFFO_PANCAKE-42-123456-abc123", "WAFFO_PANCAKE-42-tampered", 1)
+
+ event, err := VerifyConfiguredWaffoPancakeWebhook(tamperedPayload, signature)
+ require.Error(t, err)
+ require.Nil(t, event)
+ require.Contains(t, err.Error(), "invalid webhook signature")
+}
+
+func TestVerifyConfiguredWaffoPancakeWebhook_RejectsWrongWebhookPublicKey(t *testing.T) {
+ privateKey, _ := generateWaffoPancakeWebhookKeyPair(t)
+ _, wrongPublicKey := generateWaffoPancakeWebhookKeyPair(t)
+ restoreWaffoPancakeWebhookSettings(t)
+ setting.WaffoPancakeSandbox = false
+ setting.WaffoPancakeWebhookPublicKey = wrongPublicKey
+
+ payload := testWaffoPancakeWebhookPayload()
+ signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10))
+
+ event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature)
+ require.Error(t, err)
+ require.Nil(t, event)
+ require.Contains(t, err.Error(), "invalid webhook signature")
+}
+
+func TestVerifyConfiguredWaffoPancakeWebhook_RejectsInvalidWebhookPublicKey(t *testing.T) {
+ privateKey, _ := generateWaffoPancakeWebhookKeyPair(t)
+ restoreWaffoPancakeWebhookSettings(t)
+ setting.WaffoPancakeSandbox = false
+ setting.WaffoPancakeWebhookPublicKey = "not-a-public-key"
+
+ payload := testWaffoPancakeWebhookPayload()
+ signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10))
+
+ event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature)
+ require.Error(t, err)
+ require.Nil(t, event)
+ require.Contains(t, err.Error(), "invalid webhook signature")
+}
+
+func TestVerifyConfiguredWaffoPancakeWebhook_RejectsExpiredTimestamp(t *testing.T) {
+ privateKey, publicKey := generateWaffoPancakeWebhookKeyPair(t)
+ restoreWaffoPancakeWebhookSettings(t)
+ setting.WaffoPancakeSandbox = false
+ setting.WaffoPancakeWebhookPublicKey = publicKey
+
+ payload := testWaffoPancakeWebhookPayload()
+ oldTimestamp := strconv.FormatInt(time.Now().Add(-waffoPancakeDefaultTolerance-time.Minute).UnixMilli(), 10)
+ signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, oldTimestamp)
+
+ event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature)
+ require.Error(t, err)
+ require.Nil(t, event)
+ require.Contains(t, err.Error(), "webhook timestamp outside tolerance window")
+}
+
+func TestVerifyConfiguredWaffoPancakeWebhook_RejectsSandboxModeMismatch(t *testing.T) {
+ privateKey, publicKey := generateWaffoPancakeWebhookKeyPair(t)
+ restoreWaffoPancakeWebhookSettings(t)
+ setting.WaffoPancakeSandbox = false
+ setting.WaffoPancakeWebhookTestKey = publicKey
+
+ payload := strings.Replace(testWaffoPancakeWebhookPayload(), `"mode": "prod"`, `"mode": "test"`, 2)
+ signature := signWaffoPancakeWebhookPayload(t, payload, privateKey, strconv.FormatInt(time.Now().UnixMilli(), 10))
+
+ event, err := VerifyConfiguredWaffoPancakeWebhook(payload, signature)
+ require.Error(t, err)
+ require.Nil(t, event)
+ require.Contains(t, err.Error(), "webhook environment mismatch")
+}
+
+func generateWaffoPancakeWebhookKeyPair(t *testing.T) (*rsa.PrivateKey, string) {
+ t.Helper()
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
+ require.NoError(t, err)
+
+ publicKeyPEM := pem.EncodeToMemory(&pem.Block{
+ Type: "PUBLIC KEY",
+ Bytes: publicKeyDER,
+ })
+ require.NotEmpty(t, publicKeyPEM)
+
+ return privateKey, string(publicKeyPEM)
+}
+
+func signWaffoPancakeWebhookPayload(t *testing.T, payload string, privateKey *rsa.PrivateKey, timestamp string) string {
+ t.Helper()
+
+ signatureInput := fmt.Sprintf("%s.%s", timestamp, payload)
+ digest := sha256.Sum256([]byte(signatureInput))
+ signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:])
+ require.NoError(t, err)
+
+ return fmt.Sprintf("t=%s,v1=%s", timestamp, base64.StdEncoding.EncodeToString(signature))
+}
+
+func restoreWaffoPancakeWebhookSettings(t *testing.T) {
+ t.Helper()
+
+ originalSandbox := setting.WaffoPancakeSandbox
+ originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey
+ originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey
+ t.Cleanup(func() {
+ setting.WaffoPancakeSandbox = originalSandbox
+ setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey
+ setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey
+ })
+}
+
+func testWaffoPancakeWebhookPayload() string {
+ return `{
+ "id": "evt_123",
+ "eventType": "order.completed",
+ "storeId": "store_123",
+ "mode": "prod",
+ "data": {
+ "orderId": "ORD_remote_123",
+ "currency": "USD",
+ "amount": "29.00",
+ "orderMetadata": {
+ "trade_no": "WAFFO_PANCAKE-42-123456-abc123",
+ "user_id": 42,
+ "payment_method": "waffo_pancake",
+ "requested_amount": 10,
+ "stored_amount": 10,
+ "money": "29.00",
+ "currency": "USD",
+ "store_id": "store_123",
+ "product_id": "product_456",
+ "mode": "prod"
+ }
+ }
+ }`
+}
diff --git a/web/classic/public/waffo-pancake-logo.svg b/web/classic/public/waffo-pancake-logo.svg
new file mode 100644
index 00000000000..3d26e1114fa
--- /dev/null
+++ b/web/classic/public/waffo-pancake-logo.svg
@@ -0,0 +1,21 @@
+
+
\ No newline at end of file
diff --git a/web/classic/src/components/settings/PaymentSetting.jsx b/web/classic/src/components/settings/PaymentSetting.jsx
index 362473e2721..2e9ce009c0c 100644
--- a/web/classic/src/components/settings/PaymentSetting.jsx
+++ b/web/classic/src/components/settings/PaymentSetting.jsx
@@ -327,13 +327,16 @@ const PaymentSetting = () => {
hideSectionTitle
/>
- {/**/}
- {/* */}
- {/**/}
+
+
+
diff --git a/web/classic/src/components/topup/RechargeCard.jsx b/web/classic/src/components/topup/RechargeCard.jsx
index f89d8ed7e9b..628b2d1af41 100644
--- a/web/classic/src/components/topup/RechargeCard.jsx
+++ b/web/classic/src/components/topup/RechargeCard.jsx
@@ -51,6 +51,23 @@ import { getCurrencyConfig } from '../../helpers/render';
import SubscriptionPlansCard from './SubscriptionPlansCard';
const { Text } = Typography;
+const WAFFO_PANCAKE_LOGO = '/waffo-pancake-logo.svg';
+const WAFFO_PANCAKE_ICON_BOX_STYLE = {
+ display: 'inline-flex',
+ width: 18,
+ height: 18,
+ overflow: 'hidden',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ flex: '0 0 18px',
+};
+const WAFFO_PANCAKE_ICON_IMAGE_STYLE = {
+ display: 'block',
+ height: 22,
+ width: 'auto',
+ maxWidth: 'none',
+ transform: 'translateY(-2px)',
+};
const RechargeCard = ({
t,
@@ -344,6 +361,17 @@ const RechargeCard = ({
) : payMethod.type === 'stripe' ? (
+ ) : payMethod.type === 'waffo_pancake' ? (
+
+
+
) : payMethod.icon ? (
- ) : payMethod.type === 'waffo_pancake' ? (
-
) : (
+
\ No newline at end of file
diff --git a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx
index e53721f0657..1e1636491f7 100644
--- a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx
+++ b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx
@@ -1468,11 +1468,18 @@ export function PaymentSettingsSection({
-
-
-
-
-
+
+
+
+
+
+
+
{/* eslint-enable react-hooks/refs */}
)
diff --git a/web/default/src/features/wallet/lib/ui.tsx b/web/default/src/features/wallet/lib/ui.tsx
index 23a2c080adc..c99186121e3 100644
--- a/web/default/src/features/wallet/lib/ui.tsx
+++ b/web/default/src/features/wallet/lib/ui.tsx
@@ -16,7 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import { type ReactNode } from 'react'
+import { type CSSProperties, type ReactNode } from 'react'
import { CreditCard, Landmark } from 'lucide-react'
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'
import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants'
@@ -27,6 +27,23 @@ import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants'
const HAS_LOCATION =
typeof globalThis !== 'undefined' && 'location' in globalThis
+const WAFFO_PANCAKE_LOGO = '/waffo-pancake-logo.svg'
+const WAFFO_PANCAKE_ICON_BOX_STYLE: CSSProperties = {
+ display: 'inline-flex',
+ width: 18,
+ height: 18,
+ overflow: 'hidden',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ flex: '0 0 18px',
+}
+const WAFFO_PANCAKE_ICON_IMAGE_STYLE: CSSProperties = {
+ display: 'block',
+ height: 22,
+ width: 'auto',
+ maxWidth: 'none',
+ transform: 'translateY(-2px)',
+}
/**
* Resolves a backend-provided image URL to http(s) only. Rejects javascript:,
@@ -122,10 +139,17 @@ export function getPaymentIcon(
)
case PAYMENT_TYPES.WAFFO_PANCAKE:
return (
-
+ style={WAFFO_PANCAKE_ICON_BOX_STYLE}
+ aria-hidden='true'
+ >
+
+
)
default:
return