diff --git a/controller/option.go b/controller/option.go index 4849bcc6..b5fdfdc1 100644 --- a/controller/option.go +++ b/controller/option.go @@ -42,15 +42,6 @@ func isPositiveOptionValue(value string) bool { return err == nil && floatValue > 0 } -func isVisiblePublicKeyOption(key string) bool { - switch key { - case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey": - return true - default: - return false - } -} - func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) { if strings.TrimSpace(raw) == "" { return @@ -95,7 +86,7 @@ func GetOptions(c *gin.Context) { strings.HasSuffix(k, "Key") || strings.HasSuffix(k, "secret") || strings.HasSuffix(k, "api_key") - if isSensitiveKey && !isVisiblePublicKeyOption(k) { + if isSensitiveKey { continue } options = append(options, &model.Option{ diff --git a/controller/payment_webhook_availability.go b/controller/payment_webhook_availability.go index 6b16e53f..aa26e5ac 100644 --- a/controller/payment_webhook_availability.go +++ b/controller/payment_webhook_availability.go @@ -77,24 +77,15 @@ func isWaffoPancakeTopUpEnabled() bool { if !isPaymentComplianceConfirmed() { return false } - if !setting.WaffoPancakeEnabled { - return false - } - - return isWaffoPancakeWebhookConfigured() && - strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" && + // Presence-of-credentials = enabled. Webhook public keys ship inside + // the SDK; mode (test/prod) is read from each event. + return strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" && strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" && - strings.TrimSpace(setting.WaffoPancakeStoreID) != "" && strings.TrimSpace(setting.WaffoPancakeProductID) != "" } func isWaffoPancakeWebhookConfigured() bool { - currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey) - if setting.WaffoPancakeSandbox { - currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey) - } - - return currentWebhookKey != "" + return isWaffoPancakeTopUpEnabled() } func isWaffoPancakeWebhookEnabled() bool { diff --git a/controller/payment_webhook_availability_test.go b/controller/payment_webhook_availability_test.go index f277602b..002428be 100644 --- a/controller/payment_webhook_availability_test.go +++ b/controller/payment_webhook_availability_test.go @@ -114,47 +114,32 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { confirmPaymentComplianceForTest(t) - originalEnabled := setting.WaffoPancakeEnabled - originalSandbox := setting.WaffoPancakeSandbox originalMerchantID := setting.WaffoPancakeMerchantID originalPrivateKey := setting.WaffoPancakePrivateKey - originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey - originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey - originalStoreID := setting.WaffoPancakeStoreID originalProductID := setting.WaffoPancakeProductID t.Cleanup(func() { - setting.WaffoPancakeEnabled = originalEnabled - setting.WaffoPancakeSandbox = originalSandbox setting.WaffoPancakeMerchantID = originalMerchantID setting.WaffoPancakePrivateKey = originalPrivateKey - setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey - setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey - setting.WaffoPancakeStoreID = originalStoreID setting.WaffoPancakeProductID = originalProductID }) - setting.WaffoPancakeEnabled = true - setting.WaffoPancakeSandbox = false - setting.WaffoPancakeMerchantID = "merchant" + // Presence of all three credentials enables the gateway. Webhook public + // keys are bundled in the SDK and there is no separate Enabled toggle — + // clear any of the three fields to disable. + setting.WaffoPancakeMerchantID = "" setting.WaffoPancakePrivateKey = "private" - setting.WaffoPancakeStoreID = "store" setting.WaffoPancakeProductID = "product" - setting.WaffoPancakeWebhookPublicKey = "" require.False(t, isWaffoPancakeWebhookEnabled()) - setting.WaffoPancakeWebhookPublicKey = "public" + setting.WaffoPancakeMerchantID = "merchant" require.True(t, isWaffoPancakeWebhookEnabled()) - setting.WaffoPancakeEnabled = false + setting.WaffoPancakeProductID = "" require.False(t, isWaffoPancakeWebhookEnabled()) - setting.WaffoPancakeEnabled = true - setting.WaffoPancakeSandbox = true - setting.WaffoPancakeWebhookTestKey = "" + setting.WaffoPancakeProductID = "product" + setting.WaffoPancakePrivateKey = "" require.False(t, isWaffoPancakeWebhookEnabled()) - - setting.WaffoPancakeWebhookTestKey = "test_public" - require.True(t, isWaffoPancakeWebhookEnabled()) } func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { diff --git a/controller/subscription_payment_waffo_pancake.go b/controller/subscription_payment_waffo_pancake.go new file mode 100644 index 00000000..5df3d4b6 --- /dev/null +++ b/controller/subscription_payment_waffo_pancake.go @@ -0,0 +1,125 @@ +package controller + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" + "github.com/thanhpk/randstr" +) + +type SubscriptionWaffoPancakePayRequest struct { + PlanId int `json:"plan_id"` +} + +func SubscriptionRequestWaffoPancakePay(c *gin.Context) { + var req SubscriptionWaffoPancakePayRequest + if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 { + common.ApiErrorMsg(c, "参数错误") + return + } + + plan, err := model.GetSubscriptionPlanById(req.PlanId) + if err != nil { + common.ApiError(c, err) + return + } + if !plan.Enabled { + common.ApiErrorMsg(c, "套餐未启用") + return + } + if strings.TrimSpace(plan.WaffoPancakeProductId) == "" { + common.ApiErrorMsg(c, "该套餐未配置 WaffoPancakeProductId") + return + } + // Plan targets its own Pancake product, so we only require credentials + // here — not the gateway-level WaffoPancakeProductID. + if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" || + strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" { + common.ApiErrorMsg(c, "Waffo Pancake 未配置或密钥无效") + return + } + + userId := c.GetInt("id") + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + if user == nil { + common.ApiErrorMsg(c, "用户不存在") + return + } + + if plan.MaxPurchasePerUser > 0 { + count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id) + if err != nil { + common.ApiError(c, err) + return + } + if count >= int64(plan.MaxPurchasePerUser) { + common.ApiErrorMsg(c, "已达到该套餐购买上限") + return + } + } + + // WAFFO_PANCAKE_SUB- prefix (vs. wallet's WAFFO_PANCAKE-) drives webhook + // dispatch in WaffoPancakeWebhook. + tradeNo := fmt.Sprintf("WAFFO_PANCAKE_SUB-%d-%d-%s", userId, time.Now().UnixMilli(), randstr.String(6)) + + order := &model.SubscriptionOrder{ + UserId: userId, + PlanId: plan.Id, + Money: plan.PriceAmount, + TradeNo: tradeNo, + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := order.Insert(); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error())) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + expiresInSeconds := 45 * 60 + session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{ + ProductID: plan.WaffoPancakeProductId, + BuyerIdentity: service.WaffoPancakeBuyerIdentityFromUserID(user.Id), + PriceSnapshot: &service.WaffoPancakePriceSnapshot{ + Amount: decimal.NewFromFloat(plan.PriceAmount).StringFixed(2), + TaxCategory: "saas", + }, + BuyerEmail: getWaffoPancakeBuyerEmail(user), + ExpiresInSeconds: &expiresInSeconds, + }) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error())) + order.Status = common.TopUpStatusFailed + _ = order.Update() + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f", userId, plan.Id, tradeNo, session.SessionID, plan.PriceAmount)) + + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "checkout_url": session.CheckoutURL, + "session_id": session.SessionID, + "expires_at": session.ExpiresAt, + "order_id": tradeNo, + "token": session.Token, + "token_expires_at": session.TokenExpiresAt, + }, + }) +} diff --git a/controller/topup.go b/controller/topup.go index 1de19679..69e1b5e3 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -52,6 +52,27 @@ func GetTopUpInfo(c *gin.Context) { } } + // Waffo Pancake displayed above the legacy Waffo gateway. + enableWaffoPancake := isWaffoPancakeTopUpEnabled() + if enableWaffoPancake { + hasWaffoPancake := false + for _, method := range payMethods { + if method["type"] == model.PaymentMethodWaffoPancake { + hasWaffoPancake = true + break + } + } + + if !hasWaffoPancake { + payMethods = append(payMethods, map[string]string{ + "name": "Waffo Pancake", + "type": model.PaymentMethodWaffoPancake, + "color": "rgba(var(--semi-orange-5), 1)", + "min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp), + }) + } + } + // 如果启用了 Waffo 支付,添加到支付方法列表 enableWaffo := isWaffoTopUpEnabled() if enableWaffo { @@ -74,26 +95,6 @@ func GetTopUpInfo(c *gin.Context) { } } - enableWaffoPancake := isWaffoPancakeTopUpEnabled() - if enableWaffoPancake { - hasWaffoPancake := false - for _, method := range payMethods { - if method["type"] == model.PaymentMethodWaffoPancake { - hasWaffoPancake = true - break - } - } - - if !hasWaffoPancake { - payMethods = append(payMethods, map[string]string{ - "name": "Waffo Pancake", - "type": model.PaymentMethodWaffoPancake, - "color": "rgba(var(--semi-orange-5), 1)", - "min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp), - }) - } - } - data := gin.H{ "enable_online_topup": isEpayTopUpEnabled(), "enable_stripe_topup": isStripeTopUpEnabled(), diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index 11c581fa..98b00aca 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -102,27 +102,254 @@ func getWaffoPancakeBuyerEmail(user *model.User) string { return "" } -func getWaffoPancakeReturnURL() string { - if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" { - return setting.WaffoPancakeReturnURL +// The admin config endpoints below accept typed-but-not-yet-saved creds in +// the body and fall back to persisted creds when the body is blank (see +// resolveWaffoPancakeAdminCreds). Only SaveWaffoPancake writes to OptionMap. + +type waffoPancakeCredsRequest struct { + MerchantID string `json:"merchant_id"` + PrivateKey string `json:"private_key"` +} + +type saveWaffoPancakeRequest struct { + MerchantID string `json:"merchant_id"` + PrivateKey string `json:"private_key"` + ReturnURL string `json:"return_url"` + StoreID string `json:"store_id"` + ProductID string `json:"product_id"` +} + +type createWaffoPancakePairRequest struct { + MerchantID string `json:"merchant_id"` + PrivateKey string `json:"private_key"` + ReturnURL string `json:"return_url"` +} + +// SaveWaffoPancake atomically persists all five operator-controlled fields. +// Catalog / pair endpoints are transient — only this one writes the OptionMap. +func SaveWaffoPancake(c *gin.Context) { + var req saveWaffoPancakeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return } - return paymentReturnPath("/console/topup?show_history=true") + if err := service.SaveWaffoPancakeConfig( + c.Request.Context(), + req.MerchantID, + req.PrivateKey, + req.ReturnURL, + req.StoreID, + req.ProductID, + ); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q", + req.StoreID, req.ProductID, err.Error(), + )) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "保存配置失败"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "product_id": setting.WaffoPancakeProductID, + "store_id": setting.WaffoPancakeStoreID, + }, + }) +} + +// resolveWaffoPancakeAdminCreds prefers body creds (typed-but-not-yet-saved +// values, for verification) and falls back to persisted creds when the body +// is blank (so returning admins don't have to re-paste the private key, +// which is stripped from GET /api/option/). +func resolveWaffoPancakeAdminCreds(bodyMerchantID, bodyPrivateKey string) (string, string) { + m := strings.TrimSpace(bodyMerchantID) + k := strings.TrimSpace(bodyPrivateKey) + if m == "" && k == "" { + return setting.WaffoPancakeMerchantID, setting.WaffoPancakePrivateKey + } + return m, k +} + +// CreateWaffoPancakePair mints a Store + OnetimeProduct pair in one round- +// trip. Surfaces an orphan-store flag when the product half fails so the +// frontend can preselect / retry without losing context. +func CreateWaffoPancakePair(c *gin.Context) { + var req createWaffoPancakePairRequest + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + } + merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey) + if merchantID == "" || privateKey == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"}) + return + } + result, err := service.CreateWaffoPancakePrimaryPair( + c.Request.Context(), merchantID, privateKey, req.ReturnURL, + ) + if err != nil { + orphan := result != nil && result.OrphanStore + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q", + orphan, func() string { + if result == nil { + return "" + } + return result.StoreID + }(), err.Error(), + )) + data := gin.H{"error": err.Error()} + if orphan { + data["store_id"] = result.StoreID + data["store_name"] = result.StoreName + data["orphan_store"] = true + } + c.JSON(http.StatusOK, gin.H{"message": "error", "data": data}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "store_id": result.StoreID, + "store_name": result.StoreName, + "product_id": result.ProductID, + "product_name": result.ProductName, + }, + }) +} + +// ListWaffoPancakeCatalog returns the merchant's Stores + OnetimeProducts. +// Doubles as a credential probe (a successful 200 proves the resolved creds +// authenticate). See resolveWaffoPancakeAdminCreds for credential resolution. +func ListWaffoPancakeCatalog(c *gin.Context) { + var req waffoPancakeCredsRequest + // An empty body means "use persisted creds"; only fail on malformed JSON. + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + } + merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey) + if merchantID == "" || privateKey == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"}) + return + } + catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 拉取店铺与产品目录失败 error=%q", err.Error(), + )) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取目录失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "success", "data": catalog}) +} + +type createWaffoPancakeSubscriptionProductRequest struct { + Name string `json:"name"` + Amount string `json:"amount"` +} + +// CreateWaffoPancakeSubscriptionProduct mints an OnetimeProduct (not +// SubscriptionProduct — see service.CreateWaffoPancakeProductForPlan) +// sized to a plan's `name` + `amount`, using persisted Pancake credentials +// + StoreID. Reads from the form, not the plan row, so newly-typed unsaved +// plans can mint a product too. +func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) { + var req createWaffoPancakeSubscriptionProductRequest + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + } + if strings.TrimSpace(req.Name) == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐名称不能为空"}) + return + } + if strings.TrimSpace(req.Amount) == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐价格不能为空"}) + return + } + merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "") + storeID := strings.TrimSpace(setting.WaffoPancakeStoreID) + if merchantID == "" || privateKey == "" || storeID == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"}) + return + } + productID, err := service.CreateWaffoPancakeProductForPlan( + c.Request.Context(), + merchantID, + privateKey, + storeID, + req.Name, + req.Amount, + setting.WaffoPancakeReturnURL, + ) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q", + storeID, req.Name, req.Amount, err.Error(), + )) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建套餐产品失败"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "product_id": productID, + "product_name": req.Name, + "store_id": storeID, + }, + }) +} + +// ListWaffoPancakeSubscriptionProductOptions returns the OnetimeProducts +// in the saved Pancake store, for the subscription-plan dropdown. The name +// reflects new-api's plan concept; under the hood it's still OnetimeProducts. +func ListWaffoPancakeSubscriptionProductOptions(c *gin.Context) { + merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "") + storeID := strings.TrimSpace(setting.WaffoPancakeStoreID) + if merchantID == "" || privateKey == "" || storeID == "" { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"}) + return + } + catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q", storeID, err.Error(), + )) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取产品列表失败"}) + return + } + products := []service.WaffoPancakeCatalogProduct{} + for _, store := range catalog.Stores { + if store.ID == storeID { + products = store.OnetimeProducts + break + } + } + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "store_id": storeID, + "products": products, + }, + }) +} + +func getWaffoPancakeBuyerIdentity(user *model.User) string { + if user == nil { + return "" + } + return service.WaffoPancakeBuyerIdentityFromUserID(user.Id) } func RequestWaffoPancakePay(c *gin.Context) { - if !setting.WaffoPancakeEnabled { - c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"}) - return - } - currentWebhookKey := setting.WaffoPancakeWebhookPublicKey - if setting.WaffoPancakeSandbox { - currentWebhookKey = setting.WaffoPancakeWebhookTestKey - } - if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" || - strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" || - strings.TrimSpace(currentWebhookKey) == "" || - strings.TrimSpace(setting.WaffoPancakeStoreID) == "" || - strings.TrimSpace(setting.WaffoPancakeProductID) == "" { + if !isWaffoPancakeTopUpEnabled() { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"}) return } @@ -175,17 +402,13 @@ func RequestWaffoPancakePay(c *gin.Context) { expiresInSeconds := 45 * 60 session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{ - StoreID: setting.WaffoPancakeStoreID, - ProductID: setting.WaffoPancakeProductID, - ProductType: "onetime", - Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)), + ProductID: setting.WaffoPancakeProductID, + BuyerIdentity: getWaffoPancakeBuyerIdentity(user), PriceSnapshot: &service.WaffoPancakePriceSnapshot{ Amount: formatWaffoPancakeAmount(payMoney), - TaxIncluded: false, TaxCategory: "saas", }, BuyerEmail: getWaffoPancakeBuyerEmail(user), - SuccessURL: getWaffoPancakeReturnURL(), ExpiresInSeconds: &expiresInSeconds, }) if err != nil { @@ -200,10 +423,12 @@ func RequestWaffoPancakePay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "success", "data": gin.H{ - "checkout_url": session.CheckoutURL, - "session_id": session.SessionID, - "expires_at": session.ExpiresAt, - "order_id": tradeNo, + "checkout_url": session.CheckoutURL, + "session_id": session.SessionID, + "expires_at": session.ExpiresAt, + "order_id": tradeNo, + "token": session.Token, + "token_expires_at": session.TokenExpiresAt, }, }) } @@ -215,6 +440,19 @@ func WaffoPancakeWebhook(c *gin.Context) { return } + // :env splits test vs prod traffic at the routing layer — operator + // registers each URL in the matching webhook slot in Pancake's dashboard. + // We then enforce event.mode == expectedEnv to catch mis-registrations. + expectedEnv := strings.TrimSpace(c.Param("env")) + if expectedEnv != "test" && expectedEnv != "prod" { + logger.LogWarn(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s", + expectedEnv, c.Request.RequestURI, c.ClientIP(), + )) + c.String(http.StatusNotFound, "unknown env") + return + } + bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) @@ -232,15 +470,57 @@ func WaffoPancakeWebhook(c *gin.Context) { return } + if !strings.EqualFold(strings.TrimSpace(event.Mode), expectedEnv) { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s", + expectedEnv, event.Mode, event.ID, event.Data.OrderID, c.ClientIP(), + )) + c.String(http.StatusOK, "OK") + return + } + logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP())) if event.NormalizedEventType() != "order.completed" { c.String(http.StatusOK, "OK") return } + // Subscription vs top-up dispatch by trade_no prefix (written at + // session-creation time): WAFFO_PANCAKE_SUB- vs WAFFO_PANCAKE-. + rawTradeNo := strings.TrimSpace(event.Data.OrderID) + isSubscription := strings.HasPrefix(rawTradeNo, "WAFFO_PANCAKE_SUB-") + + if isSubscription { + tradeNo, err := service.ResolveWaffoPancakeSubscriptionTradeNo(event) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q", + event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(), + )) + c.String(http.StatusOK, "OK") + return + } + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + if err := model.CompleteSubscriptionOrder(tradeNo, string(bodyBytes), model.PaymentProviderWaffoPancake, ""); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error())) + c.String(http.StatusInternalServerError, "retry") + return + } + logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP())) + c.String(http.StatusOK, "OK") + return + } + tradeNo, err := service.ResolveWaffoPancakeTradeNo(event) if err != nil { - logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 订单号映射失败 event_id=%s order_id=%s error=%q", event.ID, event.Data.OrderID, err.Error())) + // LogError (not LogWarn): covers order-not-found and buyer-identity + // mismatch — both warrant human attention. 200 OK so Waffo doesn't + // retry a permanently-unresolvable webhook. + logger.LogError(c.Request.Context(), fmt.Sprintf( + "Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q", + event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(), + )) c.String(http.StatusOK, "OK") return } diff --git a/go.mod b/go.mod index f34ecc19..672c7418 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,8 @@ require ( gorm.io/gorm v1.25.2 ) +require github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 + require ( github.com/DmitriyVTitov/size v1.5.0 // indirect github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect diff --git a/go.sum b/go.sum index 6a97e299..e16f7e20 100644 --- a/go.sum +++ b/go.sum @@ -308,6 +308,10 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw= github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g= +github.com/waffo-com/waffo-pancake-sdk-go v0.1.1 h1:YOI7+3zTBlTB7Ou6+ZXnJV2JvW/ag9d7CwE/TxH3Hls= +github.com/waffo-com/waffo-pancake-sdk-go v0.1.1/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI= +github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 h1:cCSgccM66p7feTtgRqUUGT50tYQOhahsoPXavd+ib1U= +github.com/waffo-com/waffo-pancake-sdk-go v0.2.0/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= diff --git a/model/main.go b/model/main.go index 16cd373f..9083ee57 100644 --- a/model/main.go +++ b/model/main.go @@ -399,6 +399,7 @@ func ensureSubscriptionPlanTableSQLite() error { ` + "`sort_order`" + ` integer DEFAULT 0, ` + "`stripe_price_id`" + ` varchar(128) DEFAULT '', ` + "`creem_product_id`" + ` varchar(128) DEFAULT '', +` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '', ` + "`max_purchase_per_user`" + ` integer DEFAULT 0, ` + "`upgrade_group`" + ` varchar(64) DEFAULT '', ` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0, @@ -432,6 +433,7 @@ PRIMARY KEY (` + "`id`" + `) {Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"}, {Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"}, {Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"}, + {Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"}, {Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"}, {Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"}, {Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"}, diff --git a/model/option.go b/model/option.go index e0a3048d..ed1af72e 100644 --- a/model/option.go +++ b/model/option.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/setting/performance_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/system_setting" + "gorm.io/gorm" ) type Option struct { @@ -106,18 +107,13 @@ func InitOptionMap() { common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64) common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp) common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString() - common.OptionMap["WaffoPancakeEnabled"] = strconv.FormatBool(setting.WaffoPancakeEnabled) - common.OptionMap["WaffoPancakeSandbox"] = strconv.FormatBool(setting.WaffoPancakeSandbox) common.OptionMap["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey - common.OptionMap["WaffoPancakeWebhookPublicKey"] = setting.WaffoPancakeWebhookPublicKey - common.OptionMap["WaffoPancakeWebhookTestKey"] = setting.WaffoPancakeWebhookTestKey - common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID - common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID common.OptionMap["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL - common.OptionMap["WaffoPancakeCurrency"] = setting.WaffoPancakeCurrency common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64) common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp) + common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID + common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -222,6 +218,39 @@ func UpdateOption(key string, value string) error { return updateOptionMap(key, value) } +// UpdateOptionsBulk persists multiple key/value pairs in a single database +// transaction, then dispatches them through updateOptionMap in one pass. If +// any DB write fails the whole transaction rolls back and no in-memory state +// is touched — safe for callers that must commit a set of related options +// atomically (e.g. payment gateway binding). +func UpdateOptionsBulk(values map[string]string) error { + if len(values) == 0 { + return nil + } + err := DB.Transaction(func(tx *gorm.DB) error { + for k, v := range values { + option := Option{Key: k} + if err := tx.FirstOrCreate(&option, Option{Key: k}).Error; err != nil { + return err + } + option.Value = v + if err := tx.Save(&option).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + for k, v := range values { + if err := updateOptionMap(k, v); err != nil { + return err + } + } + return nil +} + func updateOptionMap(key string, value string) (err error) { common.OptionMapRWMutex.Lock() defer common.OptionMapRWMutex.Unlock() @@ -419,26 +448,16 @@ func updateOptionMap(key string, value string) (err error) { setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64) case "WaffoMinTopUp": setting.WaffoMinTopUp, _ = strconv.Atoi(value) - case "WaffoPancakeEnabled": - setting.WaffoPancakeEnabled = value == "true" - case "WaffoPancakeSandbox": - setting.WaffoPancakeSandbox = value == "true" case "WaffoPancakeMerchantID": setting.WaffoPancakeMerchantID = value case "WaffoPancakePrivateKey": setting.WaffoPancakePrivateKey = value - case "WaffoPancakeWebhookPublicKey": - setting.WaffoPancakeWebhookPublicKey = value - case "WaffoPancakeWebhookTestKey": - setting.WaffoPancakeWebhookTestKey = value + case "WaffoPancakeReturnURL": + setting.WaffoPancakeReturnURL = value case "WaffoPancakeStoreID": setting.WaffoPancakeStoreID = value case "WaffoPancakeProductID": setting.WaffoPancakeProductID = value - case "WaffoPancakeReturnURL": - setting.WaffoPancakeReturnURL = value - case "WaffoPancakeCurrency": - setting.WaffoPancakeCurrency = value case "WaffoPancakeUnitPrice": setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64) case "WaffoPancakeMinTopUp": diff --git a/model/subscription.go b/model/subscription.go index da8fdae9..4ff5a204 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -159,8 +159,9 @@ type SubscriptionPlan struct { Enabled bool `json:"enabled" gorm:"default:true"` SortOrder int `json:"sort_order" gorm:"type:int;default:0"` - StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"` - CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"` + StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"` + CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"` + WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"` // Max purchases per user (0 = unlimited) MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"` diff --git a/router/api-router.go b/router/api-router.go index da026ed9..7dfc648e 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -56,7 +56,9 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) - //apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook) + // :env separates test vs prod URLs so the operator can register each + // in Pancake's matching webhook slot; handler enforces env match. + apiRouter.POST("/waffo-pancake/webhook/:env", controller.WaffoPancakeWebhook) // Universal secure verification routes apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) @@ -100,8 +102,8 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount) selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay) - //selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) - //selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay) + selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) + selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) @@ -154,6 +156,7 @@ func SetApiRouter(router *gin.Engine) { subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay) subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay) subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay) + subscriptionRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestWaffoPancakePay) } subscriptionAdminRoute := apiRouter.Group("/subscription/admin") subscriptionAdminRoute.Use(middleware.AdminAuth()) @@ -186,6 +189,11 @@ func SetApiRouter(router *gin.Engine) { optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache) optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 + optionRoute.POST("/waffo-pancake/catalog", controller.ListWaffoPancakeCatalog) + optionRoute.POST("/waffo-pancake/pair", controller.CreateWaffoPancakePair) + optionRoute.POST("/waffo-pancake/save", controller.SaveWaffoPancake) + optionRoute.POST("/waffo-pancake/subscription-product", controller.CreateWaffoPancakeSubscriptionProduct) + optionRoute.POST("/waffo-pancake/subscription-product-options", controller.ListWaffoPancakeSubscriptionProductOptions) } // Custom OAuth provider management (root only) diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go index 9033c37f..d603ece1 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -1,398 +1,472 @@ package service import ( - "bytes" "context" - "crypto" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/pem" "fmt" - "io" - "math" - "net/http" - "strconv" "strings" - "time" - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" + pancake "github.com/waffo-com/waffo-pancake-sdk-go" ) -const ( - waffoPancakeAuthBaseURL = "https://waffo-pancake-auth-service.vercel.app" - waffoPancakeCheckoutPath = "/v1/actions/checkout/create-session" - waffoPancakeDefaultTolerance = 5 * time.Minute -) - +// WaffoPancakePriceSnapshot is the per-session price override sent with checkout. type WaffoPancakePriceSnapshot struct { - Amount string `json:"amount"` - TaxIncluded bool `json:"taxIncluded"` - TaxCategory string `json:"taxCategory"` + Amount string + TaxCategory string } +// WaffoPancakeCreateSessionParams is the input to CreateWaffoPancakeCheckoutSession. +// BuyerIdentity (merchant-controlled, stable per user) is what survives the +// buyer editing email at checkout — see WaffoPancakeBuyerIdentityFromUserID. type WaffoPancakeCreateSessionParams struct { - StoreID string `json:"storeId"` - ProductID string `json:"productId"` - ProductType string `json:"productType"` - Currency string `json:"currency"` - PriceSnapshot *WaffoPancakePriceSnapshot `json:"priceSnapshot,omitempty"` - BuyerEmail string `json:"buyerEmail,omitempty"` - SuccessURL string `json:"successUrl,omitempty"` - ExpiresInSeconds *int `json:"expiresInSeconds,omitempty"` + ProductID string + BuyerIdentity string + PriceSnapshot *WaffoPancakePriceSnapshot + BuyerEmail string + ExpiresInSeconds *int } +// WaffoPancakeCheckoutSession is the response of CreateWaffoPancakeCheckoutSession. +// CheckoutURL already carries the `#token=...` fragment; Token / TokenExpiresAt +// are exposed separately for self-service flows driven from new-api's own UI. type WaffoPancakeCheckoutSession struct { - SessionID string `json:"sessionId"` - CheckoutURL string `json:"checkoutUrl"` - ExpiresAt string `json:"expiresAt"` - OrderID string `json:"orderId"` + SessionID string + CheckoutURL string + ExpiresAt string + OrderID string + Token string + TokenExpiresAt string } -type waffoPancakeAPIError struct { - Message string `json:"message"` - Layer string `json:"layer"` +// WaffoPancakeWebhookEvent mirrors the SDK's WebhookEvent shape using plain +// strings so controllers don't have to import the SDK package. +type WaffoPancakeWebhookEvent struct { + ID string + Timestamp string + EventType string + EventID string + StoreID string + Mode string + Data WaffoPancakeWebhookData } -type waffoPancakeCreateSessionResponse struct { - Data *WaffoPancakeCheckoutSession `json:"data"` - Errors []waffoPancakeAPIError `json:"errors"` +type WaffoPancakeWebhookData struct { + OrderID string + BuyerEmail string + Currency string + Amount string + TaxAmount string + ProductName string + MerchantProvidedBuyerIdentity string } -type waffoPancakeWebhookData struct { - ID string `json:"id"` - OrderID string `json:"orderId"` - BuyerEmail string `json:"buyerEmail"` - Currency string `json:"currency"` - Amount dto.StringValue `json:"amount"` - TaxAmount dto.StringValue `json:"taxAmount"` - ProductName string `json:"productName"` -} - -type waffoPancakeWebhookEvent struct { - ID string `json:"id"` - Timestamp string `json:"timestamp"` - EventType string `json:"eventType"` - EventID string `json:"eventId"` - StoreID string `json:"storeId"` - Mode string `json:"mode"` - Data waffoPancakeWebhookData `json:"data"` -} - -func (e *waffoPancakeWebhookEvent) NormalizedEventType() string { +// NormalizedEventType returns the event type or empty string for a nil event. +func (e *WaffoPancakeWebhookEvent) NormalizedEventType() string { if e == nil { return "" } return e.EventType } +// newWaffoPancakeClient builds an SDK client from persisted settings. The +// runtime checkout / webhook paths use this; configuration endpoints use +// newWaffoPancakeClientFromCreds so the operator can verify typed-but-not- +// yet-saved credentials. +func newWaffoPancakeClient() (*pancake.Client, error) { + return pancake.New(pancake.Config{ + MerchantID: setting.WaffoPancakeMerchantID, + PrivateKey: setting.WaffoPancakePrivateKey, + }) +} + +func newWaffoPancakeClientFromCreds(merchantID, privateKey string) (*pancake.Client, error) { + if strings.TrimSpace(merchantID) == "" || strings.TrimSpace(privateKey) == "" { + return nil, fmt.Errorf("merchant id and private key are required") + } + return pancake.New(pancake.Config{ + MerchantID: merchantID, + PrivateKey: privateKey, + }) +} + +// CreateWaffoPancakeCheckoutSession creates an Authenticated-mode checkout +// session: the order is bound to BuyerIdentity (stable per user) so it stays +// attributable even if the buyer edits the email on Waffo's checkout form. func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancakeCreateSessionParams) (*WaffoPancakeCheckoutSession, error) { if params == nil { return nil, fmt.Errorf("missing checkout params") } - - body, err := common.Marshal(params) + if strings.TrimSpace(params.BuyerIdentity) == "" { + return nil, fmt.Errorf("missing buyer identity") + } + client, err := newWaffoPancakeClient() if err != nil { - return nil, fmt.Errorf("marshal Waffo Pancake checkout payload: %w", err) + return nil, fmt.Errorf("build Waffo Pancake client: %w", err) } - privateKey, err := normalizeRSAPrivateKey(setting.WaffoPancakePrivateKey) - if err != nil { - return nil, err + sdkParams := pancake.AuthenticatedCheckoutParams{ + CreateCheckoutSessionParams: pancake.CreateCheckoutSessionParams{ + ProductID: params.ProductID, + Currency: "USD", + BuyerEmail: optionalString(params.BuyerEmail), + ExpiresInSeconds: params.ExpiresInSeconds, + }, + BuyerIdentity: params.BuyerIdentity, } - - timestamp := strconv.FormatInt(time.Now().Unix(), 10) - signature, err := signWaffoPancakeRequest(http.MethodPost, waffoPancakeCheckoutPath, timestamp, string(body), privateKey) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, waffoPancakeAuthBaseURL+waffoPancakeCheckoutPath, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("build Waffo Pancake checkout request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Merchant-Id", setting.WaffoPancakeMerchantID) - req.Header.Set("X-Timestamp", timestamp) - req.Header.Set("X-Signature", signature) - if setting.WaffoPancakeSandbox { - req.Header.Set("X-Environment", "test") - } else { - req.Header.Set("X-Environment", "prod") - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("request Waffo Pancake checkout session: %w", err) - } - defer resp.Body.Close() - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read Waffo Pancake checkout response: %w", err) - } - - var result waffoPancakeCreateSessionResponse - if err := common.Unmarshal(responseBody, &result); err != nil { - return nil, fmt.Errorf("decode Waffo Pancake checkout response: %w", err) - } - if resp.StatusCode >= http.StatusBadRequest { - if len(result.Errors) > 0 { - return nil, fmt.Errorf("Waffo Pancake error (%d): %s", resp.StatusCode, result.Errors[0].Message) + if params.PriceSnapshot != nil { + sdkParams.PriceSnapshot = &pancake.PriceInfo{ + Amount: params.PriceSnapshot.Amount, + TaxCategory: pancake.TaxCategory(params.PriceSnapshot.TaxCategory), } - return nil, fmt.Errorf("Waffo Pancake checkout request failed with status %d", resp.StatusCode) } - if len(result.Errors) > 0 { - return nil, fmt.Errorf("Waffo Pancake error: %s", result.Errors[0].Message) + + session, err := client.Checkout.Authenticated.Create(ctx, sdkParams) + if err != nil { + return nil, err } - if result.Data == nil || result.Data.CheckoutURL == "" || strings.TrimSpace(result.Data.SessionID) == "" { + if session == nil || strings.TrimSpace(session.CheckoutURL) == "" || strings.TrimSpace(session.SessionID) == "" { return nil, fmt.Errorf("Waffo Pancake returned empty checkout session") } - return result.Data, nil + return &WaffoPancakeCheckoutSession{ + SessionID: session.SessionID, + CheckoutURL: session.CheckoutURL, + ExpiresAt: session.ExpiresAt, + Token: session.Token, + TokenExpiresAt: session.TokenExpiresAt, + }, nil } -func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*waffoPancakeWebhookEvent, error) { - environment := resolveWaffoPancakeWebhookEnvironment(payload) - return verifyWaffoPancakeWebhook(payload, signatureHeader, environment) +func optionalString(s string) *string { + if strings.TrimSpace(s) == "" { + return nil + } + v := s + return &v } -func ResolveWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) (string, error) { +// WaffoPancakeBuyerIdentityFromUserID renders the canonical buyer identity +// for checkout. Webhook handlers compare against the value rendered here to +// reject identity mismatches, so both call sites must use this function. +func WaffoPancakeBuyerIdentityFromUserID(userID int) string { + return fmt.Sprintf("new-api-user-%d", userID) +} + +// VerifyConfiguredWaffoPancakeWebhook verifies the signature header. The SDK +// picks the matching test / prod public key from the payload's `mode` field. +func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*WaffoPancakeWebhookEvent, error) { + evt, err := pancake.VerifyWebhookTyped[pancake.WebhookEventData](payload, signatureHeader, nil) + if err != nil { + return nil, err + } + identity := "" + if evt.Data.MerchantProvidedBuyerIdentity != nil { + identity = *evt.Data.MerchantProvidedBuyerIdentity + } + return &WaffoPancakeWebhookEvent{ + ID: evt.ID, + Timestamp: evt.Timestamp, + EventType: evt.EventType, + EventID: evt.EventID, + StoreID: evt.StoreID, + Mode: string(evt.Mode), + Data: WaffoPancakeWebhookData{ + OrderID: evt.Data.OrderID, + BuyerEmail: evt.Data.BuyerEmail, + Currency: evt.Data.Currency, + Amount: evt.Data.Amount, + TaxAmount: evt.Data.TaxAmount, + ProductName: evt.Data.ProductName, + MerchantProvidedBuyerIdentity: identity, + }, + }, nil +} + +// ResolveWaffoPancakeTradeNo maps a verified webhook event to a local TopUp +// trade_no, rejecting any payload whose buyer identity doesn't match the one +// we recorded at checkout — defence-in-depth on top of signature verification. +func ResolveWaffoPancakeTradeNo(event *WaffoPancakeWebhookEvent) (string, error) { if event == nil { return "", fmt.Errorf("missing webhook event") } - - if tradeNo := strings.TrimSpace(event.Data.OrderID); tradeNo != "" { - topUp := model.GetTopUpByTradeNo(tradeNo) - if topUp != nil && topUp.PaymentMethod == model.PaymentMethodWaffoPancake { - return tradeNo, nil - } + tradeNo := strings.TrimSpace(event.Data.OrderID) + if tradeNo == "" { + return "", fmt.Errorf("missing webhook orderId") + } + topUp := model.GetTopUpByTradeNo(tradeNo) + if topUp == nil || topUp.PaymentProvider != model.PaymentProviderWaffoPancake { return "", fmt.Errorf("waffo pancake order not found for webhook orderId=%s", tradeNo) } - - return "", fmt.Errorf("missing webhook orderId") -} - -func normalizeRSAPrivateKey(raw string) (string, error) { - return normalizePEMKey(raw, "PRIVATE KEY", "RSA PRIVATE KEY") -} - -func normalizeRSAPublicKey(raw string) (string, error) { - return normalizePEMKey(raw, "PUBLIC KEY", "RSA PUBLIC KEY") -} - -func normalizePEMKey(raw string, pkcs8Type string, pkcs1Type string) (string, error) { - if strings.TrimSpace(raw) == "" { - return "", fmt.Errorf("%s is empty", strings.ToLower(pkcs8Type)) + expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(topUp.UserId) + actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity) + if actualIdentity != expectedIdentity { + return "", fmt.Errorf( + "waffo pancake buyer identity mismatch for tradeNo=%s: expected=%q actual=%q", + tradeNo, + expectedIdentity, + actualIdentity, + ) } + return tradeNo, nil +} - normalized := strings.TrimSpace(strings.ReplaceAll(raw, `\n`, "\n")) - if strings.Contains(normalized, "BEGIN ") { - block, _ := pem.Decode([]byte(normalized)) - if block == nil { - return "", fmt.Errorf("invalid PEM encoded %s", strings.ToLower(pkcs8Type)) - } - return string(pem.EncodeToMemory(block)), nil +// ResolveWaffoPancakeSubscriptionTradeNo is the SubscriptionOrder counterpart +// of ResolveWaffoPancakeTradeNo. +func ResolveWaffoPancakeSubscriptionTradeNo(event *WaffoPancakeWebhookEvent) (string, error) { + if event == nil { + return "", fmt.Errorf("missing webhook event") } + tradeNo := strings.TrimSpace(event.Data.OrderID) + if tradeNo == "" { + return "", fmt.Errorf("missing webhook orderId") + } + order := model.GetSubscriptionOrderByTradeNo(tradeNo) + if order == nil || order.PaymentProvider != model.PaymentProviderWaffoPancake { + return "", fmt.Errorf("waffo pancake subscription order not found for webhook orderId=%s", tradeNo) + } + expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(order.UserId) + actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity) + if actualIdentity != expectedIdentity { + return "", fmt.Errorf( + "waffo pancake buyer identity mismatch for subscription tradeNo=%s: expected=%q actual=%q", + tradeNo, + expectedIdentity, + actualIdentity, + ) + } + return tradeNo, nil +} - der, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(normalized, "\n", "")) +// Deterministic default names for "+ Create": stable bodies mean stable +// X-Idempotency-Key, which lets Pancake dedupe retries server-side. +const ( + defaultWaffoPancakeStoreName = "new-api-store" + defaultWaffoPancakeProductName = "new-api-charge-product" +) + +// CreateWaffoPancakePrimaryStore creates a Pancake Store using in-flight +// (not-yet-persisted) credentials and returns the new store ID. +func CreateWaffoPancakePrimaryStore(ctx context.Context, merchantID, privateKey string) (string, error) { + client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey) if err != nil { - return "", fmt.Errorf("invalid base64 encoded %s: %w", strings.ToLower(pkcs8Type), err) + return "", err } - - pemType := pkcs8Type - if pkcs8Type == "PRIVATE KEY" { - if _, err := x509.ParsePKCS8PrivateKey(der); err != nil { - if _, err := x509.ParsePKCS1PrivateKey(der); err == nil { - pemType = pkcs1Type - } else { - return "", fmt.Errorf("invalid RSA private key") - } - } - } else { - if _, err := x509.ParsePKIXPublicKey(der); err != nil { - if _, err := x509.ParsePKCS1PublicKey(der); err == nil { - pemType = pkcs1Type - } else { - return "", fmt.Errorf("invalid RSA public key") - } - } - } - - return string(pem.EncodeToMemory(&pem.Block{Type: pemType, Bytes: der})), nil -} - -func signWaffoPancakeRequest(method string, path string, timestamp string, body string, privateKeyPEM string) (string, error) { - block, _ := pem.Decode([]byte(privateKeyPEM)) - if block == nil { - return "", fmt.Errorf("invalid RSA private key PEM") - } - - var privateKey *rsa.PrivateKey - switch block.Type { - case "PRIVATE KEY": - key, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return "", fmt.Errorf("parse PKCS#8 private key: %w", err) - } - parsed, ok := key.(*rsa.PrivateKey) - if !ok { - return "", fmt.Errorf("private key is not RSA") - } - privateKey = parsed - case "RSA PRIVATE KEY": - key, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return "", fmt.Errorf("parse PKCS#1 private key: %w", err) - } - privateKey = key - default: - return "", fmt.Errorf("unsupported private key type: %s", block.Type) - } - - canonicalRequest := buildWaffoPancakeCanonicalRequest(method, path, timestamp, body) - digest := sha256.Sum256([]byte(canonicalRequest)) - signature, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, digest[:]) + storeRes, err := client.Stores.Create(ctx, pancake.CreateStoreParams{ + Name: defaultWaffoPancakeStoreName, + }) if err != nil { - return "", fmt.Errorf("sign Waffo Pancake request: %w", err) + return "", fmt.Errorf("create Waffo Pancake store: %w", err) } - return base64.StdEncoding.EncodeToString(signature), nil + return storeRes.Store.ID, nil } -func buildWaffoPancakeCanonicalRequest(method string, path string, timestamp string, body string) string { - bodyHash := sha256.Sum256([]byte(body)) - return fmt.Sprintf( - "%s\n%s\n%s\n%s", - strings.ToUpper(method), - path, - timestamp, - base64.StdEncoding.EncodeToString(bodyHash[:]), - ) -} - -func verifyWaffoPancakeWebhook(payload string, signatureHeader string, environment string) (*waffoPancakeWebhookEvent, error) { - if signatureHeader == "" { - return nil, fmt.Errorf("missing X-Waffo-Signature header") +// CreateWaffoPancakeProductForPlan mints (and publishes) a Pancake +// OnetimeProduct priced at `amount` USD, used as a subscription plan's +// SubscriptionPlan.WaffoPancakeProductId. +// +// OnetimeProduct (not SubscriptionProduct) because new-api has no renewal- +// event handling; Pancake auto-renewing without new-api extending user +// access would be a UX divergence. Revisit if renewal handling is added. +func CreateWaffoPancakeProductForPlan(ctx context.Context, merchantID, privateKey, storeID, name, amount, returnURL string) (string, error) { + storeID = strings.TrimSpace(storeID) + if storeID == "" { + return "", fmt.Errorf("store id is required to create a product") } - - timestampPart, signaturePart := parseWaffoPancakeSignatureHeader(signatureHeader) - if timestampPart == "" || signaturePart == "" { - return nil, fmt.Errorf("malformed X-Waffo-Signature header") + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("plan name is required") } - - timestampMs, err := strconv.ParseInt(timestampPart, 10, 64) + amount = strings.TrimSpace(amount) + if amount == "" { + return "", fmt.Errorf("plan price is required") + } + client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey) if err != nil { - return nil, fmt.Errorf("invalid timestamp in X-Waffo-Signature header") + return "", err } - if math.Abs(float64(time.Now().UnixMilli()-timestampMs)) > float64(waffoPancakeDefaultTolerance.Milliseconds()) { - return nil, fmt.Errorf("webhook timestamp outside tolerance window") - } - - signatureInput := fmt.Sprintf("%s.%s", timestampPart, payload) - if err := verifyWaffoPancakeWebhookWithKey(signatureInput, signaturePart, resolveWaffoPancakeWebhookPublicKey(environment)); err != nil { - return nil, fmt.Errorf("invalid webhook signature") - } - - var event waffoPancakeWebhookEvent - if err := common.Unmarshal([]byte(payload), &event); err != nil { - return nil, fmt.Errorf("parse Waffo Pancake webhook payload: %w", err) - } - return &event, nil -} - -func parseWaffoPancakeSignatureHeader(header string) (string, string) { - var timestampPart string - var signaturePart string - for _, pair := range strings.Split(header, ",") { - key, value, found := strings.Cut(strings.TrimSpace(pair), "=") - if !found { - continue - } - switch key { - case "t": - timestampPart = value - case "v1": - signaturePart = value - } - } - return timestampPart, signaturePart -} - -func resolveWaffoPancakeWebhookEnvironment(payload string) string { - var envelope struct { - Mode string `json:"mode"` - } - if err := common.Unmarshal([]byte(payload), &envelope); err != nil { - if setting.WaffoPancakeSandbox { - return "test" - } - return "prod" - } - - switch strings.ToLower(strings.TrimSpace(envelope.Mode)) { - case "test": - return "test" - case "prod": - return "prod" - default: - if setting.WaffoPancakeSandbox { - return "test" - } - return "prod" - } -} - -func resolveWaffoPancakeWebhookPublicKey(environment string) string { - if environment == "prod" { - return strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey) - } - return strings.TrimSpace(setting.WaffoPancakeWebhookTestKey) -} - -func verifyWaffoPancakeWebhookWithKey(signatureInput string, signaturePart string, rawPublicKey string) error { - publicKeyPEM, err := normalizeRSAPublicKey(rawPublicKey) + prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{ + StoreID: storeID, + Name: name, + Prices: pancake.Prices{ + "USD": { + Amount: amount, + TaxCategory: pancake.TaxCategory("saas"), + }, + }, + SuccessURL: optionalString(strings.TrimSpace(returnURL)), + }) if err != nil { - return err + return "", fmt.Errorf("create Waffo Pancake plan product: %w", err) } - - block, _ := pem.Decode([]byte(publicKeyPEM)) - if block == nil { - return fmt.Errorf("invalid RSA public key PEM") + productID := prodRes.Product.ID + if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil { + return "", fmt.Errorf("publish Waffo Pancake plan product: %w", err) } + return productID, nil +} - var publicKey *rsa.PublicKey - switch block.Type { - case "PUBLIC KEY": - key, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - return fmt.Errorf("parse PKIX public key: %w", err) - } - parsed, ok := key.(*rsa.PublicKey) - if !ok { - return fmt.Errorf("public key is not RSA") - } - publicKey = parsed - case "RSA PUBLIC KEY": - key, err := x509.ParsePKCS1PublicKey(block.Bytes) - if err != nil { - return fmt.Errorf("parse PKCS#1 public key: %w", err) - } - publicKey = key - default: - return fmt.Errorf("unsupported public key type: %s", block.Type) +// CreateWaffoPancakePrimaryProduct mints (and publishes) the wallet-top-up +// OnetimeProduct under storeID. Per-checkout price overrides via PriceSnapshot +// are what make the "1.00" seed price irrelevant at runtime. +func CreateWaffoPancakePrimaryProduct(ctx context.Context, merchantID, privateKey, storeID, returnURL string) (string, error) { + storeID = strings.TrimSpace(storeID) + if storeID == "" { + return "", fmt.Errorf("store id is required to create a product") } - - signature, err := base64.StdEncoding.DecodeString(signaturePart) + client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey) if err != nil { - return fmt.Errorf("decode webhook signature: %w", err) + return "", err } + prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{ + StoreID: storeID, + Name: defaultWaffoPancakeProductName, + Prices: pancake.Prices{ + "USD": { + Amount: "1.00", // overridden at checkout via PriceSnapshot + TaxCategory: pancake.TaxCategory("saas"), + }, + }, + SuccessURL: optionalString(strings.TrimSpace(returnURL)), + }) + if err != nil { + return "", fmt.Errorf("create Waffo Pancake product: %w", err) + } + productID := prodRes.Product.ID + if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil { + return "", fmt.Errorf("publish Waffo Pancake product: %w", err) + } + return productID, nil +} - digest := sha256.Sum256([]byte(signatureInput)) - if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature); err != nil { - return fmt.Errorf("verify webhook signature: %w", err) +// WaffoPancakePairResult is the response of CreateWaffoPancakePrimaryPair. +// When OrphanStore is true the store was created but the product wasn't, +// so the caller can surface a partial-failure message with StoreID. +type WaffoPancakePairResult struct { + StoreID string + StoreName string + ProductID string + ProductName string + OrphanStore bool +} + +// CreateWaffoPancakePrimaryPair mints a Store + OnetimeProduct in one +// round-trip — the canonical "+ Create" entry point. Nothing is persisted +// to settings; the operator's final Save commits the chosen IDs. +func CreateWaffoPancakePrimaryPair(ctx context.Context, merchantID, privateKey, returnURL string) (*WaffoPancakePairResult, error) { + storeID, err := CreateWaffoPancakePrimaryStore(ctx, merchantID, privateKey) + if err != nil { + return nil, err + } + productID, err := CreateWaffoPancakePrimaryProduct(ctx, merchantID, privateKey, storeID, returnURL) + if err != nil { + return &WaffoPancakePairResult{ + StoreID: storeID, + StoreName: defaultWaffoPancakeStoreName, + OrphanStore: true, + }, fmt.Errorf("store created at %s but product creation failed: %w", storeID, err) + } + return &WaffoPancakePairResult{ + StoreID: storeID, + StoreName: defaultWaffoPancakeStoreName, + ProductID: productID, + ProductName: defaultWaffoPancakeProductName, + }, nil +} + +// SaveWaffoPancakeConfig persists the operator-controlled fields atomically +// at the end of the configuration flow via model.UpdateOptionsBulk (single +// DB transaction). A blank privateKey is treated as "keep current" +// (Stripe-style API-secret UX) and is omitted from the bulk payload. +func SaveWaffoPancakeConfig(ctx context.Context, merchantID, privateKey, returnURL, storeID, productID string) error { + merchantID = strings.TrimSpace(merchantID) + storeID = strings.TrimSpace(storeID) + productID = strings.TrimSpace(productID) + if merchantID == "" || storeID == "" || productID == "" { + return fmt.Errorf("merchant id, store id, and product id are required to save") + } + values := map[string]string{ + "WaffoPancakeMerchantID": merchantID, + "WaffoPancakeReturnURL": strings.TrimSpace(returnURL), + "WaffoPancakeStoreID": storeID, + "WaffoPancakeProductID": productID, + } + if pk := strings.TrimSpace(privateKey); pk != "" { + values["WaffoPancakePrivateKey"] = pk + } + if err := model.UpdateOptionsBulk(values); err != nil { + return fmt.Errorf("persist Waffo Pancake config: %w", err) } return nil } + +type WaffoPancakeCatalogProduct struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +// WaffoPancakeCatalogStore nests its OnetimeProducts so the UI can render a +// dependent store→product select without a second round-trip. +type WaffoPancakeCatalogStore struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ProdEnabled bool `json:"prodEnabled"` + OnetimeProducts []WaffoPancakeCatalogProduct `json:"onetimeProducts"` +} + +type WaffoPancakeCatalog struct { + Stores []WaffoPancakeCatalogStore `json:"stores"` +} + +// ListWaffoPancakeCatalog queries Pancake's GraphQL `stores` for the +// merchant's stores + onetime products. A successful call also proves +// the supplied credentials authenticate (doubles as a credential probe). +func ListWaffoPancakeCatalog(ctx context.Context, merchantID, privateKey string) (*WaffoPancakeCatalog, error) { + client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey) + if err != nil { + return nil, err + } + + type queryShape struct { + Stores []WaffoPancakeCatalogStore `json:"stores"` + } + // `limit: 100` because the API returns a single store when limit is + // omitted, even for multi-store merchants. Bump to paginated fetches + // (via `offset`) if real catalogs ever cross the cap. + resp, err := pancake.GraphQLQuery[queryShape](ctx, client, pancake.GraphQLParams{ + Query: `query { + stores(limit: 100) { + id + name + status + prodEnabled + onetimeProducts { + id + name + status + } + } + }`, + }) + if err != nil { + return nil, fmt.Errorf("query Waffo Pancake catalog: %w", err) + } + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("waffo pancake catalog query returned %d errors: %s", + len(resp.Errors), resp.Errors[0].Message) + } + // Drop non-active products. Operators should only see items they can + // actually bind without later hitting "product unavailable" at checkout. + stores := resp.Data.Stores + for i := range stores { + active := stores[i].OnetimeProducts[:0] + for _, p := range stores[i].OnetimeProducts { + if strings.EqualFold(strings.TrimSpace(p.Status), "active") { + active = append(active, p) + } + } + stores[i].OnetimeProducts = active + } + return &WaffoPancakeCatalog{Stores: stores}, nil +} diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go index eeb1012b..43df1bf5 100644 --- a/service/waffo_pancake_test.go +++ b/service/waffo_pancake_test.go @@ -8,7 +8,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" - "github.com/QuantumNous/new-api/setting" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -29,7 +28,7 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB { model.DB = db model.LOG_DB = db - require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{})) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}, &model.SubscriptionOrder{})) t.Cleanup(func() { sqlDB, err := db.DB() @@ -41,21 +40,6 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB { return db } -func TestWaffoPancakeCreateSessionResponseParsesDocumentedPayload(t *testing.T) { - var result waffoPancakeCreateSessionResponse - err := common.Unmarshal([]byte(`{ - "data": { - "sessionId": "cs_550e8400-e29b-41d4-a716-446655440000", - "checkoutUrl": "https://checkout.waffo.ai/my-store-abc123/checkout/cs_550e8400-e29b-41d4-a716-446655440000", - "expiresAt": "2026-01-22T10:30:00.000Z" - } - }`), &result) - require.NoError(t, err) - require.NotNil(t, result.Data) - require.Equal(t, "cs_550e8400-e29b-41d4-a716-446655440000", result.Data.SessionID) - require.Empty(t, result.Data.OrderID) -} - func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) { db := setupWaffoPancakeTestDB(t) @@ -64,21 +48,79 @@ func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *te Amount: 10, Money: 29, TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", - PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } require.NoError(t, db.Create(topUp).Error) - tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{ - Data: waffoPancakeWebhookData{ - OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", + tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", + MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(topUp.UserId), }, }) require.NoError(t, err) require.Equal(t, "ORD_5dXBtmF2HLlHfbPNm0Wcnz", tradeNo) } +func TestResolveWaffoPancakeTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + topUp := &model.TopUp{ + UserId: 42, + Amount: 10, + Money: 29, + TradeNo: "ORD_identity_mismatch_case", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + // Webhook reports the right order but a different buyer — could be a + // crossed-wires bug or a tampered payload. Either way: reject. + tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "ORD_identity_mismatch_case", + MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) + require.Contains(t, err.Error(), "buyer identity mismatch") +} + +func TestResolveWaffoPancakeTradeNo_RejectsMissingBuyerIdentity(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + topUp := &model.TopUp{ + UserId: 7, + Amount: 10, + Money: 29, + TradeNo: "ORD_missing_identity", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(topUp).Error) + + // An empty MerchantProvidedBuyerIdentity means the order was either created + // via the (now-deprecated) anonymous flow or the field was stripped — also + // reject so that we never credit anonymous orders to a specific user. + tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "ORD_missing_identity", + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) + require.Contains(t, err.Error(), "buyer identity mismatch") +} + func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) { db := setupWaffoPancakeTestDB(t) @@ -95,14 +137,15 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. Amount: 10, Money: 29, TradeNo: "WAFFO_PANCAKE-42-123456-abc123", - PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } require.NoError(t, db.Create(topUp).Error) - tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{ - Data: waffoPancakeWebhookData{ + tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ OrderID: "ORD_unknown", BuyerEmail: user.Email, Amount: "29.00", @@ -112,46 +155,107 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. require.Empty(t, tradeNo) } -func TestResolveWaffoPancakeWebhookEnvironment(t *testing.T) { - originalSandbox := setting.WaffoPancakeSandbox - t.Cleanup(func() { - setting.WaffoPancakeSandbox = originalSandbox +// Parity tests for ResolveWaffoPancakeSubscriptionTradeNo — same four cases +// as the TopUp resolver above, exercised against SubscriptionOrder records. +// Drift between the two webhook flows is a real risk because they share +// the same buyer-identity defence-in-depth pattern. + +func TestResolveWaffoPancakeSubscriptionTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + order := &model.SubscriptionOrder{ + UserId: 1, + PlanId: 5, + Money: 29, + TradeNo: "WAFFO_PANCAKE_SUB-1-1700000000-abc123", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(order).Error) + + tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123", + MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(order.UserId), + }, }) - - testCases := []struct { - name string - payload string - expected string - sandbox bool - }{ - { - name: "test mode", - payload: `{"mode":"test"}`, - expected: "test", - }, - { - name: "prod mode", - payload: `{"mode":"prod"}`, - expected: "prod", - }, - { - name: "missing mode falls back to sandbox", - payload: `{}`, - expected: "test", - sandbox: true, - }, - { - name: "invalid mode falls back to prod", - payload: `{"mode":"staging"}`, - expected: "prod", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - setting.WaffoPancakeSandbox = tc.sandbox - environment := resolveWaffoPancakeWebhookEnvironment(tc.payload) - require.Equal(t, tc.expected, environment) - }) - } + require.NoError(t, err) + require.Equal(t, "WAFFO_PANCAKE_SUB-1-1700000000-abc123", tradeNo) +} + +func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + order := &model.SubscriptionOrder{ + UserId: 42, + PlanId: 5, + Money: 29, + TradeNo: "WAFFO_PANCAKE_SUB-42-mismatch", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(order).Error) + + tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "WAFFO_PANCAKE_SUB-42-mismatch", + MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) + require.Contains(t, err.Error(), "buyer identity mismatch") +} + +func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsMissingBuyerIdentity(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + order := &model.SubscriptionOrder{ + UserId: 7, + PlanId: 5, + Money: 29, + TradeNo: "WAFFO_PANCAKE_SUB-7-missing-identity", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(order).Error) + + tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "WAFFO_PANCAKE_SUB-7-missing-identity", + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) + require.Contains(t, err.Error(), "buyer identity mismatch") +} + +func TestResolveWaffoPancakeSubscriptionTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) { + db := setupWaffoPancakeTestDB(t) + + order := &model.SubscriptionOrder{ + UserId: 42, + PlanId: 5, + Money: 29, + TradeNo: "WAFFO_PANCAKE_SUB-42-real-order", + PaymentMethod: model.PaymentMethodWaffoPancake, + PaymentProvider: model.PaymentProviderWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + require.NoError(t, db.Create(order).Error) + + tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ + Data: WaffoPancakeWebhookData{ + OrderID: "WAFFO_PANCAKE_SUB-unknown", + }, + }) + require.Error(t, err) + require.Empty(t, tradeNo) } diff --git a/setting/payment_waffo_pancake.go b/setting/payment_waffo_pancake.go index d655059a..32396aa6 100644 --- a/setting/payment_waffo_pancake.go +++ b/setting/payment_waffo_pancake.go @@ -1,16 +1,15 @@ package setting +// Waffo Pancake hosted checkout configuration. Gateway is enabled once +// MerchantID + PrivateKey + ProductID are populated (no separate Enabled +// flag, matching Stripe / Creem). StoreID + ProductID are operator-bound +// via SaveWaffoPancakeConfig. var ( - WaffoPancakeEnabled bool - WaffoPancakeSandbox bool - WaffoPancakeMerchantID string - WaffoPancakePrivateKey string - WaffoPancakeWebhookPublicKey string - WaffoPancakeWebhookTestKey string - WaffoPancakeStoreID string - WaffoPancakeProductID string - WaffoPancakeReturnURL string - WaffoPancakeCurrency string = "USD" - WaffoPancakeUnitPrice float64 = 1.0 - WaffoPancakeMinTopUp int = 1 + WaffoPancakeMerchantID string + WaffoPancakePrivateKey string + WaffoPancakeReturnURL string + WaffoPancakeUnitPrice float64 = 1.0 + WaffoPancakeMinTopUp int = 1 + WaffoPancakeStoreID string + WaffoPancakeProductID string ) diff --git a/web/classic/public/waffo-logo-dark.svg b/web/classic/public/waffo-logo-dark.svg new file mode 100644 index 00000000..18b5df03 --- /dev/null +++ b/web/classic/public/waffo-logo-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/classic/public/waffo-logo-light.svg b/web/classic/public/waffo-logo-light.svg new file mode 100644 index 00000000..a7bdce05 --- /dev/null +++ b/web/classic/public/waffo-logo-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/classic/src/components/settings/PaymentSetting.jsx b/web/classic/src/components/settings/PaymentSetting.jsx index 362473e2..bb9b2dfd 100644 --- a/web/classic/src/components/settings/PaymentSetting.jsx +++ b/web/classic/src/components/settings/PaymentSetting.jsx @@ -53,16 +53,9 @@ const PaymentSetting = () => { StripeMinTopUp: 1, StripePromotionCodesEnabled: false, - WaffoPancakeEnabled: false, - WaffoPancakeSandbox: false, WaffoPancakeMerchantID: '', WaffoPancakePrivateKey: '', - WaffoPancakeStoreID: '', - WaffoPancakeProductID: '', WaffoPancakeReturnURL: '', - WaffoPancakeCurrency: 'USD', - WaffoPancakeUnitPrice: 1.0, - WaffoPancakeMinTopUp: 1, 'payment_setting.compliance_confirmed': false, 'payment_setting.compliance_terms_version': '', 'payment_setting.compliance_confirmed_at': 0, @@ -171,21 +164,13 @@ const PaymentSetting = () => { case 'MinTopUp': case 'StripeUnitPrice': case 'StripeMinTopUp': - case 'WaffoPancakeUnitPrice': - case 'WaffoPancakeMinTopUp': newInputs[item.key] = parseFloat(item.value); break; case 'WaffoPancakeMerchantID': case 'WaffoPancakePrivateKey': - case 'WaffoPancakeStoreID': - case 'WaffoPancakeProductID': case 'WaffoPancakeReturnURL': - case 'WaffoPancakeCurrency': newInputs[item.key] = item.value; break; - case 'WaffoPancakeSandbox': - newInputs[item.key] = toBoolean(item.value); - break; default: if (item.key.endsWith('Enabled')) { newInputs[item.key] = toBoolean(item.value); @@ -320,6 +305,13 @@ const PaymentSetting = () => { hideSectionTitle /> + + + { hideSectionTitle /> - {/**/} - {/* */} - {/**/} diff --git a/web/classic/src/components/topup/RechargeCard.jsx b/web/classic/src/components/topup/RechargeCard.jsx index f89d8ed7..4fe2035a 100644 --- a/web/classic/src/components/topup/RechargeCard.jsx +++ b/web/classic/src/components/topup/RechargeCard.jsx @@ -47,6 +47,7 @@ import { } from 'lucide-react'; import { IconGift } from '@douyinfe/semi-icons'; import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime'; +import { useActualTheme } from '../../context/Theme'; import { getCurrencyConfig } from '../../helpers/render'; import SubscriptionPlansCard from './SubscriptionPlansCard'; @@ -102,6 +103,7 @@ const RechargeCard = ({ const redeemFormApiRef = useRef(null); const initialTabSetRef = useRef(false); const showAmountSkeleton = useMinimumLoadingTime(amountLoading); + const actualTheme = useActualTheme(); const [activeTab, setActiveTab] = useState('topup'); const shouldShowSubscription = !subscriptionLoading && subscriptionPlans.length > 0; @@ -355,9 +357,18 @@ const RechargeCard = ({ }} /> ) : payMethod.type === 'waffo_pancake' ? ( - ) : ( { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); @@ -454,8 +471,12 @@ const TopUp = () => { const { message, data } = res.data; if (message === 'success') { const checkoutUrl = data?.checkout_url || ''; - if (checkoutUrl) { - window.open(checkoutUrl, '_blank'); + if (checkoutUrl && isSafeHttpCheckoutUrl(checkoutUrl)) { + // In-tab redirect (not window.open) — popup blocker fires after + // the await loses user-gesture context. + window.location.href = checkoutUrl; + } else if (checkoutUrl) { + showError(t('支付跳转地址不安全')); } else { showError(t('支付请求失败')); } diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index ea6bca1b..17511d2a 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -1720,6 +1720,7 @@ "支付渠道": "Payment Channels", "支付设置": "Payment", "支付请求失败": "Payment request failed", + "支付跳转地址不安全": "Unsafe payment redirect URL", "支付返回地址": "Return URL", "支付金额": "Payment Amount", "支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images", diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json index 88ac70c1..b70e8ffb 100644 --- a/web/classic/src/i18n/locales/zh.json +++ b/web/classic/src/i18n/locales/zh.json @@ -1154,6 +1154,7 @@ "支付方式": "支付方式", "支付设置": "支付设置", "支付请求失败": "支付请求失败", + "支付跳转地址不安全": "支付跳转地址不安全", "支付金额": "支付金额", "支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。", diff --git a/web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx b/web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx index 202576df..afe3fa0f 100644 --- a/web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx +++ b/web/classic/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx @@ -26,25 +26,14 @@ import { showSuccess, } from '../../../helpers'; import { useTranslation } from 'react-i18next'; -import { BookOpen, TriangleAlert } from 'lucide-react'; +import { BookOpen } from 'lucide-react'; const defaultInputs = { - WaffoPancakeEnabled: false, - WaffoPancakeSandbox: false, WaffoPancakeMerchantID: '', WaffoPancakePrivateKey: '', - WaffoPancakeWebhookPublicKey: '', - WaffoPancakeWebhookTestKey: '', - WaffoPancakeStoreID: '', - WaffoPancakeProductID: '', WaffoPancakeReturnURL: '', - WaffoPancakeCurrency: 'USD', - WaffoPancakeUnitPrice: 1.0, - WaffoPancakeMinTopUp: 1, }; -const toBoolean = (value) => value === true || value === 'true'; - export default function SettingsPaymentGatewayWaffoPancake(props) { const { t } = useTranslation(); const sectionTitle = props.hideSectionTitle @@ -58,26 +47,9 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { if (!props.options || !formApiRef.current) return; const currentInputs = { - WaffoPancakeEnabled: toBoolean(props.options.WaffoPancakeEnabled), - WaffoPancakeSandbox: toBoolean(props.options.WaffoPancakeSandbox), WaffoPancakeMerchantID: props.options.WaffoPancakeMerchantID || '', WaffoPancakePrivateKey: props.options.WaffoPancakePrivateKey || '', - WaffoPancakeWebhookPublicKey: - props.options.WaffoPancakeWebhookPublicKey || '', - WaffoPancakeWebhookTestKey: - props.options.WaffoPancakeWebhookTestKey || '', - WaffoPancakeStoreID: props.options.WaffoPancakeStoreID || '', - WaffoPancakeProductID: props.options.WaffoPancakeProductID || '', WaffoPancakeReturnURL: props.options.WaffoPancakeReturnURL || '', - WaffoPancakeCurrency: props.options.WaffoPancakeCurrency || 'USD', - WaffoPancakeUnitPrice: - props.options.WaffoPancakeUnitPrice !== undefined - ? parseFloat(props.options.WaffoPancakeUnitPrice) - : 1.0, - WaffoPancakeMinTopUp: - props.options.WaffoPancakeMinTopUp !== undefined - ? parseFloat(props.options.WaffoPancakeMinTopUp) - : 1, }; setInputs(currentInputs); @@ -93,90 +65,23 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { ...inputs, ...(formApiRef.current?.getValues?.() || {}), }; - values.WaffoPancakeEnabled = toBoolean(values.WaffoPancakeEnabled); - values.WaffoPancakeSandbox = toBoolean(values.WaffoPancakeSandbox); - const currentWebhookField = values.WaffoPancakeSandbox - ? 'WaffoPancakeWebhookTestKey' - : 'WaffoPancakeWebhookPublicKey'; - const currentWebhookLabel = values.WaffoPancakeSandbox - ? t('Webhook 公钥(测试环境)') - : t('Webhook 公钥(生产环境)'); - - if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) { - showError(t('请输入商户 ID')); - return; - } - - if (values.WaffoPancakeEnabled && !values.WaffoPancakeStoreID.trim()) { - showError(t('请输入 Store ID')); - return; - } - - if (values.WaffoPancakeEnabled && !values.WaffoPancakeProductID.trim()) { - showError(t('请输入 Product ID')); - return; - } - - if ( - values.WaffoPancakeEnabled && - !String(values[currentWebhookField] || '').trim() - ) { - showError(currentWebhookLabel); - return; - } - - if ( - values.WaffoPancakeEnabled && - Number(values.WaffoPancakeUnitPrice) <= 0 - ) { - showError(t('充值价格必须大于 0')); - return; - } - - if (values.WaffoPancakeEnabled && Number(values.WaffoPancakeMinTopUp) < 1) { - showError(t('最低充值美元数量必须大于 0')); - return; - } setLoading(true); try { + // Classic admin only persists the three operator-typed fields. + // Store/Product binding is handled exclusively by the default + // frontend's catalog flow (see waffo-pancake-settings-section.tsx) + // because picking entities from a live catalog needs the Select + + // dependent-dropdown UX that the classic Semi-UI page doesn't have. const options = [ - { - key: 'WaffoPancakeEnabled', - value: values.WaffoPancakeEnabled ? 'true' : 'false', - }, - { - key: 'WaffoPancakeSandbox', - value: values.WaffoPancakeSandbox ? 'true' : 'false', - }, { key: 'WaffoPancakeMerchantID', value: values.WaffoPancakeMerchantID || '', }, - { - key: 'WaffoPancakeStoreID', - value: values.WaffoPancakeStoreID || '', - }, - { - key: 'WaffoPancakeProductID', - value: values.WaffoPancakeProductID || '', - }, { key: 'WaffoPancakeReturnURL', value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''), }, - { - key: 'WaffoPancakeCurrency', - value: values.WaffoPancakeCurrency || 'USD', - }, - { - key: 'WaffoPancakeUnitPrice', - value: String(values.WaffoPancakeUnitPrice), - }, - { - key: 'WaffoPancakeMinTopUp', - value: String(values.WaffoPancakeMinTopUp), - }, ]; if ((values.WaffoPancakePrivateKey || '').trim()) { @@ -186,20 +91,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { }); } - if ((values.WaffoPancakeWebhookPublicKey || '').trim()) { - options.push({ - key: 'WaffoPancakeWebhookPublicKey', - value: values.WaffoPancakeWebhookPublicKey, - }); - } - - if ((values.WaffoPancakeWebhookTestKey || '').trim()) { - options.push({ - key: 'WaffoPancakeWebhookTestKey', - value: values.WaffoPancakeWebhookTestKey, - }); - } - const results = await Promise.all( options.map((opt) => API.put('/api/option/', { @@ -237,103 +128,43 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { icon={} description={ <> - Waffo Pancake 的商户、商品和签名密钥请 + Waffo Pancake 商户 ID 与私钥请在 - 点击此处 + Waffo Pancake 控制台 - 获取,建议先在测试环境完成联调。 + 获取,保存后系统会自动在该商户名下创建 Store + Product,无需手动配置; + 环境(test / 生产)由你粘贴的 API 私钥本身决定。 + 请在 Pancake 控制台把下面两个回调地址分别注册到 Test Mode 和 Production Mode + 两个 webhook 位置,分开走避免测试流量污染生产数据:
- {t('回调地址')}: + {t('Test 回调地址')}: {props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')} - /api/waffo-pancake/webhook + /api/waffo-pancake/webhook/test +
+ {t('Production 回调地址')}: + {props.options.ServerAddress + ? removeTrailingSlash(props.options.ServerAddress) + : t('网站地址')} + /api/waffo-pancake/webhook/prod } style={{ marginBottom: 12 }} /> - } - description={t( - '请确认 Merchant、Store、Product 和所选环境密钥一致。', - )} - style={{ marginBottom: 16 }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - @@ -341,7 +172,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { field='WaffoPancakeReturnURL' label={t('支付返回地址')} placeholder={t('例如:https://example.com/console/topup')} - extraText={t('留空则自动使用当前站点的默认充值页地址')} /> @@ -350,55 +180,16 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} style={{ marginTop: 16 }} > - + - - - - - - - - - - - - + )} )} {hasEpay && ( diff --git a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx index f478cb75..a3e3d1c2 100644 --- a/web/default/src/features/subscriptions/components/subscriptions-columns.tsx +++ b/web/default/src/features/subscriptions/components/subscriptions-columns.tsx @@ -162,6 +162,13 @@ export function useSubscriptionsColumns(): ColumnDef[] { {plan.creem_product_id && ( )} + {plan.waffo_pancake_product_id && ( + + )} ) }, diff --git a/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx b/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx index 654d0ee8..a842a042 100644 --- a/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx +++ b/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx @@ -51,7 +51,13 @@ import { SheetTitle, } from '@/components/ui/sheet' import { Switch } from '@/components/ui/switch' -import { createPlan, updatePlan, getGroups } from '../api' +import { + createPlan, + updatePlan, + getGroups, + createWaffoPancakeSubscriptionProduct, + listWaffoPancakeSubscriptionProductOptions, +} from '../api' import { getDurationUnitOptions, getResetPeriodOptions } from '../constants' import { getPlanFormSchema, @@ -79,6 +85,10 @@ export function SubscriptionsMutateDrawer({ const { triggerRefresh } = useSubscriptions() const [isSubmitting, setIsSubmitting] = useState(false) const [groupOptions, setGroupOptions] = useState([]) + const [creatingPancakeProduct, setCreatingPancakeProduct] = useState(false) + const [pancakeProducts, setPancakeProducts] = useState< + { id: string; name: string; status: string }[] + >([]) const schema = getPlanFormSchema(t) const form = useForm({ @@ -98,11 +108,35 @@ export function SubscriptionsMutateDrawer({ if (res.success) setGroupOptions(res.data || []) }) .catch(() => {}) + // Best-effort — empty list still lets the operator use "+ Create". + listWaffoPancakeSubscriptionProductOptions() + .then((res) => { + if ( + res.message === 'success' && + typeof res.data === 'object' && + res.data && + Array.isArray((res.data as { products?: unknown }).products) + ) { + setPancakeProducts( + (res.data as { products: typeof pancakeProducts }).products + ) + } else { + setPancakeProducts([]) + } + }) + .catch(() => setPancakeProducts([])) } }, [open, currentRow, form]) const durationUnit = form.watch('duration_unit') const resetPeriod = form.watch('quota_reset_period') + // Gate "+ Create on Pancake" on the same checks the mint handler runs. + const watchedTitle = form.watch('title') + const watchedPrice = form.watch('price_amount') + const pancakeCreateReady = + typeof watchedTitle === 'string' && + watchedTitle.trim().length > 0 && + Number(watchedPrice ?? 0) > 0 const onSubmit = async (values: PlanFormValues) => { setIsSubmitting(true) @@ -130,6 +164,72 @@ export function SubscriptionsMutateDrawer({ } } + // Mints a Pancake OnetimeProduct (not SubscriptionProduct — see + // controller) using persisted creds + the form's title/price, then + // pins the returned PROD_ ID into the form field. + const handleCreatePancakeProduct = async () => { + const title = form.getValues('title').trim() + const priceAmount = Number(form.getValues('price_amount') || 0) + if (!title) { + toast.error(t('Plan title is required')) + return + } + if (priceAmount <= 0) { + toast.error(t('Plan price must be greater than zero')) + return + } + setCreatingPancakeProduct(true) + try { + const res = await createWaffoPancakeSubscriptionProduct({ + name: title, + amount: priceAmount.toFixed(2), + }) + if ( + res.message === 'success' && + typeof res.data === 'object' && + res.data + ) { + const created = res.data as { product_id: string; product_name: string } + form.setValue('waffo_pancake_product_id', created.product_id, { + shouldDirty: true, + }) + // Refetch from GraphQL so the dropdown reflects authoritative state. + try { + const refresh = await listWaffoPancakeSubscriptionProductOptions() + if ( + refresh.message === 'success' && + typeof refresh.data === 'object' && + refresh.data && + Array.isArray((refresh.data as { products?: unknown }).products) + ) { + setPancakeProducts( + (refresh.data as { products: typeof pancakeProducts }).products + ) + } + } catch { + // Best-effort — form value already points at the new product; + // raw-ID fallback covers the missing label. + } + toast.success( + `${t('Waffo Pancake product created')}: ${created.product_id}` + ) + } else { + const reason = typeof res.data === 'string' ? res.data : undefined + toast.error( + reason + ? `${t('Waffo Pancake product creation failed')}: ${reason}` + : t('Waffo Pancake product creation failed') + ) + } + } catch (err) { + toast.error( + `${t('Waffo Pancake product creation failed')}: ${err instanceof Error ? err.message : String(err)}` + ) + } finally { + setCreatingPancakeProduct(false) + } + } + const durationUnitOpts = getDurationUnitOptions(t) const resetPeriodOpts = getResetPeriodOptions(t) @@ -546,6 +646,67 @@ export function SubscriptionsMutateDrawer({ )} /> + + { + // 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 ( + + Waffo Pancake Product ID +
+ + +
+ + {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.' + )} + + +
+ ) + }} + /> diff --git a/web/default/src/features/subscriptions/lib/plan-form.ts b/web/default/src/features/subscriptions/lib/plan-form.ts index a4f21207..6dde24e2 100644 --- a/web/default/src/features/subscriptions/lib/plan-form.ts +++ b/web/default/src/features/subscriptions/lib/plan-form.ts @@ -43,6 +43,7 @@ export function getPlanFormSchema(t: TFunction) { upgrade_group: z.string().optional(), stripe_price_id: z.string().optional(), creem_product_id: z.string().optional(), + waffo_pancake_product_id: z.string().optional(), }) } @@ -64,6 +65,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = { upgrade_group: '', stripe_price_id: '', creem_product_id: '', + waffo_pancake_product_id: '', } export function planToFormValues(plan: SubscriptionPlan): PlanFormValues { @@ -83,6 +85,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues { upgrade_group: plan.upgrade_group || '', stripe_price_id: plan.stripe_price_id || '', creem_product_id: plan.creem_product_id || '', + waffo_pancake_product_id: plan.waffo_pancake_product_id || '', } } diff --git a/web/default/src/features/subscriptions/types.ts b/web/default/src/features/subscriptions/types.ts index 43148409..29f88394 100644 --- a/web/default/src/features/subscriptions/types.ts +++ b/web/default/src/features/subscriptions/types.ts @@ -40,6 +40,7 @@ export const subscriptionPlanSchema = z.object({ upgrade_group: z.string().optional(), stripe_price_id: z.string().optional(), creem_product_id: z.string().optional(), + waffo_pancake_product_id: z.string().optional(), }) export type SubscriptionPlan = z.infer @@ -94,8 +95,17 @@ export interface SubscriptionPayResponse { success: boolean message?: string data?: { + // Stripe-style hosted checkout link. pay_link?: string + // Waffo Pancake / Creem hosted checkout URL. checkout_url?: string + // Pancake-only: order metadata + self-service buyer session token, + // surfaced for future flows (refund / cancel from new-api's own UI). + session_id?: string + expires_at?: number | string + order_id?: string + token?: string + token_expires_at?: number | string } url?: string } diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index 3b006f77..93817224 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -96,18 +96,11 @@ const defaultBillingSettings: BillingSettings = { WaffoNotifyUrl: '', WaffoReturnUrl: '', WaffoPayMethods: '[]', - WaffoPancakeEnabled: false, - WaffoPancakeSandbox: false, WaffoPancakeMerchantID: '', WaffoPancakePrivateKey: '', - WaffoPancakeWebhookPublicKey: '', - WaffoPancakeWebhookTestKey: '', + WaffoPancakeReturnURL: '', WaffoPancakeStoreID: '', WaffoPancakeProductID: '', - WaffoPancakeReturnURL: '', - WaffoPancakeCurrency: 'USD', - WaffoPancakeUnitPrice: 1, - WaffoPancakeMinTopUp: 1, 'checkin_setting.enabled': false, 'checkin_setting.min_quota': 1000, 'checkin_setting.max_quota': 10000, diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index ee829e23..2e43d66e 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -177,20 +177,12 @@ const BILLING_SECTIONS = [ WaffoPayMethods: settings.WaffoPayMethods ?? '[]', }} waffoPancakeDefaultValues={{ - WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false, - WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false, WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '', WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '', - WaffoPancakeWebhookPublicKey: - settings.WaffoPancakeWebhookPublicKey ?? '', - WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '', - WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '', - WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '', WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '', - WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD', - WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1, - WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1, }} + waffoPancakeProvisionedStoreID={settings.WaffoPancakeStoreID ?? ''} + waffoPancakeProvisionedProductID={settings.WaffoPancakeProductID ?? ''} complianceDefaults={{ confirmed: settings['payment_setting.compliance_confirmed'] ?? false, termsVersion: 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 e53721f0..96add9ae 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 @@ -149,6 +149,8 @@ type PaymentSettingsSectionProps = { defaultValues: PaymentFormValues waffoDefaultValues: WaffoSettingsValues waffoPancakeDefaultValues: WaffoPancakeSettingsValues + waffoPancakeProvisionedStoreID?: string + waffoPancakeProvisionedProductID?: string complianceDefaults: PaymentComplianceDefaults } @@ -156,6 +158,8 @@ export function PaymentSettingsSection({ defaultValues, waffoDefaultValues, waffoPancakeDefaultValues, + waffoPancakeProvisionedStoreID, + waffoPancakeProvisionedProductID, complianceDefaults, }: PaymentSettingsSectionProps) { const { t } = useTranslation() @@ -1468,11 +1472,15 @@ export function PaymentSettingsSection({ - + - + {/* eslint-enable react-hooks/refs */} ) diff --git a/web/default/src/features/system-settings/integrations/waffo-pancake-api.ts b/web/default/src/features/system-settings/integrations/waffo-pancake-api.ts new file mode 100644 index 00000000..f0a6e115 --- /dev/null +++ b/web/default/src/features/system-settings/integrations/waffo-pancake-api.ts @@ -0,0 +1,102 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +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 { + message?: string + data?: T | string +} + +export type CatalogResponse = BackendBody<{ stores: CatalogStore[] }> +export type PairResponse = BackendBody +export type SaveResponse = BackendBody<{ product_id: string; store_id: string }> + +export async function listWaffoPancakeCatalog( + merchantID: string, + privateKey: string +): Promise { + const res = await api.post( + '/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 { + const res = await api.post('/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 { + const res = await api.post('/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 +} diff --git a/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx b/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx index 73becb89..8b0fa16a 100644 --- a/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx +++ b/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx @@ -16,293 +16,714 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useEffect, useState } from 'react' +import * as React from 'react' +import * as z from 'zod' import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Switch } from '@/components/ui/switch' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' -import { SettingsSection } from '../components/settings-section' -import { useUpdateOption } from '../hooks/use-update-option' import { removeTrailingSlash } from './utils' +import { + type CatalogStore, + type PairOrphanError, + type PairResult, + createWaffoPancakePair, + listWaffoPancakeCatalog, + saveWaffoPancakeConfig, +} from './waffo-pancake-api' -export interface WaffoPancakeSettingsValues { - WaffoPancakeEnabled: boolean - WaffoPancakeSandbox: boolean - WaffoPancakeMerchantID: string - WaffoPancakePrivateKey: string - WaffoPancakeWebhookPublicKey: string - WaffoPancakeWebhookTestKey: string - WaffoPancakeStoreID: string - WaffoPancakeProductID: string +// Only operator-typed fields. Nothing else lands in OptionMap until Save. +const waffoPancakeSchema = z.object({ + WaffoPancakeMerchantID: z.string(), + WaffoPancakePrivateKey: z.string(), +}) + +export type WaffoPancakeSettingsValues = z.infer & { WaffoPancakeReturnURL: string - WaffoPancakeCurrency: string - WaffoPancakeUnitPrice: number - WaffoPancakeMinTopUp: number } interface Props { defaultValues: WaffoPancakeSettingsValues + provisionedStoreID?: string + provisionedProductID?: string } +const PANCAKE_DASHBOARD_URL = 'https://pancake.waffo.ai/merchant/dashboard' +const DEFAULT_NEW_STORE_NAME = 'new-api-store' +const DEFAULT_NEW_PRODUCT_NAME = 'new-api-charge-product' +const DEFAULT_NEW_PAIR_NAME = `${DEFAULT_NEW_STORE_NAME} + ${DEFAULT_NEW_PRODUCT_NAME}` + export function WaffoPancakeSettingsSection(props: Props) { const { t } = useTranslation() - const updateOption = useUpdateOption() - const [loading, setLoading] = useState(false) - const form = useForm({ - defaultValues: props.defaultValues, + + const [storeID, setStoreID] = React.useState( + props.provisionedStoreID ?? '' + ) + const [productID, setProductID] = React.useState( + props.provisionedProductID ?? '' + ) + + const [phase, setPhase] = React.useState<'idle' | 'verifying' | 'saving'>( + 'idle' + ) + const [catalog, setCatalog] = React.useState([]) + // Seed dropdowns from saved bindings so they render on first paint instead + // of waiting for the async catalog fetch to confirm them. + const [chosenStoreID, setChosenStoreID] = React.useState( + props.provisionedStoreID ?? '' + ) + const [chosenProductID, setChosenProductID] = React.useState( + props.provisionedProductID ?? '' + ) + const [returnURL, setReturnURL] = React.useState( + props.defaultValues.WaffoPancakeReturnURL ?? '' + ) + const [creatingPair, setCreatingPair] = React.useState(false) + + const initialRef = React.useRef(props.defaultValues) + const defaultsSignature = React.useMemo( + () => JSON.stringify(props.defaultValues), + [props.defaultValues] + ) + + // "merchantID|privateKey" of the last verified pair; debounced verify + // skips when nothing changed. + const lastVerifiedSignature = React.useRef('') + const fetchSerialRef = React.useRef(0) + + const form = useForm({ + resolver: zodResolver(waffoPancakeSchema), + mode: 'onChange', + defaultValues: { + WaffoPancakeMerchantID: props.defaultValues.WaffoPancakeMerchantID, + WaffoPancakePrivateKey: props.defaultValues.WaffoPancakePrivateKey, + }, }) - useEffect(() => { - form.reset(props.defaultValues) - }, [props.defaultValues, form]) + // Mount-only — never re-sync from props after the first render. The + // backend strips PrivateKey from GET /api/option/, so a re-sync would + // wipe whatever the operator just typed. + const didMountRef = React.useRef(false) + React.useEffect(() => { + const parsed = JSON.parse(defaultsSignature) as WaffoPancakeSettingsValues + initialRef.current = parsed + if (didMountRef.current) return + didMountRef.current = true + form.reset({ + WaffoPancakeMerchantID: parsed.WaffoPancakeMerchantID, + WaffoPancakePrivateKey: parsed.WaffoPancakePrivateKey, + }) + setReturnURL(parsed.WaffoPancakeReturnURL ?? '') + lastVerifiedSignature.current = `${parsed.WaffoPancakeMerchantID.trim()}|${parsed.WaffoPancakePrivateKey.trim()}` + }, [defaultsSignature, form]) - const handleSave = async () => { - const values = form.getValues() - const enabled = !!values.WaffoPancakeEnabled - const sandbox = !!values.WaffoPancakeSandbox + React.useEffect(() => { + setStoreID(props.provisionedStoreID ?? '') + }, [props.provisionedStoreID]) - if (enabled && !values.WaffoPancakeMerchantID.trim()) { - toast.error(t('Merchant ID is required')) - return + React.useEffect(() => { + setProductID(props.provisionedProductID ?? '') + }, [props.provisionedProductID]) + + const productsForChosenStore = React.useMemo(() => { + if (!chosenStoreID) return [] + return catalog.find((s) => s.id === chosenStoreID)?.onetimeProducts ?? [] + }, [catalog, chosenStoreID]) + + // Raw-ID fallback items render the trigger before the catalog loads or + // when the saved entity has been deleted upstream. + const storeSelectItems = React.useMemo(() => { + const items = catalog.map((s) => ({ + value: s.id, + label: `${s.name} (${s.id})`, + })) + if (chosenStoreID && !catalog.some((s) => s.id === chosenStoreID)) { + items.push({ value: chosenStoreID, label: chosenStoreID }) } - - if (enabled && !values.WaffoPancakeStoreID.trim()) { - toast.error(t('Store ID is required')) - return + return items + }, [catalog, chosenStoreID]) + const productSelectItems = React.useMemo(() => { + const items = productsForChosenStore.map((p) => ({ + value: p.id, + label: `${p.name} (${p.id})`, + })) + if ( + chosenProductID && + !productsForChosenStore.some((p) => p.id === chosenProductID) + ) { + items.push({ value: chosenProductID, label: chosenProductID }) } + return items + }, [productsForChosenStore, chosenProductID]) - if (enabled && !values.WaffoPancakeProductID.trim()) { - toast.error(t('Product ID is required')) - return - } + // Verifies typed creds against Pancake (via /catalog) and refreshes the + // dropdown options. `preselect` overrides the post-load anchor selection; + // omitting it defaults to: saved binding → first store with products. + const verifyAndFetchCatalog = React.useCallback( + async ( + merchantID: string, + privateKey: string, + preselect?: { storeID?: string; productID?: string } + ) => { + const serial = ++fetchSerialRef.current + let stores: CatalogStore[] = [] + try { + const body = await listWaffoPancakeCatalog(merchantID, privateKey) + if (serial !== fetchSerialRef.current) return + if ( + body?.message === 'success' && + typeof body.data === 'object' && + body.data + ) { + stores = (body.data as { stores: CatalogStore[] }).stores ?? [] + } else { + const reason = typeof body?.data === 'string' ? body.data : undefined + toast.error( + reason + ? `${t('Credentials verification failed')}: ${reason}` + : t( + 'Credentials verification failed — double-check Merchant ID and API private key.' + ) + ) + setPhase('idle') + return + } + } catch (err) { + if (serial !== fetchSerialRef.current) return + toast.error( + `${t('Credentials verification failed')}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + setPhase('idle') + return + } + if (serial !== fetchSerialRef.current) return - const requiredWebhookKey = sandbox - ? values.WaffoPancakeWebhookTestKey - : values.WaffoPancakeWebhookPublicKey - if (enabled && !String(requiredWebhookKey || '').trim()) { + setCatalog(stores) + if (preselect) { + setChosenStoreID(preselect.storeID ?? '') + setChosenProductID(preselect.productID ?? '') + } else { + // Default anchor: bound product if found, else first product of + // the first store with any — saves a click for new operators. + const boundStore = stores.find((s) => + s.onetimeProducts.some((p) => p.id === productID) + ) + if (boundStore && productID) { + setChosenStoreID(boundStore.id) + setChosenProductID(productID) + } else { + const storeWithProducts = stores.find( + (s) => s.onetimeProducts.length > 0 + ) + if (storeWithProducts) { + setChosenStoreID(storeWithProducts.id) + setChosenProductID(storeWithProducts.onetimeProducts[0].id) + } else { + setChosenStoreID('') + setChosenProductID('') + } + } + } + setPhase('idle') + }, + [productID, t] + ) + + const watchedMerchantID = form.watch('WaffoPancakeMerchantID') || '' + const watchedPrivateKey = form.watch('WaffoPancakePrivateKey') || '' + React.useEffect(() => { + const m = watchedMerchantID.trim() + const k = watchedPrivateKey.trim() + if (!m || !k) return + const signature = `${m}|${k}` + if (signature === lastVerifiedSignature.current) return + const timer = setTimeout(() => { + lastVerifiedSignature.current = signature + setPhase('verifying') + void verifyAndFetchCatalog(m, k) + }, 800) + return () => clearTimeout(timer) + }, [watchedMerchantID, watchedPrivateKey, verifyAndFetchCatalog]) + + // Initial-load verify: GET /api/option/ strips PrivateKey so a returning + // admin opens the page with empty key. Send blank creds in the body — + // the catalog controller falls back to the persisted OptionMap creds. + const initialLoadRef = React.useRef(false) + React.useEffect(() => { + if (initialLoadRef.current) return + if (!props.defaultValues.WaffoPancakeMerchantID.trim()) return + initialLoadRef.current = true + setPhase('verifying') + void verifyAndFetchCatalog('', '') + }, [props.defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog]) + + // Returns typed creds when the operator edited either field; otherwise + // blanks so the backend falls back to persisted creds. Without this, + // returning admins (saved merchant ID but empty key field) would send + // a mixed-state body that the backend rejects. + const readCreds = () => { + const formMerchant = ( + form.getValues('WaffoPancakeMerchantID') || '' + ).trim() + const formKey = (form.getValues('WaffoPancakePrivateKey') || '').trim() + const saved = (props.defaultValues.WaffoPancakeMerchantID || '').trim() + const edited = formMerchant !== saved || formKey.length > 0 + if (!edited) return { merchantID: '', privateKey: '' } + return { merchantID: formMerchant, privateKey: formKey } + } + + // The minted product's SuccessURL is pinned to the current Return URL + // field, so we prompt before creating when that field is empty. + const handleCreatePair = async () => { + if (!credsReady) { toast.error( - sandbox - ? t('Webhook public key (sandbox) is required') - : t('Webhook public key (production) is required') + t('Fill in both Merchant ID and API Private Key before creating.') ) return } - - if (enabled && Number(values.WaffoPancakeUnitPrice) <= 0) { - toast.error(t('Unit price must be greater than 0')) - return + const { merchantID, privateKey } = readCreds() + const trimmedReturn = removeTrailingSlash(returnURL.trim()) + if (!trimmedReturn) { + if ( + !window.confirm( + t( + 'Payment return URL is empty. Create the product without a SuccessURL redirect?' + ) + ) + ) { + return + } } - - if (enabled && Number(values.WaffoPancakeMinTopUp) < 1) { - toast.error(t('Minimum top-up amount must be at least 1')) - return - } - - setLoading(true) + setCreatingPair(true) try { - const options: { key: string; value: string }[] = [ - { key: 'WaffoPancakeEnabled', value: enabled ? 'true' : 'false' }, - { key: 'WaffoPancakeSandbox', value: sandbox ? 'true' : 'false' }, - { - key: 'WaffoPancakeMerchantID', - value: values.WaffoPancakeMerchantID || '', - }, - { - key: 'WaffoPancakeStoreID', - value: values.WaffoPancakeStoreID || '', - }, - { - key: 'WaffoPancakeProductID', - value: values.WaffoPancakeProductID || '', - }, - { - key: 'WaffoPancakeReturnURL', - value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''), - }, - { - key: 'WaffoPancakeCurrency', - value: values.WaffoPancakeCurrency || 'USD', - }, - { - key: 'WaffoPancakeUnitPrice', - value: String(values.WaffoPancakeUnitPrice ?? 1), - }, - { - key: 'WaffoPancakeMinTopUp', - value: String(values.WaffoPancakeMinTopUp ?? 1), - }, - ] - - if ((values.WaffoPancakePrivateKey || '').trim()) { - options.push({ - key: 'WaffoPancakePrivateKey', - value: values.WaffoPancakePrivateKey, + const body = await createWaffoPancakePair({ + merchantID, + privateKey, + returnURL: trimmedReturn, + }) + if ( + body?.message === 'success' && + typeof body.data === 'object' && + body.data + ) { + const created = body.data as PairResult + // Refetch from GraphQL rather than trusting the response body so the + // dropdowns reflect authoritative state, then anchor on minted IDs. + setPhase('verifying') + await verifyAndFetchCatalog(merchantID, privateKey, { + storeID: created.store_id, + productID: created.product_id, + }) + toast.success( + `${t('Store + product created')}: ${created.store_id} / ${created.product_id}` + ) + return + } + const errData = + body && typeof body.data === 'object' && body.data !== null + ? (body.data as PairOrphanError) + : null + if (errData?.orphan_store && errData.store_id) { + setPhase('verifying') + await verifyAndFetchCatalog(merchantID, privateKey, { + storeID: errData.store_id, + productID: '', }) } - - if ((values.WaffoPancakeWebhookPublicKey || '').trim()) { - options.push({ - key: 'WaffoPancakeWebhookPublicKey', - value: values.WaffoPancakeWebhookPublicKey, - }) - } - - if ((values.WaffoPancakeWebhookTestKey || '').trim()) { - options.push({ - key: 'WaffoPancakeWebhookTestKey', - value: values.WaffoPancakeWebhookTestKey, - }) - } - - for (const option of options) { - await updateOption.mutateAsync(option) - } - toast.success(t('Updated successfully')) - } catch { - toast.error(t('Update failed')) + const reason = + errData?.error ?? + (typeof body?.data === 'string' ? body.data : undefined) + toast.error( + reason ? `${t('Creation failed')}: ${reason}` : t('Creation failed') + ) + } catch (err) { + toast.error( + `${t('Creation failed')}: ${err instanceof Error ? err.message : String(err)}` + ) } finally { - setLoading(false) + setCreatingPair(false) } } + const handleSave = async () => { + // Sends raw form values (not readCreds): SaveWaffoPancakeConfig already + // treats a blank PrivateKey as "keep existing", and MerchantID stays + // populated from props for returning admins. + const merchantID = ( + form.getValues('WaffoPancakeMerchantID') || '' + ).trim() + const privateKey = ( + form.getValues('WaffoPancakePrivateKey') || '' + ).trim() + if (!merchantID) { + toast.error(t('Merchant ID is required')) + return + } + if (!chosenStoreID || !chosenProductID) { + toast.error(t('Pick or create both a store and a product before saving.')) + return + } + setPhase('saving') + try { + const body = await saveWaffoPancakeConfig({ + merchantID, + privateKey, + returnURL: removeTrailingSlash(returnURL.trim()), + storeID: chosenStoreID, + productID: chosenProductID, + }) + if ( + body?.message === 'success' && + typeof body.data === 'object' && + body.data + ) { + const saved = body.data as { product_id: string; store_id: string } + setStoreID(saved.store_id) + setProductID(saved.product_id) + toast.success(t('Waffo Pancake settings saved')) + } else { + const reason = typeof body?.data === 'string' ? body.data : undefined + toast.error( + reason + ? `${t('Waffo Pancake save failed')}: ${reason}` + : t('Waffo Pancake save failed') + ) + } + } catch (err) { + toast.error( + `${t('Waffo Pancake save failed')}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + } finally { + setPhase('idle') + } + } + + const verifying = phase === 'verifying' + const saving = phase === 'saving' + + // "Not edited" = MerchantID unchanged AND PrivateKey field blank, in + // which case the backend falls back to persisted creds. Otherwise we + // require both fields filled (mixed states would fail signature check). + const savedMerchantID = ( + props.defaultValues.WaffoPancakeMerchantID || '' + ).trim() + const formMerchantID = watchedMerchantID.trim() + const formPrivateKey = watchedPrivateKey.trim() + const credsEdited = + formMerchantID !== savedMerchantID || formPrivateKey.length > 0 + const hasSavedCreds = savedMerchantID.length > 0 + const credsReady = credsEdited + ? formMerchantID.length > 0 && formPrivateKey.length > 0 + : hasSavedCreds + const hasCatalog = catalog.length > 0 + + let bindStatusMessage: string + if (!credsReady) { + bindStatusMessage = t('Fill in the credentials above to begin.') + } else if (verifying) { + bindStatusMessage = t( + 'Verifying credentials and pulling stores from your Pancake account...' + ) + } else if (hasCatalog) { + bindStatusMessage = t( + 'Mint a fresh pair below — or pick an existing one further down. Click Save when ready.' + ) + } else { + bindStatusMessage = t( + 'No stores on this merchant yet. Set a return URL and click Create to mint your first pair.' + ) + } + return ( - - - +
+
+

{t('Waffo Pancake MoR')}

+

{t( - 'Obtain the merchant, store, product and signing keys from your Waffo dashboard. Webhook URL: /api/waffo-pancake/webhook' + 'Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.' )} - - - -

-
- - form.setValue('WaffoPancakeEnabled', value) - } - /> - -
-
- - form.setValue('WaffoPancakeSandbox', value) - } - /> - -
-
- - -
+

+
+ e.preventDefault()} + className='space-y-4' + data-no-autosubmit='true' + > + {/* Blue box — webhook configuration only. */} +
+

{t('Webhook Configuration:')}

+
    +
  • + {t('Webhook URL (Test):')}{' '} + + {'/api/waffo-pancake/webhook/test'} + +
  • +
  • + {t('Webhook URL (Production):')}{' '} + + {'/api/waffo-pancake/webhook/prod'} + +
  • +
  • + {t( + 'Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.' + )} +
  • +
  • + {t('Configure at:')}{' '} + + {t('Waffo Pancake Dashboard')} + +
  • +
+
-
-
- - ( + + {t('Merchant ID')} + + field.onChange(event.target.value)} + /> + + + + )} /> -
-
- - -
-
- - -
-
-
-
- -