Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 58 additions & 6 deletions backend/internal/payment/httpapi/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) {
// error wins even for an irrelevant nil notification. A missing provider is
// acknowledged except official WeChat; other lookup failures return 500.
// Failed verification returns 400 after bounded diagnostics. Verified missing
// orders are acknowledged, while other notification-processing failures return
// 500 for provider retry.
// orders are acknowledged before optional upstream confirmation, while real
// lookup, confirmation, or other processing failures remain retryable.
func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) {
var rawBody string
if c.Request.Method == http.MethodGet {
Expand Down Expand Up @@ -177,7 +177,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
headers[strings.ToLower(k)] = c.GetHeader(k)
}

resolvedProviderKey, notification, err := WebhookVerifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers)
verifiedProvider, notification, err := webhookVerifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers)
if err != nil {
truncatedBody := rawBody
// Limit failure diagnostics to a byte prefix plus suffix. This bounds logged
Expand All @@ -191,6 +191,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
c.String(http.StatusBadRequest, "verify failed")
return
}
resolvedProviderKey := verifiedProvider.ProviderKey()

// The accepted candidate can classify the event as irrelevant. Acknowledge
// that nil notification immediately so it does not enter order handling or
Expand All @@ -200,6 +201,43 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
return
}

if confirmer, ok := verifiedProvider.(payment.NotificationConfirmer); ok &&
notification.Status == payment.NotificationStatusSuccess {
// Preserve the existing missing-order acknowledgement before a gateway
// query. Obsolete verified callbacks have no local state to fulfill, while
// database failures remain retryable and existing orders still require the
// same verified merchant instance to confirm upstream payment.
if _, err := h.paymentService.FindPaymentNotificationOrder(c.Request.Context(), notification.OrderID); err != nil {
if errors.Is(err, payment.ErrOrderNotFound) {
slog.Warn("[Payment Webhook] unknown order before confirmation, acking to stop retries",
"provider", resolvedProviderKey,
"outTradeNo", notification.OrderID,
"tradeNo", notification.TradeNo,
)
WebhookWriteSuccessResponse(c, resolvedProviderKey)
return
}
slog.Error("[Payment Webhook] pre-confirmation order lookup failed",
"provider", resolvedProviderKey,
"outTradeNo", notification.OrderID,
"tradeNo", notification.TradeNo,
"error", err,
)
c.String(http.StatusInternalServerError, "order lookup failed")
return
}
if err := confirmer.ConfirmNotification(c.Request.Context(), notification); err != nil {
Comment on lines +204 to +229

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Handle a missing local order before requiring upstream confirmation

This confirmation runs before HandlePaymentNotification performs the local order lookup. If an accepted callback names a missing local order and the upstream lookup fails, the handler now returns 502 confirm failed before reaching the existing ErrOrderNotFound acknowledgement below. This can keep obsolete callbacks retrying even though there is no local order to fulfill.

A handler regression test with one enabled merchant, an accepted success notification, a classified missing local order, and a failing confirmer returns 502 on this head; the base returns 200 success.

After signature verification, resolve the local order and preserve the missing-order acknowledgement before querying upstream. Genuine database failures should remain retryable, and existing orders that need payment confirmation should retain the upstream check.

slog.Error("[Payment Webhook] upstream confirmation failed",
"provider", resolvedProviderKey,
"outTradeNo", notification.OrderID,
"tradeNo", notification.TradeNo,
"error", err,
)
c.String(http.StatusBadGateway, "confirm failed")
return
}
}

