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