diff --git a/controller/subscription_payment_waffo_pancake.go b/controller/subscription_payment_waffo_pancake.go index d98f7f6b..0915ddc6 100644 --- a/controller/subscription_payment_waffo_pancake.go +++ b/controller/subscription_payment_waffo_pancake.go @@ -103,8 +103,9 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) { Amount: decimal.NewFromFloat(plan.PriceAmount).StringFixed(2), TaxCategory: "saas", }, - BuyerEmail: getWaffoPancakeBuyerEmail(user), - ExpiresInSeconds: &expiresInSeconds, + BuyerEmail: getWaffoPancakeBuyerEmail(user), + ExpiresInSeconds: &expiresInSeconds, + OrderMerchantExternalID: tradeNo, }) 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())) diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index 98b00aca..cd8b0d66 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -96,9 +96,6 @@ func getWaffoPancakeBuyerEmail(user *model.User) string { if user != nil && strings.TrimSpace(user.Email) != "" { return user.Email } - if user != nil { - return fmt.Sprintf("%d@new-api.local", user.Id) - } return "" } @@ -408,8 +405,9 @@ func RequestWaffoPancakePay(c *gin.Context) { Amount: formatWaffoPancakeAmount(payMoney), TaxCategory: "saas", }, - BuyerEmail: getWaffoPancakeBuyerEmail(user), - ExpiresInSeconds: &expiresInSeconds, + BuyerEmail: getWaffoPancakeBuyerEmail(user), + ExpiresInSeconds: &expiresInSeconds, + OrderMerchantExternalID: tradeNo, }) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error())) @@ -485,9 +483,9 @@ func WaffoPancakeWebhook(c *gin.Context) { 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) + // Dispatch by trade_no prefix. OrderMerchantExternalID = our trade_no; + // OrderID is Pancake's internal ORD_* (logs only). + rawTradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID) isSubscription := strings.HasPrefix(rawTradeNo, "WAFFO_PANCAKE_SUB-") if isSubscription { diff --git a/go.mod b/go.mod index 672c7418..eceb5e7d 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,7 @@ require ( gorm.io/gorm v1.25.2 ) -require github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 +require github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 require ( github.com/DmitriyVTitov/size v1.5.0 // indirect diff --git a/go.sum b/go.sum index e16f7e20..1eb08878 100644 --- a/go.sum +++ b/go.sum @@ -312,6 +312,8 @@ github.com/waffo-com/waffo-pancake-sdk-go v0.1.1 h1:YOI7+3zTBlTB7Ou6+ZXnJV2JvW/a 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/waffo-com/waffo-pancake-sdk-go v0.3.1 h1:ngQSN/oVB35xTwFPLfg++bxPC+SptcF145Mb6c62YCc= +github.com/waffo-com/waffo-pancake-sdk-go v0.3.1/go.mod h1:OB2MyFIQaefoPO0FV3J+yu9sDP8RVFQ+sbFsXqGuObc= 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/service/waffo_pancake.go b/service/waffo_pancake.go index d603ece1..766a686d 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -17,14 +17,15 @@ type WaffoPancakePriceSnapshot struct { } // WaffoPancakeCreateSessionParams is the input to CreateWaffoPancakeCheckoutSession. -// BuyerIdentity (merchant-controlled, stable per user) is what survives the -// buyer editing email at checkout — see WaffoPancakeBuyerIdentityFromUserID. +// BuyerIdentity must be stable per user (see WaffoPancakeBuyerIdentityFromUserID). +// OrderMerchantExternalID = our trade_no; Pancake echoes it back in webhooks. type WaffoPancakeCreateSessionParams struct { - ProductID string - BuyerIdentity string - PriceSnapshot *WaffoPancakePriceSnapshot - BuyerEmail string - ExpiresInSeconds *int + ProductID string + BuyerIdentity string + PriceSnapshot *WaffoPancakePriceSnapshot + BuyerEmail string + ExpiresInSeconds *int + OrderMerchantExternalID string } // WaffoPancakeCheckoutSession is the response of CreateWaffoPancakeCheckoutSession. @@ -52,7 +53,9 @@ type WaffoPancakeWebhookEvent struct { } type WaffoPancakeWebhookData struct { + // OrderID = Pancake ORD_* (logs); OrderMerchantExternalID = our trade_no (lookup). OrderID string + OrderMerchantExternalID string BuyerEmail string Currency string Amount string @@ -107,10 +110,11 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake sdkParams := pancake.AuthenticatedCheckoutParams{ CreateCheckoutSessionParams: pancake.CreateCheckoutSessionParams{ - ProductID: params.ProductID, - Currency: "USD", - BuyerEmail: optionalString(params.BuyerEmail), - ExpiresInSeconds: params.ExpiresInSeconds, + ProductID: params.ProductID, + Currency: "USD", + BuyerEmail: optionalString(params.BuyerEmail), + ExpiresInSeconds: params.ExpiresInSeconds, + OrderMerchantExternalID: optionalString(params.OrderMerchantExternalID), }, BuyerIdentity: params.BuyerIdentity, } @@ -163,6 +167,10 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) if evt.Data.MerchantProvidedBuyerIdentity != nil { identity = *evt.Data.MerchantProvidedBuyerIdentity } + externalID := "" + if evt.Data.OrderMerchantExternalID != nil { + externalID = *evt.Data.OrderMerchantExternalID + } return &WaffoPancakeWebhookEvent{ ID: evt.ID, Timestamp: evt.Timestamp, @@ -172,6 +180,7 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) Mode: string(evt.Mode), Data: WaffoPancakeWebhookData{ OrderID: evt.Data.OrderID, + OrderMerchantExternalID: externalID, BuyerEmail: evt.Data.BuyerEmail, Currency: evt.Data.Currency, Amount: evt.Data.Amount, @@ -183,19 +192,18 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) } // 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. +// trade_no via OrderMerchantExternalID, and rejects buyer-identity mismatches. func ResolveWaffoPancakeTradeNo(event *WaffoPancakeWebhookEvent) (string, error) { if event == nil { return "", fmt.Errorf("missing webhook event") } - tradeNo := strings.TrimSpace(event.Data.OrderID) + tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID) if tradeNo == "" { - return "", fmt.Errorf("missing webhook orderId") + return "", fmt.Errorf("missing webhook orderMerchantExternalId") } 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("waffo pancake order not found for tradeNo=%s", tradeNo) } expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(topUp.UserId) actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity) @@ -216,13 +224,13 @@ func ResolveWaffoPancakeSubscriptionTradeNo(event *WaffoPancakeWebhookEvent) (st if event == nil { return "", fmt.Errorf("missing webhook event") } - tradeNo := strings.TrimSpace(event.Data.OrderID) + tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID) if tradeNo == "" { - return "", fmt.Errorf("missing webhook orderId") + return "", fmt.Errorf("missing webhook orderMerchantExternalId") } 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) + return "", fmt.Errorf("waffo pancake subscription order not found for tradeNo=%s", tradeNo) } expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(order.UserId) actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity) diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go index 43df1bf5..6f89f045 100644 --- a/service/waffo_pancake_test.go +++ b/service/waffo_pancake_test.go @@ -57,7 +57,8 @@ func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *te tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(topUp.UserId), }, }) @@ -84,7 +85,8 @@ func TestResolveWaffoPancakeTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) { // crossed-wires bug or a tampered payload. Either way: reject. tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "ORD_identity_mismatch_case", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "ORD_identity_mismatch_case", MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user }, }) @@ -113,7 +115,8 @@ func TestResolveWaffoPancakeTradeNo_RejectsMissingBuyerIdentity(t *testing.T) { // reject so that we never credit anonymous orders to a specific user. tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "ORD_missing_identity", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "ORD_missing_identity", }, }) require.Error(t, err) @@ -146,9 +149,10 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "ORD_unknown", - BuyerEmail: user.Email, - Amount: "29.00", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "WAFFO_PANCAKE-unknown", + BuyerEmail: user.Email, + Amount: "29.00", }, }) require.Error(t, err) @@ -177,7 +181,8 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_UsesWebhookOrderIDWhenLocalOrder tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123", MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(order.UserId), }, }) @@ -202,7 +207,8 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsBuyerIdentityMismatch(t * tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "WAFFO_PANCAKE_SUB-42-mismatch", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-42-mismatch", MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user }, }) @@ -228,7 +234,7 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsMissingBuyerIdentity(t *t tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "WAFFO_PANCAKE_SUB-7-missing-identity", + OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-7-missing-identity", }, }) require.Error(t, err) @@ -253,7 +259,7 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_FailsWhenWebhookOrderIDIsUnknown tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "WAFFO_PANCAKE_SUB-unknown", + OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-unknown", }, }) require.Error(t, err) diff --git a/web/classic/src/components/settings/PaymentSetting.jsx b/web/classic/src/components/settings/PaymentSetting.jsx index 2fb3ab6b..d2d35558 100644 --- a/web/classic/src/components/settings/PaymentSetting.jsx +++ b/web/classic/src/components/settings/PaymentSetting.jsx @@ -304,6 +304,13 @@ const PaymentSetting = () => { hideSectionTitle /> + + +