if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, resolvedProviderKey); err != nil {
// A verified notification can name no local order. Acknowledge that
// classified absence so retries do not repeatedly request fulfillment for an
Expand Down Expand Up @@ -304,6 +342,20 @@ func WebhookExtractStripeOutTradeNo(rawBody string) string {
// resolved key for acknowledgement and notification validation, so ordering
// can determine which merchant accepts an event.
func WebhookVerifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (string, *payment.PaymentNotification, error) {
provider, notification, err := webhookVerifyNotificationWithProviders(ctx, providers, rawBody, headers)
if err != nil {
return "", nil, err
}
return provider.ProviderKey(), notification, nil
}

// webhookVerifyNotificationWithProviders is the handler-only variant that
// retains the exact provider instance accepting the callback. Call it after
// candidate lookup and before any local order mutation. A nil notification is
// a verified irrelevant event; otherwise the caller may use optional provider
// capabilities on this same merchant instance before fulfillment. Candidates
// are tried in order, and the last verification error wins when none accepts.
func webhookVerifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (payment.Provider, *payment.PaymentNotification, error) {
var lastErr error
for _, provider := range providers {
if provider == nil {
Expand All @@ -317,12 +369,12 @@ func WebhookVerifyNotificationWithProviders(ctx context.Context, providers []pay
lastErr = err
continue
}
return provider.ProviderKey(), notification, nil
return provider, notification, nil
}
if lastErr != nil {
return "", nil, lastErr
return nil, nil, lastErr
}
return "", nil, fmt.Errorf("no webhook provider could verify notification")
return nil, nil, fmt.Errorf("no webhook provider could verify notification")
}

// WebhookWriteSuccessResponse writes the accepted callback acknowledgement for
Expand Down
137 changes: 137 additions & 0 deletions backend/internal/payment/httpapi/webhook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package httpapi

import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"

"github.com/TokenFlux/TokenRouter/internal/payment"
)

var errWebhookOrderNotFound = errors.New("webhook order not found")

// webhookOrderStore supplies only the provider fallback and notification-order
// reads reached by these handler tests. Other promoted store operations would
// panic if callback handling unexpectedly continued into fulfillment.
type webhookOrderStore struct {
payment.BindingStore
payment.FulfillmentStore
order *payment.Order
err error
}

// webhookConfirmer verifies one fixed success notification and records whether
// handler ordering reached upstream confirmation.
type webhookConfirmer struct {
payment.Provider
notification *payment.PaymentNotification
confirmErr error
confirmCalls int
}

func TestEasyPayNotifyChecksLocalOrderBeforeConfirmation(t *testing.T) {
tests := []struct {
name string
order *payment.Order
orderErr error
confirmErr error
wantCode int
wantBody string
wantConfirms int
}{
{
name: "missing order is acknowledged",
orderErr: errWebhookOrderNotFound,
confirmErr: errors.New("confirmation must not run"),
wantCode: http.StatusOK,
wantBody: "success",
wantConfirms: 0,
},
{
name: "database failure remains retryable",
orderErr: errors.New("database unavailable"),
confirmErr: errors.New("confirmation must not run"),
wantCode: http.StatusInternalServerError,
wantBody: "order lookup failed",
wantConfirms: 0,
},
{
name: "existing order still requires confirmation",
order: &payment.Order{ID: 42, OutTradeNo: "order-42"},
confirmErr: errors.New("upstream unavailable"),
wantCode: http.StatusBadGateway,
wantBody: "confirm failed",
wantConfirms: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &webhookOrderStore{order: tt.order, err: tt.orderErr}
provider := &webhookConfirmer{
notification: &payment.PaymentNotification{
OrderID: "order-42",
Status: payment.NotificationStatusSuccess,
},
confirmErr: tt.confirmErr,
}
registry := payment.NewRegistry()
registry.Register(provider)
bindings := payment.NewProviderBindings(store, registry, nil, payment.BindingRuntime{}, true)
fulfillment := payment.NewFulfillment(store, bindings, registry, nil, nil, payment.FulfillmentRuntime{})
runtime := &payment.Runtime{
OrderLifecycle: payment.NewOrderLifecycle(fulfillment, nil, nil),
ProviderBindings: bindings,
}

recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/payment/webhook/easypay", nil)

NewPaymentWebhookHandler(runtime).EasyPayNotify(ctx)

require.Equal(t, tt.wantCode, recorder.Code)
require.Equal(t, tt.wantBody, recorder.Body.String())
require.Equal(t, tt.wantConfirms, provider.confirmCalls)
})
}
}

func (s *webhookOrderStore) OrderByTradeNumber(context.Context, string) (*payment.Order, error) {
return s.order, s.err
}

func (*webhookOrderStore) CountEnabledInstances(context.Context, string) (int, error) {
return 0, nil
}

func (*webhookOrderStore) IsNotFound(err error) bool {
return errors.Is(err, errWebhookOrderNotFound)
}

func (*webhookConfirmer) Name() string { return "webhook confirmer" }

func (*webhookConfirmer) ProviderKey() string { return payment.TypeEasyPay }

func (*webhookConfirmer) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeEasyPay}
}

func (p *webhookConfirmer) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) {
if p.notification == nil {
return nil, fmt.Errorf("test notification missing")
}
copy := *p.notification
return &copy, nil
}

func (p *webhookConfirmer) ConfirmNotification(context.Context, *payment.PaymentNotification) error {
p.confirmCalls++
return p.confirmErr
}
Loading