feat: add Airwallex payments and multi-currency support

This commit is contained in:
shaw
2026-05-11 10:45:07 +08:00
parent dbc8ae658c
commit b23055af5b
65 changed files with 3164 additions and 162 deletions
+55 -10
View File
@@ -459,6 +459,7 @@ type PublicOrderResult struct {
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Currency string `json:"currency"`
PaymentType string `json:"payment_type"`
OrderType string `json:"order_type"`
Status string `json:"status"`
@@ -481,6 +482,7 @@ func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult {
Amount: order.Amount,
PayAmount: order.PayAmount,
FeeRate: order.FeeRate,
Currency: service.PaymentOrderCurrency(order),
PaymentType: order.PaymentType,
OrderType: order.OrderType,
Status: order.Status,
@@ -554,24 +556,67 @@ func isMobile(c *gin.Context) bool {
return false
}
func sanitizePaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*dbent.PaymentOrder {
if len(orders) == 0 {
return orders
}
out := make([]*dbent.PaymentOrder, 0, len(orders))
type PaymentOrderResult struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Currency string `json:"currency"`
PaymentType string `json:"payment_type"`
OutTradeNo string `json:"out_trade_no"`
Status string `json:"status"`
OrderType string `json:"order_type"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
PaidAt *time.Time `json:"paid_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
RefundAmount float64 `json:"refund_amount"`
RefundReason *string `json:"refund_reason,omitempty"`
RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"`
RefundRequestedBy *string `json:"refund_requested_by,omitempty"`
RefundRequestReason *string `json:"refund_request_reason,omitempty"`
PlanID *int64 `json:"plan_id,omitempty"`
ProviderInstanceID *string `json:"provider_instance_id,omitempty"`
}
func sanitizePaymentOrdersForResponse(orders []*dbent.PaymentOrder) []PaymentOrderResult {
out := make([]PaymentOrderResult, 0, len(orders))
for _, order := range orders {
out = append(out, sanitizePaymentOrderForResponse(order))
if item := sanitizePaymentOrderForResponse(order); item != nil {
out = append(out, *item)
}
}
return out
}
func sanitizePaymentOrderForResponse(order *dbent.PaymentOrder) *dbent.PaymentOrder {
func sanitizePaymentOrderForResponse(order *dbent.PaymentOrder) *PaymentOrderResult {
if order == nil {
return nil
}
cloned := *order
cloned.ProviderSnapshot = nil
return &cloned
return &PaymentOrderResult{
ID: order.ID,
UserID: order.UserID,
Amount: order.Amount,
PayAmount: order.PayAmount,
FeeRate: order.FeeRate,
Currency: service.PaymentOrderCurrency(order),
PaymentType: order.PaymentType,
OutTradeNo: order.OutTradeNo,
Status: order.Status,
OrderType: order.OrderType,
CreatedAt: order.CreatedAt,
ExpiresAt: order.ExpiresAt,
PaidAt: order.PaidAt,
CompletedAt: order.CompletedAt,
RefundAmount: order.RefundAmount,
RefundReason: order.RefundReason,
RefundRequestedAt: order.RefundRequestedAt,
RefundRequestedBy: order.RefundRequestedBy,
RefundRequestReason: order.RefundRequestReason,
PlanID: order.PlanID,
ProviderInstanceID: order.ProviderInstanceID,
}
}
func isWeChatBrowser(c *gin.Context) bool {
@@ -114,6 +114,7 @@ func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) {
SetExpiresAt(time.Now().Add(time.Hour)).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
SetProviderSnapshot(map[string]any{"currency": "HKD"}).
Save(context.Background())
require.NoError(t, err)
@@ -141,6 +142,7 @@ func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) {
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Currency string `json:"currency"`
PaymentType string `json:"payment_type"`
OrderType string `json:"order_type"`
Status string `json:"status"`
@@ -155,6 +157,7 @@ func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) {
require.Equal(t, "legacy-order-no", resp.Data.OutTradeNo)
require.Equal(t, 90.64, resp.Data.PayAmount)
require.Equal(t, 0.03, resp.Data.FeeRate)
require.Equal(t, "HKD", resp.Data.Currency)
require.Equal(t, payment.TypeAlipay, resp.Data.PaymentType)
require.Equal(t, payment.OrderTypeBalance, resp.Data.OrderType)
require.Equal(t, service.OrderStatusPending, resp.Data.Status)
@@ -202,6 +205,7 @@ func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing
SetPaidAt(time.Now()).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
SetProviderSnapshot(map[string]any{"currency": "USD"}).
Save(context.Background())
require.NoError(t, err)
@@ -242,6 +246,7 @@ func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing
require.Equal(t, 100.0, resp.Data["amount"])
require.Equal(t, 103.0, resp.Data["pay_amount"])
require.Equal(t, 0.03, resp.Data["fee_rate"])
require.Equal(t, "USD", resp.Data["currency"])
require.Equal(t, payment.TypeAlipay, resp.Data["payment_type"])
require.Equal(t, payment.OrderTypeBalance, resp.Data["order_type"])
require.Equal(t, service.OrderStatusPaid, resp.Data["status"])
@@ -2,6 +2,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -60,6 +61,12 @@ func (h *PaymentWebhookHandler) StripeWebhook(c *gin.Context) {
h.handleNotify(c, payment.TypeStripe)
}
// AirwallexWebhook 处理空中云汇 Webhook 事件。
// POST /api/v1/payment/webhook/airwallex
func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) {
h.handleNotify(c, payment.TypeAirwallex)
}
// handleNotify is the shared logic for all provider webhook handlers.
func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) {
var rawBody string
@@ -146,6 +153,17 @@ func extractOutTradeNo(rawBody, providerKey string) string {
if err == nil {
return values.Get("out_trade_no")
}
case payment.TypeAirwallex:
var payload struct {
Data struct {
Object struct {
MerchantOrderID string `json:"merchant_order_id"`
} `json:"object"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(rawBody), &payload); err == nil {
return strings.TrimSpace(payload.Data.Object.MerchantOrderID)
}
}
// For other providers (Stripe, Alipay direct, WxPay direct), the registry
// typically has only one instance, so no instance lookup is needed.
@@ -183,14 +201,14 @@ const (
wxpaySuccessMessage = "成功"
)
// writeSuccessResponse sends the provider-specific success response.
// WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"};
// Stripe expects an empty 200; others accept plain text "success".
// writeSuccessResponse 返回各支付服务商要求的成功响应。
// 微信支付需要 JSON {"code":"SUCCESS","message":"成功"}
// Stripe 和空中云汇接受空 200,其它服务商接受纯文本 "success"
func writeSuccessResponse(c *gin.Context, providerKey string) {
switch providerKey {
case payment.TypeWxpay:
c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage})
case payment.TypeStripe:
case payment.TypeStripe, payment.TypeAirwallex:
c.String(http.StatusOK, "")
default:
c.String(http.StatusOK, "success")
@@ -47,6 +47,13 @@ func TestWriteSuccessResponse(t *testing.T) {
wantContentType: "text/plain",
wantBody: "",
},
{
name: "airwallex returns empty 200",
providerKey: payment.TypeAirwallex,
wantCode: http.StatusOK,
wantContentType: "text/plain",
wantBody: "",
},
{
name: "easypay returns plain text success",
providerKey: "easypay",
@@ -165,6 +172,12 @@ func TestExtractOutTradeNo(t *testing.T) {
rawBody: "{}",
want: "",
},
{
name: "airwallex payment intent payload",
providerKey: payment.TypeAirwallex,
rawBody: `{"name":"payment_intent.succeeded","data":{"object":{"merchant_order_id":"sub2_awx_123"}}}`,
want: "sub2_awx_123",
},
}
for _, tt := range tests {
@@ -220,7 +233,7 @@ type webhookHandlerProviderStub struct {
verifyErr error
}
func (p webhookHandlerProviderStub) Name() string { return p.key }
func (p webhookHandlerProviderStub) Name() string { return p.key }
func (p webhookHandlerProviderStub) ProviderKey() string { return p.key }
func (p webhookHandlerProviderStub) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.PaymentType(p.key)}