feat: add Airwallex payments and multi-currency support
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user