From b23055af5b1b1a633bb0be2f87f6688e6f5a7fa2 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 11 May 2026 10:45:07 +0800 Subject: [PATCH] feat: add Airwallex payments and multi-currency support --- backend/internal/config/config.go | 2 +- backend/internal/handler/payment_handler.go | 65 +- .../handler/payment_handler_resume_test.go | 5 + .../handler/payment_webhook_handler.go | 26 +- .../handler/payment_webhook_handler_test.go | 15 +- backend/internal/payment/amount.go | 19 +- backend/internal/payment/amount_test.go | 101 +++ backend/internal/payment/currency.go | 118 ++++ backend/internal/payment/fee.go | 16 +- backend/internal/payment/fee_test.go | 52 ++ .../internal/payment/provider/airwallex.go | 639 ++++++++++++++++++ .../payment/provider/airwallex_test.go | 352 ++++++++++ backend/internal/payment/provider/factory.go | 2 + backend/internal/payment/provider/stripe.go | 68 +- backend/internal/payment/types.go | 13 +- .../server/middleware/security_headers.go | 61 +- .../middleware/security_headers_test.go | 46 ++ backend/internal/server/routes/payment.go | 1 + backend/internal/service/payment_amounts.go | 10 +- .../internal/service/payment_config_limits.go | 78 ++- .../service/payment_config_limits_test.go | 56 ++ .../service/payment_config_providers.go | 20 +- .../service/payment_config_providers_test.go | 113 ++++ .../service/payment_config_service.go | 1 + backend/internal/service/payment_currency.go | 28 + .../internal/service/payment_fulfillment.go | 12 +- .../service/payment_fulfillment_test.go | 52 ++ backend/internal/service/payment_order.go | 91 ++- .../payment_order_provider_snapshot.go | 32 + .../payment_order_provider_snapshot_test.go | 24 + .../service/payment_order_result_test.go | 47 ++ backend/internal/service/payment_refund.go | 33 +- .../internal/service/payment_refund_test.go | 24 + backend/internal/service/payment_service.go | 4 + deploy/config.example.yaml | 2 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 15 + frontend/src/assets/icons/airwallex.svg | 13 + .../admin/payment/AdminOrderTable.vue | 1 + .../payment/PaymentMethodSelector.vue | 6 +- .../payment/PaymentProviderDialog.vue | 55 +- .../components/payment/PaymentStatusPanel.vue | 22 +- .../src/components/payment/ProviderCard.vue | 1 + .../__tests__/PaymentProviderDialog.spec.ts | 87 ++- .../payment/__tests__/currency.spec.ts | 10 + .../payment/__tests__/paymentFlow.spec.ts | 60 ++ .../payment/__tests__/providerConfig.spec.ts | 48 +- frontend/src/components/payment/currency.ts | 33 + .../src/components/payment/paymentFlow.ts | 29 +- .../src/components/payment/providerConfig.ts | 38 +- frontend/src/i18n/locales/en.ts | 17 + frontend/src/i18n/locales/zh.ts | 17 + frontend/src/router/index.ts | 14 +- frontend/src/style.css | 7 + frontend/src/types/payment.ts | 8 +- frontend/src/views/admin/SettingsView.vue | 2 + .../views/admin/orders/AdminOrdersView.vue | 1 + .../src/views/user/AirwallexPaymentView.vue | 133 ++++ frontend/src/views/user/PaymentResultView.vue | 46 +- frontend/src/views/user/PaymentView.vue | 64 +- frontend/src/views/user/StripePaymentView.vue | 58 +- .../__tests__/AirwallexPaymentView.spec.ts | 134 ++++ .../user/__tests__/PaymentResultView.spec.ts | 40 ++ .../views/user/__tests__/PaymentView.spec.ts | 4 + .../user/__tests__/StripePaymentView.spec.ts | 134 ++++ 65 files changed, 3164 insertions(+), 162 deletions(-) create mode 100644 backend/internal/payment/currency.go create mode 100644 backend/internal/payment/provider/airwallex.go create mode 100644 backend/internal/payment/provider/airwallex_test.go create mode 100644 backend/internal/service/payment_currency.go create mode 100644 frontend/src/assets/icons/airwallex.svg create mode 100644 frontend/src/components/payment/__tests__/currency.spec.ts create mode 100644 frontend/src/components/payment/currency.ts create mode 100644 frontend/src/views/user/AirwallexPaymentView.vue create mode 100644 frontend/src/views/user/__tests__/AirwallexPaymentView.spec.ts create mode 100644 frontend/src/views/user/__tests__/StripePaymentView.spec.ts diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0385a5e6..d42828d8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,7 +28,7 @@ const ( // DefaultCSPPolicy is the default Content-Security-Policy with nonce support // __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware -const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" +const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" // UMQ(用户消息队列)模式常量 const ( diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 09580442..f293c2f2 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -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 { diff --git a/backend/internal/handler/payment_handler_resume_test.go b/backend/internal/handler/payment_handler_resume_test.go index 377f432e..75208832 100644 --- a/backend/internal/handler/payment_handler_resume_test.go +++ b/backend/internal/handler/payment_handler_resume_test.go @@ -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"]) diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index 9ae799fd..dc70f120 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -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") diff --git a/backend/internal/handler/payment_webhook_handler_test.go b/backend/internal/handler/payment_webhook_handler_test.go index 7551fc83..5b613383 100644 --- a/backend/internal/handler/payment_webhook_handler_test.go +++ b/backend/internal/handler/payment_webhook_handler_test.go @@ -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)} diff --git a/backend/internal/payment/amount.go b/backend/internal/payment/amount.go index 8489ffa3..4abfb8b1 100644 --- a/backend/internal/payment/amount.go +++ b/backend/internal/payment/amount.go @@ -1,24 +1,9 @@ package payment -import ( - "fmt" - - "github.com/shopspring/decimal" -) - -const centsPerYuan = 100 - -// YuanToFen converts a CNY yuan string (e.g. "10.50") to fen (int64). -// Uses shopspring/decimal for precision. func YuanToFen(yuanStr string) (int64, error) { - d, err := decimal.NewFromString(yuanStr) - if err != nil { - return 0, fmt.Errorf("invalid amount: %s", yuanStr) - } - return d.Mul(decimal.NewFromInt(centsPerYuan)).IntPart(), nil + return AmountToMinorUnit(yuanStr, DefaultPaymentCurrency) } -// FenToYuan converts fen (int64) to yuan as a float64 for interface compatibility. func FenToYuan(fen int64) float64 { - return decimal.NewFromInt(fen).Div(decimal.NewFromInt(centsPerYuan)).InexactFloat64() + return MinorUnitToAmount(fen, DefaultPaymentCurrency) } diff --git a/backend/internal/payment/amount_test.go b/backend/internal/payment/amount_test.go index 6120b189..e0e493f9 100644 --- a/backend/internal/payment/amount_test.go +++ b/backend/internal/payment/amount_test.go @@ -126,3 +126,104 @@ func TestYuanToFenRoundTrip(t *testing.T) { } } } + +func TestPaymentCurrencyHelpers(t *testing.T) { + tests := []struct { + name string + currency string + amount string + wantMinor int64 + wantBack float64 + }{ + {name: "hkd uses cents", currency: "hkd", amount: "12.34", wantMinor: 1234, wantBack: 12.34}, + {name: "jpy has no minor unit", currency: "JPY", amount: "12", wantMinor: 12, wantBack: 12}, + {name: "kwd uses three decimal minor units", currency: "KWD", amount: "12.345", wantMinor: 12345, wantBack: 12.345}, + {name: "isk uses Stripe legacy two-decimal API amount", currency: "ISK", amount: "12", wantMinor: 1200, wantBack: 12}, + {name: "ugx uses Stripe legacy two-decimal API amount", currency: "UGX", amount: "12.00", wantMinor: 1200, wantBack: 12}, + {name: "empty currency defaults to cny", currency: "", amount: "1.23", wantMinor: 123, wantBack: 1.23}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := AmountToMinorUnit(tt.amount, tt.currency) + if err != nil { + t.Fatalf("AmountToMinorUnit(%q, %q) unexpected error: %v", tt.amount, tt.currency, err) + } + if got != tt.wantMinor { + t.Fatalf("AmountToMinorUnit(%q, %q) = %d, want %d", tt.amount, tt.currency, got, tt.wantMinor) + } + back := MinorUnitToAmount(got, tt.currency) + if math.Abs(back-tt.wantBack) > 1e-9 { + t.Fatalf("MinorUnitToAmount(%d, %q) = %f, want %f", got, tt.currency, back, tt.wantBack) + } + }) + } +} + +func TestFormatAmountForCurrency(t *testing.T) { + tests := []struct { + currency string + amount float64 + want string + }{ + {currency: "CNY", amount: 12.3, want: "12.30"}, + {currency: "JPY", amount: 12, want: "12"}, + {currency: "KWD", amount: 12.345, want: "12.345"}, + {currency: "ISK", amount: 12, want: "12"}, + } + + for _, tt := range tests { + t.Run(tt.currency, func(t *testing.T) { + if got := FormatAmountForCurrency(tt.amount, tt.currency); got != tt.want { + t.Fatalf("FormatAmountForCurrency(%v, %q) = %q, want %q", tt.amount, tt.currency, got, tt.want) + } + }) + } +} + +func TestAmountToMinorUnitRejectsUnsupportedPrecision(t *testing.T) { + if _, err := AmountToMinorUnit("100.50", "JPY"); err == nil { + t.Fatal("expected fractional JPY amount to fail") + } + if _, err := AmountToMinorUnit("100.50", "ISK"); err == nil { + t.Fatal("expected fractional ISK amount to fail") + } + if _, err := AmountToMinorUnit("100.50", "UGX"); err == nil { + t.Fatal("expected fractional UGX amount to fail") + } + if _, err := AmountToMinorUnit("12.345", "HKD"); err == nil { + t.Fatal("expected amount with more than two decimal places to fail") + } + if _, err := AmountToMinorUnit("12.3456", "KWD"); err == nil { + t.Fatal("expected amount with more than three decimal places to fail") + } + if got, err := AmountToMinorUnit("100.00", "JPY"); err != nil || got != 100 { + t.Fatalf("AmountToMinorUnit integer-form JPY = (%d, %v), want (100, nil)", got, err) + } +} + +func TestThreeDecimalPaymentCurrencies(t *testing.T) { + for _, currency := range []string{"BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"} { + t.Run(currency, func(t *testing.T) { + got, err := AmountToMinorUnit("12.345", currency) + if err != nil { + t.Fatalf("AmountToMinorUnit(%q, %q) unexpected error: %v", "12.345", currency, err) + } + if got != 12345 { + t.Fatalf("AmountToMinorUnit(%q, %q) = %d, want 12345", "12.345", currency, got) + } + if back := MinorUnitToAmount(got, currency); math.Abs(back-12.345) > 1e-9 { + t.Fatalf("MinorUnitToAmount(%d, %q) = %f, want 12.345", got, currency, back) + } + }) + } +} + +func TestNormalizePaymentCurrencyRejectsInvalidCodes(t *testing.T) { + if _, err := NormalizePaymentCurrency("HK"); err == nil { + t.Fatal("expected invalid two-letter currency to fail") + } + if _, err := NormalizePaymentCurrency("US1"); err == nil { + t.Fatal("expected non-letter currency to fail") + } +} diff --git a/backend/internal/payment/currency.go b/backend/internal/payment/currency.go new file mode 100644 index 00000000..53ba608b --- /dev/null +++ b/backend/internal/payment/currency.go @@ -0,0 +1,118 @@ +package payment + +import ( + "fmt" + "strings" + + "github.com/shopspring/decimal" +) + +const DefaultPaymentCurrency = "CNY" + +type paymentCurrencyAmountUnit struct { + apiMinorUnit int + maxFractionDigits int +} + +var ( + zeroDecimalAmountUnit = paymentCurrencyAmountUnit{apiMinorUnit: 0, maxFractionDigits: 0} + twoDecimalAmountUnit = paymentCurrencyAmountUnit{apiMinorUnit: 2, maxFractionDigits: 2} + threeDecimalAmountUnit = paymentCurrencyAmountUnit{apiMinorUnit: 3, maxFractionDigits: 3} + stripeLegacyZeroAmount = paymentCurrencyAmountUnit{apiMinorUnit: 2, maxFractionDigits: 0} +) + +var paymentCurrencyAmountUnits = map[string]paymentCurrencyAmountUnit{ + "BIF": zeroDecimalAmountUnit, + "CLP": zeroDecimalAmountUnit, + "DJF": zeroDecimalAmountUnit, + "GNF": zeroDecimalAmountUnit, + "JPY": zeroDecimalAmountUnit, + "KMF": zeroDecimalAmountUnit, + "KRW": zeroDecimalAmountUnit, + "MGA": zeroDecimalAmountUnit, + "PYG": zeroDecimalAmountUnit, + "RWF": zeroDecimalAmountUnit, + "VND": zeroDecimalAmountUnit, + "VUV": zeroDecimalAmountUnit, + "XAF": zeroDecimalAmountUnit, + "XOF": zeroDecimalAmountUnit, + "XPF": zeroDecimalAmountUnit, + "ISK": stripeLegacyZeroAmount, + "UGX": stripeLegacyZeroAmount, + "BHD": threeDecimalAmountUnit, + "IQD": threeDecimalAmountUnit, + "JOD": threeDecimalAmountUnit, + "KWD": threeDecimalAmountUnit, + "LYD": threeDecimalAmountUnit, + "OMR": threeDecimalAmountUnit, + "TND": threeDecimalAmountUnit, +} + +func NormalizePaymentCurrency(raw string) (string, error) { + currency := strings.ToUpper(strings.TrimSpace(raw)) + if currency == "" { + return DefaultPaymentCurrency, nil + } + if len(currency) != 3 { + return "", fmt.Errorf("payment currency must be a 3-letter ISO currency code") + } + for _, ch := range currency { + if ch < 'A' || ch > 'Z' { + return "", fmt.Errorf("payment currency must be a 3-letter ISO currency code") + } + } + return currency, nil +} + +func CurrencyMinorUnit(currency string) int { + return paymentCurrencyAmountUnitFor(currency).apiMinorUnit +} + +// CurrencyMaxFractionDigits 返回支付金额允许展示和输入的小数位数。 +func CurrencyMaxFractionDigits(currency string) int { + return paymentCurrencyAmountUnitFor(currency).maxFractionDigits +} + +// FormatAmountForCurrency 按币种允许的小数位格式化支付金额。 +func FormatAmountForCurrency(amount float64, currency string) string { + return decimal.NewFromFloat(amount).StringFixed(int32(CurrencyMaxFractionDigits(currency))) +} + +func paymentCurrencyAmountUnitFor(currency string) paymentCurrencyAmountUnit { + normalized, err := NormalizePaymentCurrency(currency) + if err != nil { + return twoDecimalAmountUnit + } + if amountUnit, ok := paymentCurrencyAmountUnits[normalized]; ok { + return amountUnit + } + return twoDecimalAmountUnit +} + +func AmountToMinorUnit(amountStr, currency string) (int64, error) { + d, err := decimal.NewFromString(strings.TrimSpace(amountStr)) + if err != nil { + return 0, fmt.Errorf("invalid amount: %s", amountStr) + } + normalizedCurrency, err := NormalizePaymentCurrency(currency) + if err != nil { + return 0, err + } + amountUnit := paymentCurrencyAmountUnitFor(normalizedCurrency) + precisionFactor := decimal.New(1, int32(amountUnit.maxFractionDigits)) + scaledForPrecision := d.Mul(precisionFactor) + if !scaledForPrecision.Equal(scaledForPrecision.Truncate(0)) { + if amountUnit.maxFractionDigits == 0 { + return 0, fmt.Errorf("payment amount for %s must be a whole number", normalizedCurrency) + } + return 0, fmt.Errorf("payment amount for %s must not have more than %d decimal places", normalizedCurrency, amountUnit.maxFractionDigits) + } + factor := decimal.New(1, int32(amountUnit.apiMinorUnit)) + minorAmount := d.Mul(factor) + return minorAmount.IntPart(), nil +} + +func MinorUnitToAmount(value int64, currency string) float64 { + factor := decimal.New(1, int32(CurrencyMinorUnit(currency))) + return decimal.NewFromInt(value).Div(factor).InexactFloat64() +} diff --git a/backend/internal/payment/fee.go b/backend/internal/payment/fee.go index e2128e5e..01afb287 100644 --- a/backend/internal/payment/fee.go +++ b/backend/internal/payment/fee.go @@ -4,16 +4,18 @@ import ( "github.com/shopspring/decimal" ) -// CalculatePayAmount computes the total pay amount given a recharge amount and -// fee rate (percentage). Fee = amount * feeRate / 100, rounded UP (away from zero) -// to 2 decimal places. The returned string is formatted to exactly 2 decimal places. -// If feeRate <= 0, the amount is returned as-is (formatted to 2 decimal places). func CalculatePayAmount(rechargeAmount float64, feeRate float64) string { + return CalculatePayAmountForCurrency(rechargeAmount, feeRate, DefaultPaymentCurrency) +} + +// CalculatePayAmountForCurrency 按币种精度计算应付金额,手续费向上取整到该币种最小支付单位。 +func CalculatePayAmountForCurrency(rechargeAmount float64, feeRate float64, currency string) string { + fractionDigits := int32(CurrencyMaxFractionDigits(currency)) amount := decimal.NewFromFloat(rechargeAmount) if feeRate <= 0 { - return amount.StringFixed(2) + return amount.StringFixed(fractionDigits) } rate := decimal.NewFromFloat(feeRate) - fee := amount.Mul(rate).Div(decimal.NewFromInt(100)).RoundUp(2) - return amount.Add(fee).StringFixed(2) + fee := amount.Mul(rate).Div(decimal.NewFromInt(100)).RoundUp(fractionDigits) + return amount.Add(fee).StringFixed(fractionDigits) } diff --git a/backend/internal/payment/fee_test.go b/backend/internal/payment/fee_test.go index c58d1082..5745839d 100644 --- a/backend/internal/payment/fee_test.go +++ b/backend/internal/payment/fee_test.go @@ -109,3 +109,55 @@ func TestCalculatePayAmount(t *testing.T) { }) } } + +func TestCalculatePayAmountForCurrency(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amount float64 + feeRate float64 + currency string + expected string + }{ + { + name: "zero decimal currency rounds fee up to whole unit", + amount: 100, + feeRate: 2.5, + currency: "JPY", + expected: "103", + }, + { + name: "three decimal currency keeps three decimal places", + amount: 12.345, + feeRate: 1, + currency: "KWD", + expected: "12.469", + }, + { + name: "stripe legacy zero decimal currency displays whole unit", + amount: 100, + feeRate: 2.5, + currency: "ISK", + expected: "103", + }, + { + name: "default currency keeps existing two decimal behavior", + amount: 10, + feeRate: 3.33, + currency: "CNY", + expected: "10.34", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := CalculatePayAmountForCurrency(tt.amount, tt.feeRate, tt.currency) + if got != tt.expected { + t.Fatalf("CalculatePayAmountForCurrency(%v, %v, %q) = %q, want %q", tt.amount, tt.feeRate, tt.currency, got, tt.expected) + } + }) + } +} diff --git a/backend/internal/payment/provider/airwallex.go b/backend/internal/payment/provider/airwallex.go new file mode 100644 index 00000000..31652fb0 --- /dev/null +++ b/backend/internal/payment/provider/airwallex.go @@ -0,0 +1,639 @@ +package provider + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +const ( + airwallexDemoAPIBase = "https://api-demo.airwallex.com/api/v1" + airwallexProdAPIBase = "https://api.airwallex.com/api/v1" + airwallexDefaultCountry = "CN" + airwallexHTTPTimeout = 15 * time.Second + airwallexMaxResponseSize = 1 << 20 + airwallexMaxErrorSummary = 512 + airwallexTokenSkew = 2 * time.Minute + airwallexWebhookTolerance = 5 * time.Minute + + airwallexEventPaymentSucceeded = "payment_intent.succeeded" + airwallexEventPaymentCancelled = "payment_intent.cancelled" + + airwallexPaymentStatusSucceeded = "SUCCEEDED" + airwallexPaymentStatusCancelled = "CANCELLED" + airwallexRefundStatusReceived = "RECEIVED" + airwallexRefundStatusAccepted = "ACCEPTED" + airwallexRefundStatusSettled = "SETTLED" + airwallexRefundStatusFailed = "FAILED" +) + +type Airwallex struct { + instanceID string + config map[string]string + httpClient *http.Client +} + +type airwallexTokenState struct { + mu sync.Mutex + token string + expiresAt time.Time +} + +var airwallexAccessTokens sync.Map + +func NewAirwallex(instanceID string, config map[string]string) (*Airwallex, error) { + for _, k := range []string{"clientId", "apiKey", "webhookSecret", "apiBase"} { + if strings.TrimSpace(config[k]) == "" { + return nil, fmt.Errorf("airwallex config missing required key: %s", k) + } + } + cfg := cloneStringMap(config) + apiBase, err := normalizeAirwallexAPIBase(cfg["apiBase"]) + if err != nil { + return nil, err + } + cfg["apiBase"] = apiBase + currency, err := payment.NormalizePaymentCurrency(cfg["currency"]) + if err != nil { + return nil, fmt.Errorf("airwallex config currency: %w", err) + } + cfg["currency"] = currency + countryCode, err := normalizeAirwallexCountryCode(cfg["countryCode"]) + if err != nil { + return nil, err + } + cfg["countryCode"] = countryCode + return &Airwallex{ + instanceID: instanceID, + config: cfg, + httpClient: &http.Client{Timeout: airwallexHTTPTimeout}, + }, nil +} + +func normalizeAirwallexCountryCode(raw string) (string, error) { + countryCode := strings.ToUpper(strings.TrimSpace(raw)) + if countryCode == "" { + return airwallexDefaultCountry, nil + } + if len(countryCode) != 2 { + return "", fmt.Errorf("airwallex config countryCode must be a two-letter ISO country code") + } + for _, ch := range countryCode { + if ch < 'A' || ch > 'Z' { + return "", fmt.Errorf("airwallex config countryCode must be a two-letter ISO country code") + } + } + return countryCode, nil +} + +func normalizeAirwallexAPIBase(raw string) (string, error) { + base := strings.TrimSpace(raw) + if base == "" { + return "", fmt.Errorf("airwallex apiBase is required") + } + parsed, err := url.Parse(base) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", fmt.Errorf("airwallex apiBase must be an HTTPS URL") + } + host := strings.ToLower(parsed.Host) + if host != "api-demo.airwallex.com" && host != "api.airwallex.com" { + return "", fmt.Errorf("airwallex apiBase host must be api-demo.airwallex.com or api.airwallex.com") + } + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.RawPath = "" + parsed.Path = strings.TrimRight(parsed.Path, "/") + if parsed.Path == "" { + parsed.Path = "/api/v1" + } + if parsed.Path != "/api/v1" { + return "", fmt.Errorf("airwallex apiBase path must be /api/v1") + } + return parsed.String(), nil +} + +func (a *Airwallex) Name() string { return "空中云汇" } +func (a *Airwallex) ProviderKey() string { return payment.TypeAirwallex } +func (a *Airwallex) SupportedTypes() []payment.PaymentType { + return []payment.PaymentType{payment.TypeAirwallex} +} + +func (a *Airwallex) MerchantIdentityMetadata() map[string]string { + if a == nil { + return nil + } + metadata := map[string]string{"currency": a.currency()} + if accountID := strings.TrimSpace(a.config["accountId"]); accountID != "" { + metadata["account_id"] = accountID + } + return metadata +} + +func (a *Airwallex) currency() string { + if a == nil { + return payment.DefaultPaymentCurrency + } + currency, err := payment.NormalizePaymentCurrency(a.config["currency"]) + if err != nil { + return payment.DefaultPaymentCurrency + } + return currency +} + +func (a *Airwallex) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { + amount, err := decimal.NewFromString(req.Amount) + if err != nil || amount.LessThanOrEqual(decimal.Zero) { + return nil, fmt.Errorf("airwallex create payment: invalid amount %s", req.Amount) + } + token, err := a.accessToken(ctx) + if err != nil { + return nil, fmt.Errorf("airwallex auth: %w", err) + } + + currency := a.currency() + requestID := airwallexDeterministicRequestID("payment-intent", req.OrderID, req.Amount, currency) + payload := airwallexCreatePaymentIntentRequest{ + RequestID: requestID, + Amount: newAirwallexRequestAmount(amount), + Currency: currency, + MerchantOrderID: req.OrderID, + ReturnURL: req.ReturnURL, + Metadata: map[string]string{ + "order_id": req.OrderID, + }, + } + if descriptor := strings.TrimSpace(a.config["descriptor"]); descriptor != "" { + payload.Descriptor = descriptor + } + + var intent airwallexPaymentIntent + if err := a.doJSON(ctx, http.MethodPost, "/pa/payment_intents/create", token, payload, &intent); err != nil { + return nil, fmt.Errorf("airwallex create payment: %w", err) + } + if strings.TrimSpace(intent.ID) == "" || strings.TrimSpace(intent.ClientSecret) == "" { + return nil, fmt.Errorf("airwallex create payment: missing payment intent id or client secret") + } + return &payment.CreatePaymentResponse{ + TradeNo: intent.ID, + ClientSecret: intent.ClientSecret, + IntentID: intent.ID, + Currency: currency, + CountryCode: a.config["countryCode"], + PaymentEnv: a.checkoutEnv(), + }, nil +} + +func (a *Airwallex) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) { + intentID := strings.TrimSpace(tradeNo) + if intentID == "" { + return nil, fmt.Errorf("airwallex query order: missing payment intent id") + } + token, err := a.accessToken(ctx) + if err != nil { + return nil, fmt.Errorf("airwallex auth: %w", err) + } + + var intent airwallexPaymentIntent + if err := a.doJSON(ctx, http.MethodGet, "/pa/payment_intents/"+url.PathEscape(intentID), token, nil, &intent); err != nil { + return nil, fmt.Errorf("airwallex query order: %w", err) + } + return &payment.QueryOrderResponse{ + TradeNo: intent.ID, + Status: airwallexProviderStatus(intent.Status), + Amount: intent.Amount.InexactFloat64(), + Metadata: a.intentMetadata(intent, ""), + }, nil +} + +func (a *Airwallex) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) { + if err := verifyAirwallexWebhookSignature(rawBody, headers, a.config["webhookSecret"], time.Now()); err != nil { + return nil, err + } + + var event airwallexWebhookEvent + if err := json.Unmarshal([]byte(rawBody), &event); err != nil { + return nil, fmt.Errorf("airwallex parse webhook: %w", err) + } + switch event.Name { + case airwallexEventPaymentSucceeded, airwallexEventPaymentCancelled: + default: + return nil, nil + } + + var intent airwallexPaymentIntent + if err := json.Unmarshal(event.Data.Object, &intent); err != nil { + return nil, fmt.Errorf("airwallex parse payment intent: %w", err) + } + if strings.TrimSpace(intent.ID) == "" || strings.TrimSpace(intent.MerchantOrderID) == "" { + return nil, fmt.Errorf("airwallex webhook missing payment intent id or merchant_order_id") + } + status := payment.ProviderStatusFailed + if event.Name == airwallexEventPaymentSucceeded { + if strings.ToUpper(strings.TrimSpace(intent.Status)) != airwallexPaymentStatusSucceeded { + return nil, fmt.Errorf("airwallex succeeded webhook has non-succeeded status: %s", intent.Status) + } + status = payment.NotificationStatusSuccess + } + + return &payment.PaymentNotification{ + TradeNo: intent.ID, + OrderID: intent.MerchantOrderID, + Amount: intent.Amount.InexactFloat64(), + Status: status, + RawData: rawBody, + Metadata: a.intentMetadata(intent, event.accountID()), + }, nil +} + +func (a *Airwallex) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) { + intentID := strings.TrimSpace(req.TradeNo) + if intentID == "" { + return nil, fmt.Errorf("airwallex refund missing payment intent id") + } + amount, err := decimal.NewFromString(req.Amount) + if err != nil || amount.LessThanOrEqual(decimal.Zero) { + return nil, fmt.Errorf("airwallex refund: invalid amount %s", req.Amount) + } + token, err := a.accessToken(ctx) + if err != nil { + return nil, fmt.Errorf("airwallex auth: %w", err) + } + + payload := airwallexCreateRefundRequest{ + RequestID: airwallexDeterministicRequestID("refund", intentID, req.Amount), + PaymentIntentID: intentID, + Amount: newAirwallexRequestAmount(amount), + Reason: strings.TrimSpace(req.Reason), + } + if payload.Reason == "" { + payload.Reason = "refund" + } + + var resp airwallexRefund + if err := a.doJSON(ctx, http.MethodPost, "/pa/refunds/create", token, payload, &resp); err != nil { + return nil, fmt.Errorf("airwallex refund: %w", err) + } + if strings.TrimSpace(resp.ID) == "" { + return nil, fmt.Errorf("airwallex refund: missing refund id") + } + refundResp := &payment.RefundResponse{ + RefundID: resp.ID, + Status: airwallexRefundProviderStatus(resp.Status), + } + if refundResp.Status != payment.ProviderStatusSuccess { + return refundResp, fmt.Errorf("airwallex refund not settled: status %s", strings.ToUpper(strings.TrimSpace(resp.Status))) + } + return refundResp, nil +} + +func (a *Airwallex) CancelPayment(ctx context.Context, tradeNo string) error { + intentID := strings.TrimSpace(tradeNo) + if intentID == "" { + return nil + } + token, err := a.accessToken(ctx) + if err != nil { + return fmt.Errorf("airwallex auth: %w", err) + } + var intent airwallexPaymentIntent + if err := a.doJSON(ctx, http.MethodPost, "/pa/payment_intents/"+url.PathEscape(intentID)+"/cancel", token, nil, &intent); err != nil { + return fmt.Errorf("airwallex cancel payment: %w", err) + } + return nil +} + +func (a *Airwallex) intentMetadata(intent airwallexPaymentIntent, accountID string) map[string]string { + metadata := map[string]string{ + "currency": strings.ToUpper(strings.TrimSpace(intent.Currency)), + "status": strings.ToUpper(strings.TrimSpace(intent.Status)), + } + if accountID = strings.TrimSpace(accountID); accountID != "" { + metadata["account_id"] = accountID + } else if configured := strings.TrimSpace(a.config["accountId"]); configured != "" { + metadata["account_id"] = configured + } + return metadata +} + +func (a *Airwallex) checkoutEnv() string { + if strings.EqualFold(a.config["apiBase"], airwallexProdAPIBase) { + return "prod" + } + return "demo" +} + +func (a *Airwallex) accessToken(ctx context.Context) (string, error) { + cacheKey := a.tokenCacheKey() + rawState, _ := airwallexAccessTokens.LoadOrStore(cacheKey, &airwallexTokenState{}) + state, ok := rawState.(*airwallexTokenState) + if !ok { + return "", fmt.Errorf("airwallex auth token cache state type mismatch") + } + state.mu.Lock() + defer state.mu.Unlock() + + if state.token != "" && time.Now().Add(airwallexTokenSkew).Before(state.expiresAt) { + return state.token, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.config["apiBase"]+"/authentication/login", nil) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-client-id", a.config["clientId"]) + req.Header.Set("x-api-key", a.config["apiKey"]) + if accountID := strings.TrimSpace(a.config["accountId"]); accountID != "" { + req.Header.Set("x-login-as", accountID) + } + + body, status, err := a.do(req) + if err != nil { + return "", err + } + if status < http.StatusOK || status >= http.StatusMultipleChoices { + return "", formatAirwallexAuthHTTPError(status, body) + } + var resp airwallexAuthResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "", fmt.Errorf("parse authentication response: %w", err) + } + if strings.TrimSpace(resp.Token) == "" { + return "", fmt.Errorf("authentication response missing token") + } + expiresAt, err := parseAirwallexTime(resp.ExpiresAt) + if err != nil { + expiresAt = time.Now().Add(25 * time.Minute) + } + state.token = resp.Token + state.expiresAt = expiresAt + return state.token, nil +} + +func formatAirwallexAuthHTTPError(status int, body []byte) error { + summary := summarizeAirwallexResponse(body) + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return fmt.Errorf("authentication HTTP %d: %s; Airwallex credentials were rejected, check Client ID/API Key, API Base environment (sandbox: https://api-demo.airwallex.com/api/v1, production: https://api.airwallex.com/api/v1), and Account ID (leave it empty for single-account scoped keys)", status, summary) + } + return fmt.Errorf("authentication HTTP %d: %s", status, summary) +} + +func (a *Airwallex) tokenCacheKey() string { + sum := sha256.Sum256([]byte(a.config["apiKey"])) + return a.config["apiBase"] + "|" + a.config["clientId"] + "|" + strings.TrimSpace(a.config["accountId"]) + "|" + hex.EncodeToString(sum[:8]) +} + +func (a *Airwallex) doJSON(ctx context.Context, method, path, token string, payload any, out any) error { + var bodyReader io.Reader + if payload != nil { + body, err := json.Marshal(payload) + if err != nil { + return err + } + bodyReader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, a.config["apiBase"]+path, bodyReader) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if accountID := strings.TrimSpace(a.config["accountId"]); accountID != "" { + req.Header.Set("x-on-behalf-of", accountID) + } + + body, status, err := a.do(req) + if err != nil { + return err + } + if status < http.StatusOK || status >= http.StatusMultipleChoices { + return fmt.Errorf("HTTP %d: %s", status, summarizeAirwallexResponse(body)) + } + if out == nil || len(bytes.TrimSpace(body)) == 0 { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("parse response: %w", err) + } + return nil +} + +func (a *Airwallex) do(req *http.Request) ([]byte, int, error) { + client := a.httpClient + if client == nil { + client = &http.Client{Timeout: airwallexHTTPTimeout} + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, airwallexMaxResponseSize)) + if err != nil { + return nil, resp.StatusCode, err + } + return body, resp.StatusCode, nil +} + +func airwallexProviderStatus(status string) string { + switch strings.ToUpper(strings.TrimSpace(status)) { + case airwallexPaymentStatusSucceeded: + return payment.ProviderStatusPaid + case airwallexPaymentStatusCancelled: + return payment.ProviderStatusFailed + default: + return payment.ProviderStatusPending + } +} + +func airwallexRefundProviderStatus(status string) string { + switch strings.ToUpper(strings.TrimSpace(status)) { + case airwallexRefundStatusSettled: + return payment.ProviderStatusSuccess + case airwallexRefundStatusFailed: + return payment.ProviderStatusFailed + case airwallexRefundStatusReceived, airwallexRefundStatusAccepted: + return payment.ProviderStatusPending + default: + return payment.ProviderStatusPending + } +} + +func airwallexDeterministicRequestID(parts ...string) string { + hash := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + var id uuid.UUID + copy(id[:], hash[:16]) + id[6] = (id[6] & 0x0f) | 0x40 + id[8] = (id[8] & 0x3f) | 0x80 + return id.String() +} + +func verifyAirwallexWebhookSignature(rawBody string, headers map[string]string, secret string, now time.Time) error { + secret = strings.TrimSpace(secret) + if secret == "" { + return fmt.Errorf("airwallex webhookSecret not configured") + } + timestamp := strings.TrimSpace(headers["x-timestamp"]) + signature := strings.ToLower(strings.TrimSpace(headers["x-signature"])) + if timestamp == "" || signature == "" { + return fmt.Errorf("airwallex notification missing x-timestamp or x-signature header") + } + + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(timestamp)) + _, _ = mac.Write([]byte(rawBody)) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(signature)) { + return fmt.Errorf("airwallex invalid signature") + } + + ts, err := parseAirwallexWebhookTimestamp(timestamp) + if err != nil { + return err + } + if now.IsZero() { + now = time.Now() + } + if diff := now.Sub(ts).Abs(); diff > airwallexWebhookTolerance { + return fmt.Errorf("airwallex webhook timestamp outside tolerance") + } + return nil +} + +func parseAirwallexWebhookTimestamp(raw string) (time.Time, error) { + ts, err := decimal.NewFromString(strings.TrimSpace(raw)) + if err != nil { + return time.Time{}, fmt.Errorf("airwallex invalid webhook timestamp") + } + millis := ts.IntPart() + if millis <= 0 { + return time.Time{}, fmt.Errorf("airwallex invalid webhook timestamp") + } + return time.UnixMilli(millis), nil +} + +func parseAirwallexTime(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, fmt.Errorf("empty time") + } + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05-0700", "2006-01-02T15:04:05.000-0700"} { + if t, err := time.Parse(layout, raw); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("invalid time: %s", raw) +} + +func summarizeAirwallexResponse(body []byte) string { + summary := strings.Join(strings.Fields(string(body)), " ") + if summary == "" { + return "" + } + if len(summary) > airwallexMaxErrorSummary { + return summary[:airwallexMaxErrorSummary] + "..." + } + return summary +} + +type airwallexAuthResponse struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` +} + +type airwallexCreatePaymentIntentRequest struct { + RequestID string `json:"request_id"` + Amount airwallexRequestAmount `json:"amount"` + Currency string `json:"currency"` + MerchantOrderID string `json:"merchant_order_id"` + ReturnURL string `json:"return_url,omitempty"` + Descriptor string `json:"descriptor,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type airwallexCreateRefundRequest struct { + RequestID string `json:"request_id"` + PaymentIntentID string `json:"payment_intent_id"` + Amount airwallexRequestAmount `json:"amount,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type airwallexRequestAmount struct { + decimal.Decimal +} + +func newAirwallexRequestAmount(amount decimal.Decimal) airwallexRequestAmount { + return airwallexRequestAmount{Decimal: amount} +} + +func (a airwallexRequestAmount) MarshalJSON() ([]byte, error) { + return []byte(a.String()), nil +} + +func (a *airwallexRequestAmount) UnmarshalJSON(data []byte) error { + amount, err := decimal.NewFromString(strings.Trim(string(data), `"`)) + if err != nil { + return err + } + a.Decimal = amount + return nil +} + +type airwallexPaymentIntent struct { + ID string `json:"id"` + RequestID string `json:"request_id"` + ClientSecret string `json:"client_secret"` + MerchantOrderID string `json:"merchant_order_id"` + Amount decimal.Decimal `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + Metadata map[string]string `json:"metadata"` +} + +type airwallexRefund struct { + ID string `json:"id"` + RequestID string `json:"request_id"` + PaymentIntentID string `json:"payment_intent_id"` + Amount decimal.Decimal `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` +} + +type airwallexWebhookEvent struct { + ID string `json:"id"` + Name string `json:"name"` + AccountID string `json:"accountId"` + AccountIDSnake string `json:"account_id"` + Data struct { + Object json.RawMessage `json:"object"` + } `json:"data"` +} + +func (e airwallexWebhookEvent) accountID() string { + if accountID := strings.TrimSpace(e.AccountID); accountID != "" { + return accountID + } + return strings.TrimSpace(e.AccountIDSnake) +} + +var ( + _ payment.Provider = (*Airwallex)(nil) + _ payment.CancelableProvider = (*Airwallex)(nil) + _ payment.MerchantIdentityProvider = (*Airwallex)(nil) +) diff --git a/backend/internal/payment/provider/airwallex_test.go b/backend/internal/payment/provider/airwallex_test.go new file mode 100644 index 00000000..ada43316 --- /dev/null +++ b/backend/internal/payment/provider/airwallex_test.go @@ -0,0 +1,352 @@ +//go:build unit + +package provider + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" +) + +func TestNewAirwallexValidatesConfig(t *testing.T) { + t.Parallel() + + _, err := NewAirwallex("1", map[string]string{ + "clientId": "cid", + "apiKey": "key", + "webhookSecret": "secret", + "apiBase": "https://evil.example.com/api/v1", + }) + require.ErrorContains(t, err, "apiBase host") + + _, err = NewAirwallex("1", map[string]string{ + "clientId": "cid", + "apiKey": "key", + "webhookSecret": "secret", + "apiBase": airwallexDemoAPIBase, + "countryCode": "C1", + }) + require.ErrorContains(t, err, "countryCode") + + prov, err := NewAirwallex("1", map[string]string{ + "clientId": "cid", + "apiKey": "key", + "webhookSecret": "secret", + "apiBase": airwallexDemoAPIBase, + }) + require.NoError(t, err) + require.Equal(t, payment.TypeAirwallex, prov.ProviderKey()) + require.Equal(t, []payment.PaymentType{payment.TypeAirwallex}, prov.SupportedTypes()) + require.Equal(t, payment.DefaultPaymentCurrency, prov.config["currency"]) + require.Equal(t, airwallexDefaultCountry, prov.config["countryCode"]) +} + +func TestAirwallexCreatePaymentUsesServerAmountAndStableRequestID(t *testing.T) { + t.Parallel() + + var createRequests []airwallexCreatePaymentIntentRequest + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/authentication/login": + require.Equal(t, "cid", r.Header.Get("x-client-id")) + require.Equal(t, "key", r.Header.Get("x-api-key")) + _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`)) + case "/api/v1/pa/payment_intents/create": + require.Equal(t, "Bearer token-1", r.Header.Get("Authorization")) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Contains(t, string(body), `"amount":12.34`) + var payload airwallexCreatePaymentIntentRequest + require.NoError(t, json.Unmarshal(body, &payload)) + createRequests = append(createRequests, payload) + _, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + prov := mustTestAirwallexProvider(t, server) + resp, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2_order", + Amount: "12.34", + ReturnURL: "https://merchant.example.com/payment/result", + }) + require.NoError(t, err) + require.Equal(t, "int_123", resp.TradeNo) + require.Equal(t, "secret_123", resp.ClientSecret) + require.Equal(t, "int_123", resp.IntentID) + require.Equal(t, "CNY", resp.Currency) + require.Equal(t, "CN", resp.CountryCode) + require.Equal(t, "demo", resp.PaymentEnv) + require.Len(t, createRequests, 1) + require.Equal(t, "12.34", createRequests[0].Amount.StringFixed(2)) + require.Equal(t, "CNY", createRequests[0].Currency) + require.Equal(t, "sub2_order", createRequests[0].MerchantOrderID) + require.Equal(t, airwallexDeterministicRequestID("payment-intent", "sub2_order", "12.34", "CNY"), createRequests[0].RequestID) +} + +func TestAirwallexCreatePaymentUsesConfiguredCurrency(t *testing.T) { + t.Parallel() + + var createRequest airwallexCreatePaymentIntentRequest + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/authentication/login": + _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`)) + case "/api/v1/pa/payment_intents/create": + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &createRequest)) + _, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"HKD","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + prov, err := NewAirwallex("1", map[string]string{ + "clientId": "cid", + "apiKey": "key", + "webhookSecret": "whsec", + "apiBase": airwallexDemoAPIBase, + "currency": "hkd", + "countryCode": "HK", + }) + require.NoError(t, err) + prov.config["apiBase"] = server.URL + "/api/v1" + prov.httpClient = server.Client() + + resp, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2_order", + Amount: "12.34", + ReturnURL: "https://merchant.example.com/payment/result", + }) + require.NoError(t, err) + require.Equal(t, "HKD", createRequest.Currency) + require.Equal(t, "HKD", resp.Currency) + require.Equal(t, "HK", resp.CountryCode) + require.Equal(t, "HKD", prov.MerchantIdentityMetadata()["currency"]) +} + +func TestAirwallexRequestsUseConfiguredAccountID(t *testing.T) { + t.Parallel() + + paRequestCount := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/authentication/login": + require.Equal(t, "acct_123", r.Header.Get("x-login-as")) + _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`)) + case "/api/v1/pa/payment_intents/create": + paRequestCount++ + require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of")) + _, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`)) + case "/api/v1/pa/payment_intents/int_123": + paRequestCount++ + require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of")) + _, _ = w.Write([]byte(`{"id":"int_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"SUCCEEDED"}`)) + case "/api/v1/pa/refunds/create": + paRequestCount++ + require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of")) + _, _ = w.Write([]byte(`{"id":"ref_123","payment_intent_id":"int_123","amount":12.34,"currency":"CNY","status":"SETTLED"}`)) + case "/api/v1/pa/payment_intents/int_123/cancel": + paRequestCount++ + require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of")) + _, _ = w.Write([]byte(`{"id":"int_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"CANCELLED"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + prov, err := NewAirwallex("1", map[string]string{ + "clientId": "cid", + "apiKey": "key", + "webhookSecret": "whsec", + "apiBase": airwallexDemoAPIBase, + "accountId": "acct_123", + }) + require.NoError(t, err) + prov.config["apiBase"] = server.URL + "/api/v1" + prov.httpClient = server.Client() + + _, err = prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2_order", + Amount: "12.34", + }) + require.NoError(t, err) + _, err = prov.QueryOrder(context.Background(), "int_123") + require.NoError(t, err) + _, err = prov.Refund(context.Background(), payment.RefundRequest{ + TradeNo: "int_123", + Amount: "12.34", + Reason: "test refund", + }) + require.NoError(t, err) + require.NoError(t, prov.CancelPayment(context.Background(), "int_123")) + require.Contains(t, prov.tokenCacheKey(), "acct_123") + require.Equal(t, 4, paRequestCount) +} + +func TestAirwallexRefundRejectsUnsettledStatus(t *testing.T) { + t.Parallel() + + for _, status := range []string{"RECEIVED", "ACCEPTED", "FAILED"} { + t.Run(status, func(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/authentication/login": + _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`)) + case "/api/v1/pa/refunds/create": + _, _ = w.Write([]byte(`{"id":"ref_123","payment_intent_id":"int_123","amount":12.34,"currency":"CNY","status":"` + status + `"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + prov := mustTestAirwallexProvider(t, server) + resp, err := prov.Refund(context.Background(), payment.RefundRequest{ + TradeNo: "int_123", + Amount: "12.34", + Reason: "test refund", + }) + + require.ErrorContains(t, err, "airwallex refund not settled") + require.NotNil(t, resp) + require.Equal(t, "ref_123", resp.RefundID) + if status == airwallexRefundStatusFailed { + require.Equal(t, payment.ProviderStatusFailed, resp.Status) + } else { + require.Equal(t, payment.ProviderStatusPending, resp.Status) + } + }) + } +} + +func TestAirwallexAuthErrorIncludesCredentialGuidance(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/authentication/login", r.URL.Path) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"code":"credentials_invalid","details":["Access Denied"],"message":"UNAUTHORIZED","source":""}`)) + })) + defer server.Close() + + prov := mustTestAirwallexProvider(t, server) + _, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2_order", + Amount: "12.34", + }) + + require.ErrorContains(t, err, "credentials_invalid") + require.ErrorContains(t, err, "API Base environment") + require.ErrorContains(t, err, "https://api-demo.airwallex.com/api/v1") + require.ErrorContains(t, err, "https://api.airwallex.com/api/v1") + require.ErrorContains(t, err, "Account ID") +} + +func TestAirwallexVerifyNotificationRequiresValidSignatureAndCurrency(t *testing.T) { + t.Parallel() + + prov, err := NewAirwallex("1", map[string]string{ + "clientId": "cid", + "apiKey": "key", + "webhookSecret": "whsec", + "apiBase": airwallexDemoAPIBase, + "accountId": "acct_123", + }) + require.NoError(t, err) + + raw := `{"id":"evt_1","name":"payment_intent.succeeded","accountId":"acct_123","data":{"object":{"id":"int_123","merchant_order_id":"sub2_abc","amount":88.66,"currency":"CNY","status":"SUCCEEDED"}}}` + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + headers := signedAirwallexHeaders(raw, timestamp, "whsec") + + n, err := prov.VerifyNotification(context.Background(), raw, headers) + require.NoError(t, err) + require.NotNil(t, n) + require.Equal(t, "int_123", n.TradeNo) + require.Equal(t, "sub2_abc", n.OrderID) + require.Equal(t, payment.NotificationStatusSuccess, n.Status) + require.InDelta(t, 88.66, n.Amount, 0.0001) + require.Equal(t, "CNY", n.Metadata["currency"]) + require.Equal(t, "acct_123", n.Metadata["account_id"]) + + headers["x-signature"] = strings.Repeat("0", 64) + _, err = prov.VerifyNotification(context.Background(), raw, headers) + require.ErrorContains(t, err, "invalid signature") +} + +func TestVerifyAirwallexWebhookSignatureRejectsReplay(t *testing.T) { + t.Parallel() + + raw := `{"id":"evt_1"}` + timestamp := "1778241600000" + headers := signedAirwallexHeaders(raw, timestamp, "whsec") + err := verifyAirwallexWebhookSignature(raw, headers, "whsec", time.UnixMilli(1778241600000).Add(airwallexWebhookTolerance+time.Millisecond)) + require.ErrorContains(t, err, "outside tolerance") +} + +func TestAirwallexQueryOrderMapsSucceeded(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/authentication/login": + _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`)) + case "/api/v1/pa/payment_intents/int_123": + _, _ = w.Write([]byte(`{"id":"int_123","amount":99.01,"currency":"CNY","merchant_order_id":"sub2_order","status":"SUCCEEDED"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + prov := mustTestAirwallexProvider(t, server) + resp, err := prov.QueryOrder(context.Background(), "int_123") + require.NoError(t, err) + require.Equal(t, payment.ProviderStatusPaid, resp.Status) + require.InDelta(t, 99.01, resp.Amount, 0.0001) + require.Equal(t, "CNY", resp.Metadata["currency"]) + require.Equal(t, "SUCCEEDED", resp.Metadata["status"]) +} + +func mustTestAirwallexProvider(t *testing.T, server *httptest.Server) *Airwallex { + t.Helper() + prov, err := NewAirwallex("1", map[string]string{ + "clientId": "cid", + "apiKey": "key", + "webhookSecret": "whsec", + "apiBase": airwallexDemoAPIBase, + }) + require.NoError(t, err) + prov.config["apiBase"] = server.URL + "/api/v1" + prov.httpClient = server.Client() + return prov +} + +func signedAirwallexHeaders(rawBody, timestamp, secret string) map[string]string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(timestamp)) + _, _ = mac.Write([]byte(rawBody)) + return map[string]string{ + "x-timestamp": timestamp, + "x-signature": hex.EncodeToString(mac.Sum(nil)), + } +} diff --git a/backend/internal/payment/provider/factory.go b/backend/internal/payment/provider/factory.go index 0adbd267..cc34d535 100644 --- a/backend/internal/payment/provider/factory.go +++ b/backend/internal/payment/provider/factory.go @@ -17,6 +17,8 @@ func CreateProvider(providerKey string, instanceID string, config map[string]str return NewWxpay(instanceID, config) case payment.TypeStripe: return NewStripe(instanceID, config) + case payment.TypeAirwallex: + return NewAirwallex(instanceID, config) default: return nil, fmt.Errorf("unknown provider key: %s", providerKey) } diff --git a/backend/internal/payment/provider/stripe.go b/backend/internal/payment/provider/stripe.go index 15359d45..168b90be 100644 --- a/backend/internal/payment/provider/stripe.go +++ b/backend/internal/payment/provider/stripe.go @@ -14,7 +14,6 @@ import ( // Stripe constants. const ( - stripeCurrency = "cny" stripeEventPaymentSuccess = "payment_intent.succeeded" stripeEventPaymentFailed = "payment_intent.payment_failed" ) @@ -34,9 +33,15 @@ func NewStripe(instanceID string, config map[string]string) (*Stripe, error) { if config["secretKey"] == "" { return nil, fmt.Errorf("stripe config missing required key: secretKey") } + cfg := cloneStringMap(config) + currency, err := payment.NormalizePaymentCurrency(cfg["currency"]) + if err != nil { + return nil, fmt.Errorf("stripe config currency: %w", err) + } + cfg["currency"] = currency return &Stripe{ instanceID: instanceID, - config: config, + config: cfg, }, nil } @@ -60,6 +65,24 @@ func (s *Stripe) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeStripe} } +func (s *Stripe) MerchantIdentityMetadata() map[string]string { + if s == nil { + return nil + } + return map[string]string{"currency": s.currency()} +} + +func (s *Stripe) currency() string { + if s == nil { + return payment.DefaultPaymentCurrency + } + currency, err := payment.NormalizePaymentCurrency(s.config["currency"]) + if err != nil { + return payment.DefaultPaymentCurrency + } + return currency +} + // stripePaymentMethodTypes maps our PaymentType to Stripe payment_method_types. var stripePaymentMethodTypes = map[string][]string{ payment.TypeCard: {"card"}, @@ -72,7 +95,8 @@ var stripePaymentMethodTypes = map[string][]string{ func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { s.ensureInit() - amountInCents, err := payment.YuanToFen(req.Amount) + currency := s.currency() + amountInMinorUnit, err := payment.AmountToMinorUnit(req.Amount, currency) if err != nil { return nil, fmt.Errorf("stripe create payment: %w", err) } @@ -86,8 +110,8 @@ func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentReq } params := &stripe.PaymentIntentCreateParams{ - Amount: stripe.Int64(amountInCents), - Currency: stripe.String(stripeCurrency), + Amount: stripe.Int64(amountInMinorUnit), + Currency: stripe.String(strings.ToLower(currency)), PaymentMethodTypes: pmTypes, Description: stripe.String(req.Subject), Metadata: map[string]string{"orderId": req.OrderID}, @@ -113,6 +137,7 @@ func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentReq return &payment.CreatePaymentResponse{ TradeNo: pi.ID, ClientSecret: pi.ClientSecret, + Currency: currency, }, nil } @@ -133,10 +158,14 @@ func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query status = payment.ProviderStatusFailed } + currency := stripeIntentCurrency(pi.Currency, s.currency()) return &payment.QueryOrderResponse{ TradeNo: pi.ID, Status: status, - Amount: payment.FenToYuan(pi.Amount), + Amount: payment.MinorUnitToAmount(pi.Amount, currency), + Metadata: map[string]string{ + "currency": currency, + }, }, nil } @@ -174,12 +203,16 @@ func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { return nil, fmt.Errorf("stripe parse payment_intent: %w", err) } + currency := stripeIntentCurrency(pi.Currency, payment.DefaultPaymentCurrency) return &payment.PaymentNotification{ TradeNo: pi.ID, OrderID: pi.Metadata["orderId"], - Amount: payment.FenToYuan(pi.Amount), + Amount: payment.MinorUnitToAmount(pi.Amount, currency), Status: status, RawData: rawBody, + Metadata: map[string]string{ + "currency": currency, + }, }, nil } @@ -187,14 +220,14 @@ func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) { s.ensureInit() - amountInCents, err := payment.YuanToFen(req.Amount) + amountInMinorUnit, err := payment.AmountToMinorUnit(req.Amount, s.currency()) if err != nil { return nil, fmt.Errorf("stripe refund: %w", err) } params := &stripe.RefundCreateParams{ PaymentIntent: stripe.String(req.TradeNo), - Amount: stripe.Int64(amountInCents), + Amount: stripe.Int64(amountInMinorUnit), Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)), } params.Context = ctx @@ -215,6 +248,18 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen }, nil } +func stripeIntentCurrency(raw stripe.Currency, fallback string) string { + currency, err := payment.NormalizePaymentCurrency(string(raw)) + if err != nil || currency == payment.DefaultPaymentCurrency && strings.TrimSpace(string(raw)) == "" { + normalizedFallback, fallbackErr := payment.NormalizePaymentCurrency(fallback) + if fallbackErr == nil { + return normalizedFallback + } + return payment.DefaultPaymentCurrency + } + return currency +} + // resolveStripeMethodTypes converts instance supported_types (comma-separated) // into Stripe API payment_method_types. Falls back to ["card"] if empty. func resolveStripeMethodTypes(instanceSubMethods string) []string { @@ -257,6 +302,7 @@ func (s *Stripe) CancelPayment(ctx context.Context, tradeNo string) error { // Ensure interface compliance. var ( - _ payment.Provider = (*Stripe)(nil) - _ payment.CancelableProvider = (*Stripe)(nil) + _ payment.Provider = (*Stripe)(nil) + _ payment.CancelableProvider = (*Stripe)(nil) + _ payment.MerchantIdentityProvider = (*Stripe)(nil) ) diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index e7ac6727..d27d9696 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -17,6 +17,7 @@ const ( TypeCard PaymentType = "card" TypeLink PaymentType = "link" TypeEasyPay PaymentType = "easypay" + TypeAirwallex PaymentType = "airwallex" ) // Order status constants shared across payment and service layers. @@ -82,6 +83,8 @@ func GetBasePaymentType(t string) string { switch { case t == TypeEasyPay: return TypeEasyPay + case t == TypeAirwallex: + return TypeAirwallex case t == TypeStripe || t == TypeCard || t == TypeLink: return TypeStripe case len(t) >= len(TypeAlipay) && t[:len(TypeAlipay)] == TypeAlipay: @@ -96,7 +99,7 @@ func GetBasePaymentType(t string) string { // CreatePaymentRequest holds the parameters for creating a new payment. type CreatePaymentRequest struct { OrderID string // Internal order ID - Amount string // Pay amount in CNY (formatted to 2 decimal places) + Amount string // 支付金额,按服务商实例配置的币种解释 PaymentType string // e.g. "alipay", "wxpay", "stripe" Subject string // Product description NotifyURL string // Webhook callback URL @@ -141,7 +144,11 @@ type CreatePaymentResponse struct { TradeNo string // Third-party transaction ID PayURL string // H5 payment URL (alipay/wxpay) QRCode string // QR code content for scanning - ClientSecret string // Stripe PaymentIntent client secret + ClientSecret string // Stripe PaymentIntent 客户端密钥 + IntentID string // 前端 SDK 需要的服务商支付意图 ID + Currency string // 服务商支付币种 + CountryCode string // 服务商收银台国家/地区代码 + PaymentEnv string // 服务商前端环境标识 ResultType CreatePaymentResultType // Typed result contract for frontend flows OAuth *WechatOAuthInfo // WeChat OAuth bootstrap payload when required JSAPI *WechatJSAPIPayload // WeChat JSAPI invocation payload when ready @@ -151,7 +158,7 @@ type CreatePaymentResponse struct { type QueryOrderResponse struct { TradeNo string Status string // "pending", "paid", "failed", "refunded" - Amount float64 // Amount in CNY + Amount float64 // 按服务商返回币种解释的金额 PaidAt string // RFC3339 timestamp or empty Metadata map[string]string } diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index 398c0351..e71f6318 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -20,8 +20,35 @@ const ( CloudflareInsightsDomain = "https://static.cloudflareinsights.com" // StripeDomain is the domain for Stripe.js SDK StripeDomain = "https://*.stripe.com" + // AirwallexStaticDomain 是 Airwallex 生产环境 SDK 脚本域名。 + AirwallexStaticDomain = "https://static.airwallex.com" + // AirwallexCheckoutDomain 是 Airwallex 生产环境收银台元素和 iframe 域名。 + AirwallexCheckoutDomain = "https://checkout.airwallex.com" + // AirwallexDemoStaticDomain 是 Airwallex 沙箱环境 SDK 脚本域名。 + AirwallexDemoStaticDomain = "https://static-demo.airwallex.com" + // AirwallexDemoCheckoutDomain 是 Airwallex 沙箱环境收银台元素和 iframe 域名。 + AirwallexDemoCheckoutDomain = "https://checkout-demo.airwallex.com" ) +var requiredCSPDirectiveValues = []struct { + directive string + value string +}{ + {"script-src", CloudflareInsightsDomain}, + {"script-src", StripeDomain}, + {"frame-src", StripeDomain}, + {"script-src", AirwallexStaticDomain}, + {"script-src", AirwallexCheckoutDomain}, + {"style-src", AirwallexStaticDomain}, + {"style-src", AirwallexCheckoutDomain}, + {"frame-src", AirwallexCheckoutDomain}, + {"script-src", AirwallexDemoStaticDomain}, + {"script-src", AirwallexDemoCheckoutDomain}, + {"style-src", AirwallexDemoStaticDomain}, + {"style-src", AirwallexDemoCheckoutDomain}, + {"frame-src", AirwallexDemoCheckoutDomain}, +} + // GenerateNonce generates a cryptographically secure random nonce. // 返回 error 以确保调用方在 crypto/rand 失败时能正确降级。 func GenerateNonce() (string, error) { @@ -100,29 +127,39 @@ func isAPIRoutePath(c *gin.Context) bool { strings.HasPrefix(path, "/images") } -// enhanceCSPPolicy ensures the CSP policy includes nonce support, Cloudflare Insights, -// and Stripe.js domains. This allows the application to work correctly even if the -// config file has an older CSP policy. +// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和支付 SDK 必需域名。 +// 这样旧配置文件没有及时补域名时,前端支付组件仍能正常加载。 func enhanceCSPPolicy(policy string) string { // Add nonce placeholder to script-src if not present if !strings.Contains(policy, NonceTemplate) && !strings.Contains(policy, "'nonce-") { policy = addToDirective(policy, "script-src", NonceTemplate) } - // Add Cloudflare Insights domain to script-src if not present - if !strings.Contains(policy, CloudflareInsightsDomain) { - policy = addToDirective(policy, "script-src", CloudflareInsightsDomain) - } - - // Add Stripe.js domain to script-src and frame-src if not present - if !strings.Contains(policy, "stripe.com") { - policy = addToDirective(policy, "script-src", StripeDomain) - policy = addToDirective(policy, "frame-src", StripeDomain) + for _, required := range requiredCSPDirectiveValues { + if !directiveHasValue(policy, required.directive, required.value) { + policy = addToDirective(policy, required.directive, required.value) + } } return policy } +func directiveHasValue(policy, directive, value string) bool { + for _, rawDirective := range strings.Split(policy, ";") { + fields := strings.Fields(strings.TrimSpace(rawDirective)) + if len(fields) == 0 || fields[0] != directive { + continue + } + for _, field := range fields[1:] { + if field == value { + return true + } + } + return false + } + return false +} + // addToDirective adds a value to a specific CSP directive. // If the directive doesn't exist, it will be added after default-src. func addToDirective(policy, directive, value string) string { diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index 031385d0..c980f7e6 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -330,6 +330,52 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.NotContains(t, enhanced, NonceTemplate) assert.Contains(t, enhanced, "'nonce-existing'") }) + + t.Run("adds_airwallex_domains_for_payment_sdk", func(t *testing.T) { + policy := "default-src 'self'; script-src 'self' __CSP_NONCE__; style-src 'self'; frame-src 'self'" + enhanced := enhanceCSPPolicy(policy) + + assert.Contains(t, enhanced, "script-src 'self' __CSP_NONCE__") + assert.Contains(t, enhanced, AirwallexStaticDomain) + assert.Contains(t, enhanced, AirwallexCheckoutDomain) + assert.Contains(t, enhanced, AirwallexDemoStaticDomain) + assert.Contains(t, enhanced, AirwallexDemoCheckoutDomain) + assert.Contains(t, enhanced, "style-src 'self'") + assert.Contains(t, enhanced, "frame-src 'self'") + }) + + t.Run("does_not_duplicate_airwallex_domains", func(t *testing.T) { + policy := "default-src 'self'; script-src 'self' https://static.airwallex.com https://static-demo.airwallex.com; frame-src https://checkout.airwallex.com https://checkout-demo.airwallex.com" + enhanced := enhanceCSPPolicy(policy) + + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", AirwallexStaticDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", AirwallexCheckoutDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", AirwallexStaticDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", AirwallexCheckoutDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", AirwallexCheckoutDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", AirwallexDemoStaticDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", AirwallexDemoCheckoutDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", AirwallexDemoStaticDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", AirwallexDemoCheckoutDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", AirwallexDemoCheckoutDomain)) + }) +} + +func countDirectiveValue(policy, directive, value string) int { + for _, rawDirective := range strings.Split(policy, ";") { + fields := strings.Fields(strings.TrimSpace(rawDirective)) + if len(fields) == 0 || fields[0] != directive { + continue + } + count := 0 + for _, field := range fields[1:] { + if field == value { + count++ + } + } + return count + } + return 0 } func TestAddToDirective(t *testing.T) { diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index e4828ead..beeae611 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -62,6 +62,7 @@ func RegisterPaymentRoutes( webhook.POST("/alipay", webhookHandler.AlipayNotify) webhook.POST("/wxpay", webhookHandler.WxpayNotify) webhook.POST("/stripe", webhookHandler.StripeWebhook) + webhook.POST("/airwallex", webhookHandler.AirwallexWebhook) } // --- Admin payment endpoints (admin auth) --- diff --git a/backend/internal/service/payment_amounts.go b/backend/internal/service/payment_amounts.go index cc01f6ad..a7f620d3 100644 --- a/backend/internal/service/payment_amounts.go +++ b/backend/internal/service/payment_amounts.go @@ -3,6 +3,7 @@ package service import ( "math" + "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/shopspring/decimal" ) @@ -22,16 +23,17 @@ func calculateCreditedBalance(paymentAmount, multiplier float64) float64 { InexactFloat64() } -func calculateGatewayRefundAmount(orderAmount, payAmount, refundAmount float64) float64 { +func calculateGatewayRefundAmount(orderAmount, payAmount, refundAmount float64, currency string) float64 { if orderAmount <= 0 || payAmount <= 0 || refundAmount <= 0 { return 0 } - if math.Abs(refundAmount-orderAmount) <= amountToleranceCNY { - return decimal.NewFromFloat(payAmount).Round(2).InexactFloat64() + fractionDigits := int32(payment.CurrencyMaxFractionDigits(currency)) + if math.Abs(refundAmount-orderAmount) <= paymentAmountToleranceForCurrency(currency) { + return decimal.NewFromFloat(payAmount).Round(fractionDigits).InexactFloat64() } return decimal.NewFromFloat(payAmount). Mul(decimal.NewFromFloat(refundAmount)). Div(decimal.NewFromFloat(orderAmount)). - Round(2). + Round(fractionDigits). InexactFloat64() } diff --git a/backend/internal/service/payment_config_limits.go b/backend/internal/service/payment_config_limits.go index 973c601a..45b24bfc 100644 --- a/backend/internal/service/payment_config_limits.go +++ b/backend/internal/service/payment_config_limits.go @@ -8,6 +8,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) // GetAvailableMethodLimits collects all payment types from enabled provider @@ -25,7 +26,12 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M Methods: make(map[string]MethodLimits, len(typeInstances)), } for pt, insts := range typeInstances { + currency, ok := s.pcAggregateMethodCurrency(insts) + if !ok { + continue + } ml := pcAggregateMethodLimits(pt, insts) + ml.Currency = currency resp.Methods[ml.PaymentType] = ml } resp.GlobalMin, resp.GlobalMax = pcComputeGlobalRange(resp.Methods) @@ -82,11 +88,81 @@ func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []stri matching = append(matching, inst) } } - result = append(result, pcAggregateMethodLimits(pt, matching)) + currency, ok := s.pcAggregateMethodCurrency(matching) + if !ok { + continue + } + ml := pcAggregateMethodLimits(pt, matching) + ml.Currency = currency + result = append(result, ml) } return result, nil } +func (s *PaymentConfigService) ValidateMethodCurrencyConsistency(ctx context.Context, paymentType string) (string, error) { + method := NormalizeVisibleMethod(paymentType) + if method == "" || s == nil || s.entClient == nil { + return payment.DefaultPaymentCurrency, nil + } + + instances, err := s.entClient.PaymentProviderInstance.Query(). + Where(paymentproviderinstance.EnabledEQ(true)).All(ctx) + if err != nil { + return "", fmt.Errorf("query provider instances: %w", err) + } + + typeInstances := pcGroupByPaymentType(instances) + typeInstances = s.pcApplyEnabledVisibleMethodInstances(ctx, typeInstances, instances) + matching := typeInstances[method] + if len(matching) == 0 { + return payment.DefaultPaymentCurrency, nil + } + + currency, ok := s.pcAggregateMethodCurrency(matching) + if !ok { + return "", infraerrors.ServiceUnavailable( + "PAYMENT_METHOD_CURRENCY_CONFLICT", + "payment method has enabled provider instances with mixed currencies", + ).WithMetadata(map[string]string{"payment_type": method}) + } + return currency, nil +} + +func (s *PaymentConfigService) pcAggregateMethodCurrency(instances []*dbent.PaymentProviderInstance) (string, bool) { + currency := "" + for _, inst := range instances { + next := s.pcInstancePaymentCurrency(inst) + if next == "" { + continue + } + if currency == "" { + currency = next + continue + } + if currency != next { + return "", false + } + } + if currency == "" { + return payment.DefaultPaymentCurrency, true + } + return currency, true +} + +func (s *PaymentConfigService) pcInstancePaymentCurrency(inst *dbent.PaymentProviderInstance) string { + if inst == nil { + return payment.DefaultPaymentCurrency + } + cfg := map[string]string{} + if s != nil { + decrypted, err := s.decryptConfig(inst.Config) + if err == nil && decrypted != nil { + cfg = decrypted + } + } + return paymentProviderConfigCurrency(inst.ProviderKey, cfg) +} + // pcGroupByPaymentType groups instances by user-facing payment type. // For Stripe providers, ALL sub-types (card, link, alipay, wxpay) map to "stripe" // because the user sees a single "Stripe" button, not individual sub-methods. diff --git a/backend/internal/service/payment_config_limits_test.go b/backend/internal/service/payment_config_limits_test.go index 4df506d6..c0aa2b27 100644 --- a/backend/internal/service/payment_config_limits_test.go +++ b/backend/internal/service/payment_config_limits_test.go @@ -6,6 +6,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" ) @@ -199,6 +200,61 @@ func TestPcGroupByPaymentType(t *testing.T) { }) } +func TestPcAggregateMethodCurrency(t *testing.T) { + t.Parallel() + + svc := &PaymentConfigService{} + stripe := makeInstance(1, payment.TypeStripe, payment.TypeStripe, "") + stripe.Config = `{"currency":"hkd"}` + currency, ok := svc.pcAggregateMethodCurrency([]*dbent.PaymentProviderInstance{stripe}) + require.True(t, ok) + require.Equal(t, "HKD", currency) + + airwallex := makeInstance(2, payment.TypeAirwallex, payment.TypeAirwallex, "") + airwallex.Config = `{"currency":"usd"}` + currency, ok = svc.pcAggregateMethodCurrency([]*dbent.PaymentProviderInstance{stripe, airwallex}) + require.False(t, ok) + require.Empty(t, currency) + + easypay := makeInstance(3, payment.TypeEasyPay, payment.TypeAlipay, "") + currency, ok = svc.pcAggregateMethodCurrency([]*dbent.PaymentProviderInstance{easypay}) + require.True(t, ok) + require.Equal(t, payment.DefaultPaymentCurrency, currency) +} + +func TestGetAvailableMethodLimitsOmitsMixedCurrencyMethod(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + + _, err := client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeStripe). + SetName("Stripe HKD"). + SetConfig(`{"currency":"HKD"}`). + SetSupportedTypes("card,link"). + SetEnabled(true). + Save(ctx) + require.NoError(t, err) + + _, err = client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeStripe). + SetName("Stripe USD"). + SetConfig(`{"currency":"USD"}`). + SetSupportedTypes("card,link"). + SetEnabled(true). + Save(ctx) + require.NoError(t, err) + + svc := &PaymentConfigService{entClient: client} + resp, err := svc.GetAvailableMethodLimits(ctx) + require.NoError(t, err) + require.NotContains(t, resp.Methods, payment.TypeStripe) + + _, err = svc.ValidateMethodCurrencyConsistency(ctx, payment.TypeStripe) + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, "PAYMENT_METHOD_CURRENCY_CONFLICT", appErr.Reason) +} + func TestPcComputeGlobalRange(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go index ff05e559..7e925585 100644 --- a/backend/internal/service/payment_config_providers.go +++ b/backend/internal/service/payment_config_providers.go @@ -110,10 +110,11 @@ var pendingOrderStatuses = []string{ // Key matching is case-insensitive. Non-listed keys (e.g. appId, notifyUrl, // stripe publishableKey) are returned in plaintext by the admin GET API. var providerSensitiveConfigFields = map[string]map[string]struct{}{ - payment.TypeEasyPay: {"pkey": {}}, - payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}}, - payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}}, - payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}}, + payment.TypeEasyPay: {"pkey": {}}, + payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}}, + payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}}, + payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}}, + payment.TypeAirwallex: {"apikey": {}, "webhooksecret": {}}, } // providerPendingOrderProtectedConfigFields lists config keys that cannot be @@ -121,10 +122,11 @@ var providerSensitiveConfigFields = map[string]map[string]struct{}{ // all provider identity fields that are snapshotted into orders or used by // webhook/refund verification. var providerPendingOrderProtectedConfigFields = map[string]map[string]struct{}{ - payment.TypeEasyPay: {"pkey": {}, "pid": {}}, - payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}, "appid": {}}, - payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}}, - payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}}, + payment.TypeEasyPay: {"pkey": {}, "pid": {}}, + payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}, "appid": {}}, + payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}}, + payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}, "currency": {}}, + payment.TypeAirwallex: {"clientid": {}, "apikey": {}, "webhooksecret": {}, "apibase": {}, "accountid": {}, "currency": {}}, } func isSensitiveProviderConfigField(providerKey, fieldName string) bool { @@ -175,7 +177,7 @@ func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, pla } var validProviderKeys = map[string]bool{ - payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, + payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true, } func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { diff --git a/backend/internal/service/payment_config_providers_test.go b/backend/internal/service/payment_config_providers_test.go index e0d2908a..43708de7 100644 --- a/backend/internal/service/payment_config_providers_test.go +++ b/backend/internal/service/payment_config_providers_test.go @@ -44,6 +44,13 @@ func TestValidateProviderRequest(t *testing.T) { supportedTypes: "", wantErr: false, }, + { + name: "valid airwallex provider", + providerKey: payment.TypeAirwallex, + providerName: "Airwallex Provider", + supportedTypes: payment.TypeAirwallex, + wantErr: false, + }, { name: "valid alipay provider", providerKey: "alipay", @@ -120,6 +127,7 @@ func TestIsSensitiveProviderConfigField(t *testing.T) { {"stripe", "webhookSecret", true}, {"stripe", "SecretKey", true}, // case-insensitive {"stripe", "publishableKey", false}, + {"stripe", "currency", false}, {"stripe", "appId", false}, // Alipay @@ -142,6 +150,14 @@ func TestIsSensitiveProviderConfigField(t *testing.T) { {"easypay", "pid", false}, {"easypay", "apiBase", false}, + // Airwallex + {payment.TypeAirwallex, "apiKey", true}, + {payment.TypeAirwallex, "webhookSecret", true}, + {payment.TypeAirwallex, "clientId", false}, + {payment.TypeAirwallex, "apiBase", false}, + {payment.TypeAirwallex, "accountId", false}, + {payment.TypeAirwallex, "currency", false}, + // Unknown provider: never sensitive {"unknown", "secretKey", false}, } @@ -395,6 +411,42 @@ func TestUpdateProviderInstanceRejectsProtectedConfigChangesWhilePendingOrders(t fieldName: "pid", wantValue: "pid-test", }, + { + name: "stripe currency", + providerKey: payment.TypeStripe, + createConfig: validStripeProviderConfig, + supportedType: []string{payment.TypeStripe}, + updateConfig: map[string]string{"currency": "HKD"}, + fieldName: "currency", + wantValue: "CNY", + }, + { + name: "airwallex accountId", + providerKey: payment.TypeAirwallex, + createConfig: validAirwallexProviderConfig, + supportedType: []string{payment.TypeAirwallex}, + updateConfig: map[string]string{"accountId": "acct-updated"}, + fieldName: "accountId", + wantValue: "acct-test", + }, + { + name: "airwallex currency", + providerKey: payment.TypeAirwallex, + createConfig: validAirwallexProviderConfig, + supportedType: []string{payment.TypeAirwallex}, + updateConfig: map[string]string{"currency": "HKD"}, + fieldName: "currency", + wantValue: "CNY", + }, + { + name: "airwallex webhookSecret", + providerKey: payment.TypeAirwallex, + createConfig: validAirwallexProviderConfig, + supportedType: []string{payment.TypeAirwallex}, + updateConfig: map[string]string{"webhookSecret": "whsec-updated"}, + fieldName: "webhookSecret", + wantValue: "whsec-test", + }, } for _, tc := range tests { @@ -506,6 +558,39 @@ func TestUpdateProviderInstanceAllowsSafeConfigChangesWhilePendingOrders(t *test } } +func TestUpdateProviderInstanceClearsAirwallexAccountID(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + svc := &PaymentConfigService{ + entClient: client, + encryptionKey: []byte("0123456789abcdef0123456789abcdef"), + } + + instance, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{ + ProviderKey: payment.TypeAirwallex, + Name: "airwallex-clear-account", + Config: validAirwallexProviderConfig(t), + SupportedTypes: []string{payment.TypeAirwallex}, + Enabled: true, + }) + require.NoError(t, err) + + updated, err := svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{ + Config: map[string]string{"accountId": ""}, + }) + require.NoError(t, err) + require.NotNil(t, updated) + + saved, err := client.PaymentProviderInstance.Get(ctx, instance.ID) + require.NoError(t, err) + cfg, err := svc.decryptConfig(saved.Config) + require.NoError(t, err) + require.Empty(t, cfg["accountId"]) + require.Equal(t, "client-id-test", cfg["clientId"]) +} + func createPendingProviderConfigOrder(t *testing.T, ctx context.Context, client *dbent.Client, instance *dbent.PaymentProviderInstance) { t.Helper() @@ -545,11 +630,26 @@ func providerPendingOrderPaymentType(providerKey string) string { return payment.TypeWxpay case payment.TypeAlipay: return payment.TypeAlipay + case payment.TypeAirwallex: + return payment.TypeAirwallex + case payment.TypeStripe: + return payment.TypeStripe default: return payment.TypeAlipay } } +func validStripeProviderConfig(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + "secretKey": "sk_test_123", + "publishableKey": "pk_test_123", + "webhookSecret": "whsec-test", + "currency": "CNY", + } +} + func boolPtrValue(v bool) *bool { return &v } @@ -577,6 +677,19 @@ func validEasyPayProviderConfig(t *testing.T) map[string]string { } } +func validAirwallexProviderConfig(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + "clientId": "client-id-test", + "apiKey": "api-key-test", + "webhookSecret": "whsec-test", + "apiBase": "https://api-demo.airwallex.com/api/v1", + "accountId": "acct-test", + "currency": "CNY", + } +} + func validWxpayProviderConfig(t *testing.T) map[string]string { t.Helper() diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 02d061ae..f57ac614 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -103,6 +103,7 @@ type UpdatePaymentConfigRequest struct { // MethodLimits holds per-payment-type limits. type MethodLimits struct { PaymentType string `json:"payment_type"` + Currency string `json:"currency"` FeeRate float64 `json:"fee_rate"` DailyLimit float64 `json:"daily_limit"` SingleMin float64 `json:"single_min"` diff --git a/backend/internal/service/payment_currency.go b/backend/internal/service/payment_currency.go new file mode 100644 index 00000000..64fe94dd --- /dev/null +++ b/backend/internal/service/payment_currency.go @@ -0,0 +1,28 @@ +package service + +import ( + "strings" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/payment" +) + +func paymentProviderConfigCurrency(providerKey string, cfg map[string]string) string { + switch strings.TrimSpace(providerKey) { + case payment.TypeStripe, payment.TypeAirwallex: + currency, err := payment.NormalizePaymentCurrency(cfg["currency"]) + if err == nil { + return currency + } + } + return payment.DefaultPaymentCurrency +} + +func PaymentOrderCurrency(order *dbent.PaymentOrder) string { + if snapshot := psOrderProviderSnapshot(order); snapshot != nil { + if currency, err := payment.NormalizePaymentCurrency(snapshot.Currency); err == nil { + return currency + } + } + return payment.DefaultPaymentCurrency +} diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index f96684a4..8a26e868 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -101,13 +101,21 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo }) return fmt.Errorf("invalid paid amount from provider: %v", paid) } - if math.Abs(paid-o.PayAmount) > amountToleranceCNY { + if math.Abs(paid-o.PayAmount) > paymentAmountToleranceForCurrency(PaymentOrderCurrency(o)) { s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo}) - return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid) + return fmt.Errorf("amount mismatch: expected %s, got %s", strconv.FormatFloat(o.PayAmount, 'f', -1, 64), strconv.FormatFloat(paid, 'f', -1, 64)) } return s.toPaid(ctx, o, tradeNo, paid, pk) } +func paymentAmountToleranceForCurrency(currency string) float64 { + minorUnit := payment.CurrencyMinorUnit(currency) + if minorUnit <= 2 { + return amountToleranceCNY + } + return math.Pow10(-minorUnit) / 2 +} + func isValidProviderAmount(amount float64) bool { return amount > 0 && !math.IsNaN(amount) && !math.IsInf(amount, 0) } diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index abdb59de..f46cf037 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -366,3 +366,55 @@ func TestValidateProviderNotificationMetadataRejectsEasyPaySnapshotMismatch(t *t }) assert.ErrorContains(t, err, "easypay pid mismatch") } + +func TestValidateProviderNotificationMetadataRejectsAirwallexSnapshotMismatch(t *testing.T) { + t.Parallel() + + order := &dbent.PaymentOrder{ + PaymentType: payment.TypeAirwallex, + ProviderSnapshot: map[string]any{ + "schema_version": 2, + "merchant_id": "acct_expected", + "currency": "CNY", + }, + } + + err := validateProviderNotificationMetadata(order, payment.TypeAirwallex, map[string]string{ + "account_id": "acct_other", + "currency": "CNY", + "status": "SUCCEEDED", + }) + assert.ErrorContains(t, err, "airwallex account_id mismatch") + + err = validateProviderNotificationMetadata(order, payment.TypeAirwallex, map[string]string{ + "account_id": "acct_expected", + "currency": "USD", + "status": "SUCCEEDED", + }) + assert.ErrorContains(t, err, "airwallex currency mismatch") +} + +func TestValidateProviderNotificationMetadataRejectsStripeCurrencyMismatch(t *testing.T) { + t.Parallel() + + order := &dbent.PaymentOrder{ + PaymentType: payment.TypeStripe, + ProviderSnapshot: map[string]any{ + "schema_version": 2, + "currency": "HKD", + }, + } + + err := validateProviderNotificationMetadata(order, payment.TypeStripe, map[string]string{ + "currency": "USD", + }) + assert.ErrorContains(t, err, "stripe currency mismatch") +} + +func TestPaymentAmountToleranceForThreeDecimalCurrency(t *testing.T) { + t.Parallel() + + assert.Equal(t, amountToleranceCNY, paymentAmountToleranceForCurrency("CNY")) + assert.Equal(t, amountToleranceCNY, paymentAmountToleranceForCurrency("JPY")) + assert.InDelta(t, 0.0005, paymentAmountToleranceForCurrency("KWD"), 1e-12) +} diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 15d4509d..056967f0 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -57,8 +57,17 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest orderAmount = calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier) } feeRate := cfg.RechargeFeeRate - payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate) - payAmount, _ := strconv.ParseFloat(payAmountStr, 64) + methodCurrency := payment.DefaultPaymentCurrency + if s.configService != nil { + methodCurrency, err = s.configService.ValidateMethodCurrencyConsistency(ctx, req.PaymentType) + if err != nil { + return nil, err + } + } + payAmountStr, payAmount, err := calculateCreateOrderPayAmount(limitAmount, feeRate, methodCurrency) + if err != nil { + return nil, err + } sel, err := s.selectCreateOrderInstance(ctx, req, cfg, payAmount) if err != nil { return nil, err @@ -66,6 +75,19 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest if err := s.validateSelectedCreateOrderInstance(ctx, req, sel); err != nil { return nil, err } + selectedCurrency := payment.DefaultPaymentCurrency + if sel != nil { + selectedCurrency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config) + } + if selectedCurrency != methodCurrency { + payAmountStr, payAmount, err = calculateCreateOrderPayAmount(limitAmount, feeRate, selectedCurrency) + if err != nil { + return nil, err + } + } + if err := validateSelectedCreateOrderAmountCurrency(payAmountStr, sel); err != nil { + return nil, err + } oauthResp, err := s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, limitAmount, payAmount, feeRate, sel) if err != nil { return nil, err @@ -257,7 +279,7 @@ func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req Creat if merchantID := strings.TrimSpace(sel.Config["mchId"]); merchantID != "" { snapshot["merchant_id"] = merchantID } - snapshot["currency"] = "CNY" + snapshot["currency"] = payment.DefaultPaymentCurrency } if providerKey == payment.TypeAlipay { if merchantAppID := strings.TrimSpace(sel.Config["appId"]); merchantAppID != "" { @@ -269,6 +291,15 @@ func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req Creat snapshot["merchant_id"] = merchantID } } + if providerKey == payment.TypeStripe { + snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config) + } + if providerKey == payment.TypeAirwallex { + if accountID := strings.TrimSpace(sel.Config["accountId"]); accountID != "" { + snapshot["merchant_id"] = accountID + } + snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config) + } if len(snapshot) == 1 { return nil @@ -377,7 +408,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen return nil, infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_MISCONFIGURED", "provider_misconfigured"). WithMetadata(map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID}) } - subject := s.buildPaymentSubject(plan, limitAmount, cfg) + subject := s.buildPaymentSubject(plan, limitAmount, cfg, sel) outTradeNo := order.OutTradeNo canonicalReturnURL, err := CanonicalizeReturnURL(req.ReturnURL, req.SrcHost, req.SrcURL) if err != nil { @@ -466,20 +497,24 @@ func selectedInstanceSupportedTypes(sel *payment.InstanceSelection) string { return sel.SupportedTypes } -func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig) string { +func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig, sel *payment.InstanceSelection) string { if plan != nil { if plan.ProductName != "" { return plan.ProductName } return "Sub2API Subscription " + plan.Name } - amountStr := strconv.FormatFloat(limitAmount, 'f', 2, 64) + currency := payment.DefaultPaymentCurrency + if sel != nil { + currency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config) + } + amountStr := payment.FormatAmountForCurrency(limitAmount, currency) pf := strings.TrimSpace(cfg.ProductNamePrefix) sf := strings.TrimSpace(cfg.ProductNameSuffix) if pf != "" || sf != "" { return strings.TrimSpace(pf + " " + amountStr + " " + sf) } - return "Sub2API " + amountStr + " CNY" + return "Sub2API " + amountStr + " " + currency } func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) { @@ -540,6 +575,44 @@ func (s *PaymentService) validateSelectedCreateOrderInstance(ctx context.Context return nil } +func calculateCreateOrderPayAmount(limitAmount, feeRate float64, currency string) (string, float64, error) { + if err := validateCreateOrderAmountCurrency(limitAmount, currency); err != nil { + return "", 0, err + } + payAmountStr := payment.CalculatePayAmountForCurrency(limitAmount, feeRate, currency) + if _, err := payment.AmountToMinorUnit(payAmountStr, currency); err != nil { + return "", 0, infraerrors.BadRequest("INVALID_AMOUNT", err.Error()). + WithMetadata(map[string]string{"currency": currency}) + } + payAmount, err := strconv.ParseFloat(payAmountStr, 64) + if err != nil { + return "", 0, infraerrors.BadRequest("INVALID_AMOUNT", "invalid payment amount"). + WithMetadata(map[string]string{"currency": currency}) + } + return payAmountStr, payAmount, nil +} + +func validateCreateOrderAmountCurrency(amount float64, currency string) error { + amountStr := strconv.FormatFloat(amount, 'f', -1, 64) + if _, err := payment.AmountToMinorUnit(amountStr, currency); err != nil { + return infraerrors.BadRequest("INVALID_AMOUNT", err.Error()). + WithMetadata(map[string]string{"currency": currency}) + } + return nil +} + +func validateSelectedCreateOrderAmountCurrency(payAmount string, sel *payment.InstanceSelection) error { + if sel == nil { + return nil + } + currency := paymentProviderConfigCurrency(sel.ProviderKey, sel.Config) + if _, err := payment.AmountToMinorUnit(payAmount, currency); err != nil { + return infraerrors.BadRequest("INVALID_AMOUNT", err.Error()). + WithMetadata(map[string]string{"currency": currency}) + } + return nil +} + func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment.InstanceSelection) bool { if sel == nil || sel.ProviderKey != payment.TypeWxpay || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay { return false @@ -596,6 +669,10 @@ func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, PayURL: pr.PayURL, QRCode: pr.QRCode, ClientSecret: pr.ClientSecret, + IntentID: pr.IntentID, + Currency: pr.Currency, + CountryCode: pr.CountryCode, + PaymentEnv: pr.PaymentEnv, OAuth: pr.OAuth, JSAPI: pr.JSAPI, JSAPIPayload: pr.JSAPI, diff --git a/backend/internal/service/payment_order_provider_snapshot.go b/backend/internal/service/payment_order_provider_snapshot.go index bb60f9e2..c5d8f86f 100644 --- a/backend/internal/service/payment_order_provider_snapshot.go +++ b/backend/internal/service/payment_order_provider_snapshot.go @@ -188,6 +188,38 @@ func validateProviderSnapshotMetadata(order *dbent.PaymentOrder, providerKey str return fmt.Errorf("easypay pid mismatch: expected %s, got %s", expected, actual) } } + case payment.TypeStripe: + if expected := strings.TrimSpace(snapshot.Currency); expected != "" { + actual := strings.ToUpper(strings.TrimSpace(metadata["currency"])) + if actual == "" { + return fmt.Errorf("stripe notification missing currency") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("stripe currency mismatch: expected %s, got %s", expected, actual) + } + } + case payment.TypeAirwallex: + if expected := strings.TrimSpace(snapshot.MerchantID); expected != "" { + actual := strings.TrimSpace(metadata["account_id"]) + if actual == "" { + return fmt.Errorf("airwallex account_id missing") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("airwallex account_id mismatch: expected %s, got %s", expected, actual) + } + } + if expected := strings.TrimSpace(snapshot.Currency); expected != "" { + actual := strings.ToUpper(strings.TrimSpace(metadata["currency"])) + if actual == "" { + return fmt.Errorf("airwallex notification missing currency") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("airwallex currency mismatch: expected %s, got %s", expected, actual) + } + } + if actual := strings.TrimSpace(metadata["status"]); actual != "" && !strings.EqualFold(actual, "SUCCEEDED") { + return fmt.Errorf("airwallex status mismatch: expected SUCCEEDED, got %s", actual) + } } return nil diff --git a/backend/internal/service/payment_order_provider_snapshot_test.go b/backend/internal/service/payment_order_provider_snapshot_test.go index efa013b5..127202bc 100644 --- a/backend/internal/service/payment_order_provider_snapshot_test.go +++ b/backend/internal/service/payment_order_provider_snapshot_test.go @@ -164,6 +164,30 @@ func TestBuildPaymentOrderProviderSnapshot_IncludesEasyPayMerchantIdentity(t *te require.NotContains(t, snapshot, "pkey") } +func TestBuildPaymentOrderProviderSnapshot_IncludesProviderCurrency(t *testing.T) { + t.Parallel() + + stripeSnapshot := buildPaymentOrderProviderSnapshot(&payment.InstanceSelection{ + InstanceID: "77", + ProviderKey: payment.TypeStripe, + Config: map[string]string{ + "currency": "hkd", + }, + }, CreateOrderRequest{}) + require.Equal(t, "HKD", stripeSnapshot["currency"]) + + airwallexSnapshot := buildPaymentOrderProviderSnapshot(&payment.InstanceSelection{ + InstanceID: "78", + ProviderKey: payment.TypeAirwallex, + Config: map[string]string{ + "currency": "usd", + "accountId": "acct-78", + }, + }, CreateOrderRequest{}) + require.Equal(t, "USD", airwallexSnapshot["currency"]) + require.Equal(t, "acct-78", airwallexSnapshot["merchant_id"]) +} + func valueOrEmpty(v *string) string { if v == nil { return "" diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go index 2d7412e0..f78d6b37 100644 --- a/backend/internal/service/payment_order_result_test.go +++ b/backend/internal/service/payment_order_result_test.go @@ -91,6 +91,53 @@ func TestBuildCreateOrderResponseCopiesJSAPIPayload(t *testing.T) { } } +func TestValidateSelectedCreateOrderAmountCurrencyRejectsFractionalZeroDecimal(t *testing.T) { + t.Parallel() + + err := validateSelectedCreateOrderAmountCurrency("100.50", &payment.InstanceSelection{ + ProviderKey: payment.TypeStripe, + Config: map[string]string{"currency": "JPY"}, + }) + if err == nil { + t.Fatal("expected fractional JPY amount to fail") + } + if appErr := infraerrors.FromError(err); appErr.Reason != "INVALID_AMOUNT" { + t.Fatalf("reason = %q, want INVALID_AMOUNT", appErr.Reason) + } +} + +func TestCalculateCreateOrderPayAmountUsesCurrencyPrecision(t *testing.T) { + t.Parallel() + + amountStr, amount, err := calculateCreateOrderPayAmount(100, 2.5, "JPY") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if amountStr != "103" || amount != 103 { + t.Fatalf("JPY pay amount = (%q, %v), want (103, 103)", amountStr, amount) + } + + amountStr, amount, err = calculateCreateOrderPayAmount(12.345, 1, "KWD") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if amountStr != "12.469" || amount != 12.469 { + t.Fatalf("KWD pay amount = (%q, %v), want (12.469, 12.469)", amountStr, amount) + } +} + +func TestCalculateCreateOrderPayAmountRejectsFractionalZeroDecimal(t *testing.T) { + t.Parallel() + + _, _, err := calculateCreateOrderPayAmount(100.5, 0, "JPY") + if err == nil { + t.Fatal("expected fractional JPY amount to fail") + } + if appErr := infraerrors.FromError(err); appErr.Reason != "INVALID_AMOUNT" { + t.Fatalf("reason = %q, want INVALID_AMOUNT", appErr.Reason) + } +} + func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) { t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "0123456789abcdef0123456789abcdef") diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index 7521878c..4df3e41f 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -226,10 +226,11 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float if amt <= 0 { amt = o.Amount } - if amt-o.Amount > amountToleranceCNY { + orderCurrency := PaymentOrderCurrency(o) + if amt-o.Amount > paymentAmountToleranceForCurrency(orderCurrency) { return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge") } - ga := calculateGatewayRefundAmount(o.Amount, o.PayAmount, amt) + ga := calculateGatewayRefundAmount(o.Amount, o.PayAmount, amt, orderCurrency) rr := strings.TrimSpace(reason) if rr == "" && o.RefundRequestReason != nil { rr = *o.RefundRequestReason @@ -339,13 +340,35 @@ func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error { }) return err } - _, err = prov.Refund(ctx, payment.RefundRequest{ + resp, err := prov.Refund(ctx, payment.RefundRequest{ TradeNo: p.Order.PaymentTradeNo, OrderID: p.Order.OutTradeNo, - Amount: strconv.FormatFloat(p.GatewayAmount, 'f', 2, 64), + Amount: formatGatewayRefundAmount(p.GatewayAmount, p.Order), Reason: p.Reason, }) - return err + if err != nil { + return err + } + return validateRefundProviderResponse(resp) +} + +func formatGatewayRefundAmount(amount float64, order *dbent.PaymentOrder) string { + return payment.FormatAmountForCurrency(amount, PaymentOrderCurrency(order)) +} + +func validateRefundProviderResponse(resp *payment.RefundResponse) error { + if resp == nil { + return fmt.Errorf("payment refund response missing") + } + status := strings.TrimSpace(resp.Status) + switch status { + case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded, payment.ProviderStatusPending: + return nil + case payment.ProviderStatusFailed: + return fmt.Errorf("payment refund failed: status %s", status) + default: + return fmt.Errorf("payment refund returned unknown status: %s", status) + } } // getRefundProvider creates a provider using the order's original instance config. diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go index ca5b62cb..c275bb65 100644 --- a/backend/internal/service/payment_refund_test.go +++ b/backend/internal/service/payment_refund_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/payment" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" @@ -184,3 +185,26 @@ func TestGwRefundRejectsAlipayMerchantIdentitySnapshotMismatch(t *testing.T) { }) require.ErrorContains(t, err, "alipay app_id mismatch") } + +func TestCalculateGatewayRefundAmountUsesCurrencyPrecision(t *testing.T) { + require.InDelta(t, 6.173, calculateGatewayRefundAmount(100, 12.345, 50, "KWD"), 1e-12) + require.InDelta(t, 12.345, calculateGatewayRefundAmount(100, 12.345, 100, "KWD"), 1e-12) + require.InDelta(t, 52, calculateGatewayRefundAmount(100, 103, 50, "JPY"), 1e-12) +} + +func TestFormatGatewayRefundAmountUsesOrderCurrency(t *testing.T) { + order := &dbent.PaymentOrder{ + ProviderSnapshot: map[string]any{ + "currency": "KWD", + }, + } + + require.Equal(t, "12.345", formatGatewayRefundAmount(12.345, order)) +} + +func TestValidateRefundProviderResponseAcceptsPending(t *testing.T) { + require.NoError(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusPending})) + require.NoError(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusSuccess})) + require.Error(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusFailed})) + require.Error(t, validateRefundProviderResponse(nil)) +} diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index aa121e41..42553840 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -97,6 +97,10 @@ type CreateOrderResponse struct { PayURL string `json:"pay_url,omitempty"` QRCode string `json:"qr_code,omitempty"` ClientSecret string `json:"client_secret,omitempty"` + IntentID string `json:"intent_id,omitempty"` + Currency string `json:"currency,omitempty"` + CountryCode string `json:"country_code,omitempty"` + PaymentEnv string `json:"payment_env,omitempty"` OAuth *payment.WechatOAuthInfo `json:"oauth,omitempty"` JSAPI *payment.WechatJSAPIPayload `json:"jsapi,omitempty"` JSAPIPayload *payment.WechatJSAPIPayload `json:"jsapi_payload,omitempty"` diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index f48280f7..0d61b710 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -129,7 +129,7 @@ security: # 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖) # Note: __CSP_NONCE__ will be replaced with 'nonce-xxx' at request time for inline script security # 注意:__CSP_NONCE__ 会在请求时被替换为 'nonce-xxx',用于内联脚本安全 - policy: "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + policy: "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" proxy_probe: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) diff --git a/frontend/package.json b/frontend/package.json index d33026f9..1a4fb951 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@airwallex/components-sdk": "^1.30.2", "@lobehub/icons": "^4.0.2", "@stripe/stripe-js": "^9.0.1", "@tanstack/vue-virtual": "^3.13.23", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 31637760..02fca903 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@airwallex/components-sdk': + specifier: ^1.30.2 + version: 1.30.2 '@lobehub/icons': specifier: ^4.0.2 version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -129,6 +132,12 @@ importers: packages: + '@airwallex/airtracker@3.2.0': + resolution: {integrity: sha512-PKE5N38ajTVg6ph9JzLpWsICNjqLtf/wWudNVU3UPX9SVy2I5s5ITc281sMSD8+LIE6RJoGjGTO+VYP/io5kig==} + + '@airwallex/components-sdk@1.30.2': + resolution: {integrity: sha512-BGwAPCACwOJm8XNxDxJGMq1o/73D9+ZWifvp5YHvfgIwxg1RGVCIME0tP1g8cash3fVLHgl7xObyS1QbIOSDXw==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -4516,6 +4525,12 @@ packages: snapshots: + '@airwallex/airtracker@3.2.0': {} + + '@airwallex/components-sdk@1.30.2': + dependencies: + '@airwallex/airtracker': 3.2.0 + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': diff --git a/frontend/src/assets/icons/airwallex.svg b/frontend/src/assets/icons/airwallex.svg new file mode 100644 index 00000000..8270f497 --- /dev/null +++ b/frontend/src/assets/icons/airwallex.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index 57e475f3..677103bb 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -212,6 +212,7 @@ const paymentTypeFilterOptions = computed(() => [ { value: 'alipay', label: t('payment.methods.alipay') }, { value: 'wxpay', label: t('payment.methods.wxpay') }, { value: 'stripe', label: t('payment.methods.stripe') }, + { value: 'airwallex', label: t('payment.methods.airwallex') }, ]) const orderTypeFilterOptions = computed(() => [ diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue index 5e3f801c..d84a3e15 100644 --- a/frontend/src/components/payment/PaymentMethodSelector.vue +++ b/frontend/src/components/payment/PaymentMethodSelector.vue @@ -20,7 +20,7 @@ @click="method.available && emit('select', method.type)" > - + {{ t(`payment.methods.${method.type}`) }} = { alipay: alipayIcon, wxpay: wxpayIcon, stripe: stripeIcon, + airwallex: airwallexIcon, } const sortedMethods = computed(() => { @@ -79,6 +81,7 @@ const sortedMethods = computed(() => { function methodIcon(type: string): string { if (type.includes('alipay')) return METHOD_ICONS.alipay if (type.includes('wxpay')) return METHOD_ICONS.wxpay + if (type === 'airwallex') return METHOD_ICONS.airwallex return METHOD_ICONS[type] || alipayIcon } @@ -86,6 +89,7 @@ function methodSelectedClass(type: string): string { if (type.includes('alipay')) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100' if (type.includes('wxpay')) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100' if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100' + if (type === 'airwallex') return 'border-[#FF6B3D] bg-orange-50 text-gray-900 shadow-sm dark:border-[#FF8E3C] dark:bg-orange-950 dark:text-gray-100' return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100' } diff --git a/frontend/src/components/payment/PaymentProviderDialog.vue b/frontend/src/components/payment/PaymentProviderDialog.vue index 0e03ec60..86304cf6 100644 --- a/frontend/src/components/payment/PaymentProviderDialog.vue +++ b/frontend/src/components/payment/PaymentProviderDialog.vue @@ -149,6 +149,12 @@ + +

+ {{ t(field.hintKey) }} +

@@ -177,14 +186,17 @@ - -
+ +

- {{ t('admin.settings.payment.stripeWebhookHint') }} + {{ t(providerWebhookHint) }}

- {{ stripeWebhookUrl }} + {{ providerWebhookUrl }} +

+ {{ t('admin.settings.payment.stripeWebhookApiVersionHint', { version: STRIPE_SDK_API_VERSION }) }} +

@@ -266,6 +278,7 @@ import { WEBHOOK_PATHS, PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP, + STRIPE_SDK_API_VERSION, getAvailableTypes, extractBaseUrl, } from './providerConfig' @@ -330,8 +343,18 @@ const visibleFields = reactive>({}) // --- Computed --- const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : '' -const stripeWebhookUrl = computed(() => - form.provider_key === 'stripe' ? defaultBaseUrl + WEBHOOK_PATHS.stripe : '', +const providerWebhookHintMap: Record = { + stripe: 'admin.settings.payment.stripeWebhookHint', + airwallex: 'admin.settings.payment.airwallexWebhookHint', +} + +const providerWebhookUrl = computed(() => { + const path = WEBHOOK_PATHS[form.provider_key] + return providerWebhookHintMap[form.provider_key] && path ? defaultBaseUrl + path : '' +}) + +const providerWebhookHint = computed(() => + providerWebhookHintMap[form.provider_key] || 'admin.settings.payment.stripeWebhookHint', ) const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null) @@ -415,6 +438,14 @@ const paymentGuide = computed(() => { } } + if (form.provider_key === 'airwallex') { + return { + summary: t('admin.settings.payment.airwallexGuideSummary'), + note: t('admin.settings.payment.airwallexGuideNote'), + items: [], + } + } + return null }) @@ -527,9 +558,19 @@ function handleSave() { } } + const clearableConfigKeys = new Set( + (PROVIDER_CONFIG_FIELDS[form.provider_key] || []) + .filter(field => field.clearable) + .map(field => field.key), + ) const filteredConfig: Record = {} for (const [k, v] of Object.entries(config)) { - if (!v || !v.trim()) continue + if (!v || !v.trim()) { + if (clearableConfigKeys.has(k)) { + filteredConfig[k] = '' + } + continue + } filteredConfig[k] = v } diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 2cdc9dce..2c8b0a93 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -22,11 +22,11 @@
{{ t('payment.orders.amount') }} - {{ paidOrder.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.toFixed(2) }} + {{ paidOrder.order_type === 'balance' ? '$' + paidOrder.amount.toFixed(2) : formatGatewayAmount(paidOrder.amount) }}
{{ t('payment.orders.payAmount') }} - ¥{{ paidOrder.pay_amount.toFixed(2) }} + {{ formatGatewayAmount(paidOrder.pay_amount) }}
@@ -129,6 +129,7 @@ import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' import { extractI18nErrorMessage } from '@/utils/apiError' import { getPaymentPopupFeatures } from '@/components/payment/providerConfig' +import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency' import type { PaymentOrder } from '@/types/payment' import Icon from '@/components/icons/Icon.vue' import QRCode from 'qrcode' @@ -142,13 +143,15 @@ const props = defineProps<{ paymentType: string payUrl?: string orderType?: string + currency?: string }>() type PaymentOutcome = 'success' | 'cancelled' | 'expired' const emit = defineEmits<{ done: []; success: []; settled: [outcome: PaymentOutcome] }>() -const { t } = useI18n() +const i18n = useI18n() +const { t } = i18n const paymentStore = usePaymentStore() const appStore = useAppStore() @@ -157,6 +160,15 @@ const qrUrl = ref('') const remainingSeconds = ref(0) const cancelling = ref(false) const paidOrder = ref(null) +const paymentCurrency = computed(() => normalizePaymentCurrency(props.currency)) +const localeCode = computed(() => { + const raw = i18n.locale as unknown + if (typeof raw === 'string') return raw + if (raw && typeof raw === 'object' && 'value' in raw) { + return String((raw as { value?: string }).value || '') + } + return undefined +}) // Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired' const outcome = ref(null) @@ -197,6 +209,10 @@ const countdownDisplay = computed(() => { return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0') }) +function formatGatewayAmount(value: number): string { + return formatPaymentAmount(value, paymentCurrency.value, localeCode.value) +} + function isSuccessStatus(status: string | null | undefined): boolean { return status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING' } diff --git a/frontend/src/components/payment/ProviderCard.vue b/frontend/src/components/payment/ProviderCard.vue index aecc8c8a..9a73c027 100644 --- a/frontend/src/components/payment/ProviderCard.vue +++ b/frontend/src/components/payment/ProviderCard.vue @@ -76,6 +76,7 @@ const PROVIDER_KEY_LABELS: Record = { alipay: 'admin.settings.payment.providerAlipay', wxpay: 'admin.settings.payment.providerWxpay', stripe: 'admin.settings.payment.providerStripe', + airwallex: 'admin.settings.payment.providerAirwallex', } const props = defineProps<{ diff --git a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts index 637d805f..099152d8 100644 --- a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts +++ b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts @@ -2,34 +2,66 @@ import { describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { nextTick } from 'vue' import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vue' +import { STRIPE_SDK_API_VERSION } from '@/components/payment/providerConfig' +import type { ProviderInstance } from '@/types/payment' const messages: Record = { 'admin.settings.payment.providerConfig': 'Credentials', 'admin.settings.payment.paymentGuideTrigger': 'View payment guide', 'admin.settings.payment.alipayGuideSummary': 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.', 'admin.settings.payment.wxpayGuideSummary': 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.', + 'admin.settings.payment.airwallexGuideSummary': 'Use Payment Acceptance read/write only.', + 'admin.settings.payment.stripeWebhookHint': 'Configure Stripe webhook.', + 'admin.settings.payment.stripeWebhookApiVersionHint': 'Use Stripe API version {version}.', + 'admin.settings.payment.airwallexWebhookHint': 'Select payment_intent.succeeded and use the latest stable API version.', } vi.mock('vue-i18n', () => ({ useI18n: () => ({ - t: (key: string) => messages[key] ?? key, + t: (key: string, params?: Record) => { + const message = messages[key] ?? key + if (!params) return message + return Object.entries(params).reduce( + (value, [name, replacement]) => value.replaceAll(`{${name}}`, replacement), + message, + ) + }, }), })) -function mountDialog() { +function providerFactory(overrides: Partial = {}): ProviderInstance { + return { + id: 1, + provider_key: 'airwallex', + name: 'Airwallex', + config: {}, + supported_types: ['airwallex'], + enabled: true, + payment_mode: '', + refund_enabled: false, + allow_user_refund: false, + limits: '', + sort_order: 0, + ...overrides, + } +} + +function mountDialog(options: { editing?: ProviderInstance | null } = {}) { return mount(PaymentProviderDialog, { props: { show: true, saving: false, - editing: null, + editing: options.editing ?? null, allKeyOptions: [ { value: 'alipay', label: 'Alipay' }, { value: 'wxpay', label: 'WeChat Pay' }, { value: 'stripe', label: 'Stripe' }, + { value: 'airwallex', label: 'Airwallex' }, ], enabledKeyOptions: [ { value: 'alipay', label: 'Alipay' }, { value: 'wxpay', label: 'WeChat Pay' }, + { value: 'airwallex', label: 'Airwallex' }, ], allPaymentTypes: [ { value: 'alipay', label: 'Alipay' }, @@ -66,6 +98,7 @@ describe('PaymentProviderDialog payment guide', () => { it.each([ ['alipay', 'admin.settings.payment.alipayGuideSummary'], ['wxpay', 'admin.settings.payment.wxpayGuideSummary'], + ['airwallex', 'admin.settings.payment.airwallexGuideSummary'], ])('shows the payment guide summary for %s', async (providerKey, summaryKey) => { const wrapper = mountDialog() @@ -75,4 +108,52 @@ describe('PaymentProviderDialog payment guide', () => { expect(wrapper.text()).toContain(messages[summaryKey]) expect(wrapper.find('button[title="View payment guide"]').exists()).toBe(true) }) + + it('shows Airwallex webhook event and API version guidance with the webhook URL', async () => { + const wrapper = mountDialog() + + ;(wrapper.vm as unknown as { reset: (key: string) => void }).reset('airwallex') + await nextTick() + + expect(wrapper.text()).toContain(messages['admin.settings.payment.airwallexWebhookHint']) + expect(wrapper.text()).toContain('/api/v1/payment/webhook/airwallex') + }) + + it('shows Stripe webhook API version guidance with the integrated SDK version', async () => { + const wrapper = mountDialog() + + ;(wrapper.vm as unknown as { reset: (key: string) => void }).reset('stripe') + await nextTick() + + expect(wrapper.text()).toContain(messages['admin.settings.payment.stripeWebhookHint']) + expect(wrapper.text()).toContain(`Use Stripe API version ${STRIPE_SDK_API_VERSION}.`) + expect(wrapper.text()).toContain('/api/v1/payment/webhook/stripe') + }) + + it('emits an empty Airwallex accountId when the admin clears it', async () => { + const provider = providerFactory({ + config: { + clientId: 'cid_123', + apiBase: 'https://api.airwallex.com/api/v1', + countryCode: 'CN', + currency: 'CNY', + accountId: 'acct_123', + }, + }) + const wrapper = mountDialog({ editing: provider }) + + ;(wrapper.vm as unknown as { loadProvider: (provider: ProviderInstance) => void }).loadProvider(provider) + await nextTick() + + const accountIdInput = wrapper + .findAll('input[type="text"]') + .find(input => (input.element as HTMLInputElement).value === 'acct_123') + if (!accountIdInput) throw new Error('accountId input not found') + + await accountIdInput.setValue('') + await wrapper.find('form').trigger('submit.prevent') + + const payload = wrapper.emitted('save')?.[0]?.[0] as { config: Record } + expect(payload.config.accountId).toBe('') + }) }) diff --git a/frontend/src/components/payment/__tests__/currency.spec.ts b/frontend/src/components/payment/__tests__/currency.spec.ts new file mode 100644 index 00000000..2f834303 --- /dev/null +++ b/frontend/src/components/payment/__tests__/currency.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { formatPaymentAmount } from '../currency' + +describe('formatPaymentAmount', () => { + it('uses the currency default fraction digits', () => { + expect(formatPaymentAmount(100, 'JPY', 'en-US')).not.toContain('.00') + expect(formatPaymentAmount(100, 'KRW', 'en-US')).not.toContain('.00') + expect(formatPaymentAmount(100, 'HKD', 'en-US')).toContain('.00') + }) +}) diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts index aebec8e5..e9530ff2 100644 --- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts +++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts @@ -38,12 +38,14 @@ describe('getVisibleMethods', () => { alipay_direct: methodLimit({ single_min: 5 }), wxpay: methodLimit({ single_max: 100 }), stripe: methodLimit({ fee_rate: 3 }), + airwallex: methodLimit({ single_min: 10 }), }) expect(visible).toEqual({ alipay: methodLimit({ single_min: 5 }), wxpay: methodLimit({ single_max: 100 }), stripe: methodLimit({ fee_rate: 3 }), + airwallex: methodLimit({ single_min: 10 }), }) }) @@ -104,6 +106,29 @@ describe('decidePaymentLaunch', () => { expect(decision.paymentState.orderType).toBe('subscription') }) + it('routes Airwallex client secrets through the hosted Airwallex page', () => { + const decision = decidePaymentLaunch(createOrderResult({ + client_secret: 'awx_cs', + intent_id: 'int_awx', + currency: 'CNY', + country_code: 'CN', + payment_env: 'demo', + out_trade_no: 'sub2_awx', + }), { + visibleMethod: 'airwallex', + orderType: 'balance', + isMobile: false, + airwallexRouteUrl: '/payment/airwallex?order_id=101', + }) + + expect(decision.kind).toBe('airwallex_route') + expect(decision.paymentState.payUrl).toBe('/payment/airwallex?order_id=101') + expect(decision.paymentState.intentId).toBe('int_awx') + expect(decision.paymentState.currency).toBe('CNY') + expect(decision.paymentState.countryCode).toBe('CN') + expect(decision.paymentState.paymentEnv).toBe('demo') + }) + it('keeps hosted redirect metadata for recovery flows', () => { const decision = decidePaymentLaunch(createOrderResult({ pay_url: 'https://pay.example.com/session/abc', @@ -248,6 +273,10 @@ describe('readPaymentRecoverySnapshot', () => { payUrl: 'https://pay.example.com/session/33', outTradeNo: 'sub2_33', clientSecret: '', + intentId: '', + currency: '', + countryCode: '', + paymentEnv: '', payAmount: 18, orderType: 'balance', paymentMode: 'popup', @@ -273,6 +302,10 @@ describe('readPaymentRecoverySnapshot', () => { payUrl: 'https://pay.example.com/session/55', outTradeNo: 'sub2_55', clientSecret: '', + intentId: '', + currency: '', + countryCode: '', + paymentEnv: '', payAmount: 18, orderType: 'balance', paymentMode: 'popup', @@ -317,4 +350,31 @@ describe('readPaymentRecoverySnapshot', () => { expect(restored?.orderId).toBe(44) expect(restored?.outTradeNo).toBe('') }) + + it('keeps backward compatibility with snapshots written before Airwallex fields existed', () => { + const restored = readPaymentRecoverySnapshot(JSON.stringify({ + orderId: 45, + amount: 28, + qrCode: '', + expiresAt: '2099-01-01T00:10:00.000Z', + paymentType: 'airwallex', + payUrl: '/payment/airwallex?order_id=45', + outTradeNo: 'sub2_45', + clientSecret: 'awx_cs', + payAmount: 28, + orderType: 'balance', + paymentMode: '', + resumeToken: 'resume-45', + createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), + }), { + now: Date.UTC(2099, 0, 1, 0, 1, 0), + resumeToken: 'resume-45', + }) + + expect(restored?.orderId).toBe(45) + expect(restored?.intentId).toBe('') + expect(restored?.currency).toBe('') + expect(restored?.countryCode).toBe('') + expect(restored?.paymentEnv).toBe('') + }) }) diff --git a/frontend/src/components/payment/__tests__/providerConfig.spec.ts b/frontend/src/components/payment/__tests__/providerConfig.spec.ts index ec63726f..bafc7cd7 100644 --- a/frontend/src/components/payment/__tests__/providerConfig.spec.ts +++ b/frontend/src/components/payment/__tests__/providerConfig.spec.ts @@ -1,20 +1,52 @@ import { describe, expect, it } from 'vitest' -import { PROVIDER_CONFIG_FIELDS } from '@/components/payment/providerConfig' +import { PAYMENT_CURRENCY_OPTIONS, PROVIDER_CONFIG_FIELDS } from '@/components/payment/providerConfig' -function findField(key: string) { - const fields = PROVIDER_CONFIG_FIELDS.wxpay || [] +function findField(providerKey: string, key: string) { + const fields = PROVIDER_CONFIG_FIELDS[providerKey] || [] return fields.find(field => field.key === key) } describe('PROVIDER_CONFIG_FIELDS.wxpay', () => { it('keeps admin form validation aligned with backend-required credentials', () => { - expect(findField('publicKeyId')?.optional).toBeFalsy() - expect(findField('certSerial')?.optional).toBeFalsy() + expect(findField('wxpay', 'publicKeyId')?.optional).toBeFalsy() + expect(findField('wxpay', 'certSerial')?.optional).toBeFalsy() }) it('only keeps the simplified visible credential set in the admin form', () => { - expect(findField('mpAppId')).toBeUndefined() - expect(findField('h5AppName')).toBeUndefined() - expect(findField('h5AppUrl')).toBeUndefined() + expect(findField('wxpay', 'mpAppId')).toBeUndefined() + expect(findField('wxpay', 'h5AppName')).toBeUndefined() + expect(findField('wxpay', 'h5AppUrl')).toBeUndefined() + }) +}) + +describe('PROVIDER_CONFIG_FIELDS.airwallex', () => { + it('adds currency config with CNY as the default', () => { + const currency = findField('airwallex', 'currency') + + expect(currency?.defaultValue).toBe('CNY') + expect(currency?.hintKey).toBe('admin.settings.payment.field_paymentCurrencyHint') + expect(currency?.options).toBe(PAYMENT_CURRENCY_OPTIONS) + }) + + it('marks accountId as optional and explains when it can be left blank', () => { + const accountId = findField('airwallex', 'accountId') + + expect(accountId?.optional).toBe(true) + expect(accountId?.clearable).toBe(true) + expect(accountId?.hintKey).toBe('admin.settings.payment.field_accountIdHint') + }) + + it('explains that apiBase must match the Airwallex key environment', () => { + expect(findField('airwallex', 'apiBase')?.hintKey).toBe('admin.settings.payment.field_airwallexApiBaseHint') + }) +}) + +describe('PROVIDER_CONFIG_FIELDS.stripe', () => { + it('adds currency config with CNY as the default', () => { + const currency = findField('stripe', 'currency') + + expect(currency?.defaultValue).toBe('CNY') + expect(currency?.hintKey).toBe('admin.settings.payment.field_paymentCurrencyHint') + expect(currency?.options).toBe(PAYMENT_CURRENCY_OPTIONS) }) }) diff --git a/frontend/src/components/payment/currency.ts b/frontend/src/components/payment/currency.ts new file mode 100644 index 00000000..5b37e7ac --- /dev/null +++ b/frontend/src/components/payment/currency.ts @@ -0,0 +1,33 @@ +export const DEFAULT_PAYMENT_CURRENCY = 'CNY' + +export function normalizePaymentCurrency(currency?: string | null): string { + const normalized = String(currency || '').trim().toUpperCase() + return /^[A-Z]{3}$/.test(normalized) ? normalized : DEFAULT_PAYMENT_CURRENCY +} + +function paymentCurrencyFractionDigits(currency: string): number { + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency, + }).resolvedOptions().maximumFractionDigits ?? 2 + } catch { + return 2 + } +} + +export function formatPaymentAmount(amount: number, currency?: string | null, locale?: string): string { + const normalized = normalizePaymentCurrency(currency) + const fractionDigits = paymentCurrencyFractionDigits(normalized) + try { + return new Intl.NumberFormat(locale || undefined, { + style: 'currency', + currency: normalized, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }).format(Number.isFinite(amount) ? amount : 0) + } catch { + return `${normalized} ${(Number.isFinite(amount) ? amount : 0).toFixed(fractionDigits)}` + } +} diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts index 318f3882..e66ef8e3 100644 --- a/frontend/src/components/payment/paymentFlow.ts +++ b/frontend/src/components/payment/paymentFlow.ts @@ -15,15 +15,17 @@ const VISIBLE_METHOD_ALIASES = { wxpay: 'wxpay', wxpay_direct: 'wxpay', stripe: 'stripe', + airwallex: 'airwallex', } as const -export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' +export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex' export type StripeVisibleMethod = 'alipay' | 'wechat_pay' export type PaymentLaunchKind = | 'qr_waiting' | 'redirect_waiting' | 'stripe_popup' | 'stripe_route' + | 'airwallex_route' | 'wechat_oauth' | 'wechat_jsapi' | 'unhandled' @@ -37,6 +39,10 @@ export interface PaymentRecoverySnapshot { payUrl: string outTradeNo: string clientSecret: string + intentId: string + currency: string + countryCode: string + paymentEnv: string payAmount: number orderType: OrderType | '' paymentMode: string @@ -52,6 +58,7 @@ export interface PaymentLaunchContext { now?: number stripePopupUrl?: string stripeRouteUrl?: string + airwallexRouteUrl?: string } export interface PaymentLaunchDecision { @@ -138,12 +145,24 @@ export function decidePaymentLaunch( payUrl: result.pay_url || '', outTradeNo: result.out_trade_no || '', clientSecret: result.client_secret || '', + intentId: result.intent_id || '', + currency: result.currency || '', + countryCode: result.country_code || '', + paymentEnv: result.payment_env || '', payAmount: result.pay_amount, orderType: context.orderType, paymentMode: (result.payment_mode || '').trim(), resumeToken: result.resume_token || '', }, context.now) + if (visibleMethod === 'airwallex' && baseState.clientSecret && baseState.intentId) { + if (!context.airwallexRouteUrl) { + return { kind: 'unhandled', paymentState: baseState, recovery: baseState } + } + const paymentState = { ...baseState, payUrl: context.airwallexRouteUrl || '' } + return { kind: 'airwallex_route', paymentState, recovery: paymentState } + } + if (baseState.clientSecret) { // visibleMethod === 'stripe' means the user clicked the dedicated Stripe button // and should land on the full Payment Element to choose a sub-method themselves. @@ -239,6 +258,10 @@ export function readPaymentRecoverySnapshot( || typeof parsed.payUrl !== 'string' || (parsed.outTradeNo != null && typeof parsed.outTradeNo !== 'string') || typeof parsed.clientSecret !== 'string' + || (parsed.intentId != null && typeof parsed.intentId !== 'string') + || (parsed.currency != null && typeof parsed.currency !== 'string') + || (parsed.countryCode != null && typeof parsed.countryCode !== 'string') + || (parsed.paymentEnv != null && typeof parsed.paymentEnv !== 'string') || typeof parsed.payAmount !== 'number' || typeof parsed.paymentMode !== 'string' || typeof parsed.resumeToken !== 'string' @@ -265,6 +288,10 @@ export function readPaymentRecoverySnapshot( payUrl: parsed.payUrl, outTradeNo: parsed.outTradeNo || '', clientSecret: parsed.clientSecret, + intentId: parsed.intentId || '', + currency: parsed.currency || '', + countryCode: parsed.countryCode || '', + paymentEnv: parsed.paymentEnv || '', payAmount: parsed.payAmount, orderType: parsed.orderType === 'subscription' ? 'subscription' : 'balance', paymentMode: parsed.paymentMode, diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts index f4f5acdc..9e69bf58 100644 --- a/frontend/src/components/payment/providerConfig.ts +++ b/frontend/src/components/payment/providerConfig.ts @@ -9,12 +9,16 @@ export interface ConfigFieldDef { label: string sensitive: boolean optional?: boolean + clearable?: boolean defaultValue?: string + hintKey?: string + options?: TypeOption[] } export interface TypeOption { value: string label: string + [key: string]: unknown } /** Callback URL paths for a provider. */ @@ -31,18 +35,36 @@ export const PROVIDER_SUPPORTED_TYPES: Record = { alipay: ['alipay'], wxpay: ['wxpay'], stripe: ['card', 'alipay', 'wxpay', 'link'], + airwallex: ['airwallex'], } /** Available payment modes for EasyPay providers. */ export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const /** Fixed display order for user-facing payment methods */ -export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe'] as const +export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex'] as const /** Payment mode constants */ export const PAYMENT_MODE_QRCODE = 'qrcode' export const PAYMENT_MODE_POPUP = 'popup' +export const PAYMENT_CURRENCY_OPTIONS: TypeOption[] = [ + { value: 'CNY', label: 'CNY' }, + { value: 'HKD', label: 'HKD' }, + { value: 'USD', label: 'USD' }, + { value: 'EUR', label: 'EUR' }, + { value: 'GBP', label: 'GBP' }, + { value: 'AUD', label: 'AUD' }, + { value: 'CAD', label: 'CAD' }, + { value: 'SGD', label: 'SGD' }, + { value: 'JPY', label: 'JPY' }, + { value: 'KRW', label: 'KRW' }, + { value: 'NZD', label: 'NZD' }, +] + +// 与后端当前集成的 stripe-go v85.0.0 的 stripe.APIVersion 保持一致。 +export const STRIPE_SDK_API_VERSION = '2026-03-25.dahlia' + /** Preferred popup size for payment gateways. Alipay's standard checkout * (QR + account login panel) needs ~1200×900 to render without any scrolling. */ const PAYMENT_POPUP_PREFERRED_WIDTH = 1250 @@ -68,6 +90,7 @@ export const WEBHOOK_PATHS: Record = { alipay: '/api/v1/payment/webhook/alipay', wxpay: '/api/v1/payment/webhook/wxpay', stripe: '/api/v1/payment/webhook/stripe', + airwallex: '/api/v1/payment/webhook/airwallex', } export const RETURN_PATH = '/payment/result' @@ -77,7 +100,8 @@ export const PROVIDER_CALLBACK_PATHS: Record = { easypay: { notifyUrl: WEBHOOK_PATHS.easypay, returnUrl: RETURN_PATH }, alipay: { notifyUrl: WEBHOOK_PATHS.alipay, returnUrl: RETURN_PATH }, wxpay: { notifyUrl: WEBHOOK_PATHS.wxpay }, - // stripe: no callback URL config needed (webhook is separate) + // stripe: 不需要回调 URL 配置,Webhook 单独配置。 + // airwallex: 不需要回调 URL 配置,Webhook 在空中云汇后台配置。 } /** Per-provider config fields (excludes notifyUrl/returnUrl which are handled separately). */ @@ -107,6 +131,16 @@ export const PROVIDER_CONFIG_FIELDS: Record = { { key: 'secretKey', label: '', sensitive: true }, { key: 'publishableKey', label: '', sensitive: false }, { key: 'webhookSecret', label: '', sensitive: true }, + { key: 'currency', label: '', sensitive: false, defaultValue: 'CNY', hintKey: 'admin.settings.payment.field_paymentCurrencyHint', options: PAYMENT_CURRENCY_OPTIONS }, + ], + airwallex: [ + { key: 'clientId', label: '', sensitive: false }, + { key: 'apiKey', label: '', sensitive: true }, + { key: 'webhookSecret', label: '', sensitive: true }, + { key: 'apiBase', label: '', sensitive: false, defaultValue: 'https://api.airwallex.com/api/v1', hintKey: 'admin.settings.payment.field_airwallexApiBaseHint' }, + { key: 'countryCode', label: '', sensitive: false, defaultValue: 'CN' }, + { key: 'currency', label: '', sensitive: false, defaultValue: 'CNY', hintKey: 'admin.settings.payment.field_paymentCurrencyHint', options: PAYMENT_CURRENCY_OPTIONS }, + { key: 'accountId', label: '', sensitive: false, optional: true, clearable: true, hintKey: 'admin.settings.payment.field_accountIdHint' }, ], } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d18a895c..70a8363d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5514,6 +5514,7 @@ export default { providerAlipay: 'Alipay (Direct)', providerWxpay: 'WeChat Pay (Direct)', providerStripe: 'Stripe', + providerAirwallex: 'Airwallex', typeDisabled: 'type disabled', enableTypesFirst: 'Enable at least one payment type above first', easypayRedirect: 'Redirect', @@ -5540,12 +5541,24 @@ export default { wxpayConfigHint: 'WeChat Pay usually only needs App ID. Fill MP App ID, H5 App Name, and H5 App URL only when your Official Account or H5 flow specifically requires them.', wxpayAdvancedOptions: 'WeChat Pay Advanced Options', field_secretKey: 'Secret Key', + field_clientId: 'Client ID', + field_apiKey: 'API Key', field_publishableKey: 'Publishable Key', field_webhookSecret: 'Webhook Secret', + field_countryCode: 'Country/region code', + field_currency: 'Payment currency', + field_accountId: 'Airwallex Account ID', + field_airwallexApiBaseHint: 'Must match the API key environment: use https://api-demo.airwallex.com/api/v1 for sandbox/demo keys, and https://api.airwallex.com/api/v1 for production keys. Mixed environments return credentials_invalid / Access Denied.', + field_paymentCurrencyHint: 'Default is CNY. Stripe and Airwallex can choose HKD, USD, or another listed currency supported by the account; WeChat Pay, Alipay, and EasyPay remain CNY.', + field_accountIdHint: 'Leave this empty unless you use multiple accounts, an organization-level key, or connected-account payments. A single-account scoped API key uses the selected account by default.', field_cid: 'Channel ID', field_cidAlipay: 'Alipay Channel ID', field_cidWxpay: 'WeChat Channel ID', stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:', + stripeWebhookApiVersionHint: 'Set this Webhook endpoint API version to match the integrated Stripe SDK. Recommended: {version}. A mismatch can cause webhook parsing errors.', + airwallexWebhookHint: 'Configure the following URL as a Webhook endpoint in Airwallex. Select at least Payment Intent -> Succeeded (payment_intent.succeeded), preferably also Payment Intent -> Cancelled (payment_intent.cancelled). Use the account default or latest stable API version.', + airwallexGuideSummary: 'When creating an Airwallex scoped API key, select Read and Write for Payment Acceptance under account-level permissions.', + airwallexGuideNote: 'Do not grant unrelated permissions such as Spend, Payouts, Transfers, Funds Splits, or POS Terminals unless you explicitly need them. For webhooks, select at least payment_intent.succeeded, preferably also payment_intent.cancelled, and use the account default or latest stable API version.', limitsTitle: 'Limits', limitSingleMin: 'Min per order', limitSingleMax: 'Max per order', @@ -6402,6 +6415,7 @@ export default { alipay: 'Alipay', wxpay: 'WeChat Pay', stripe: 'Stripe', + airwallex: 'Airwallex', card: 'Card', link: 'Link', alipay_direct: 'Alipay (Direct)', @@ -6487,6 +6501,8 @@ export default { stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.', stripeMissingParams: 'Missing order ID or client secret', stripeNotConfigured: 'Stripe is not configured', + airwallexLoadFailed: 'Failed to load Airwallex payment component. Please refresh and try again.', + airwallexMissingParams: 'Missing Airwallex payment parameters', errors: { tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.', cancelRateLimited: 'Too many cancellations. Please try again later.', @@ -6532,6 +6548,7 @@ export default { REFUND_AMOUNT_EXCEEDED: 'Refund amount exceeds the recharge amount.', REFUND_FAILED: 'Refund failed.', }, + airwallexPay: 'Airwallex Payment', stripePay: 'Pay Now', stripeSuccessProcessing: 'Payment successful, processing your order...', stripePopup: { diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4f473f94..b43f768c 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5675,6 +5675,7 @@ export default { providerAlipay: '支付宝官方', providerWxpay: '微信官方', providerStripe: 'Stripe', + providerAirwallex: 'Airwallex', typeDisabled: '类型已禁用', enableTypesFirst: '请先在上方启用至少一种服务商', easypayRedirect: '跳转', @@ -5701,12 +5702,24 @@ export default { wxpayConfigHint: '微信支付通常只需要填写 App ID。公众号 App ID、H5 应用名称、H5 应用地址仅在公众号支付或 H5 场景有特殊要求时再填写。', wxpayAdvancedOptions: '微信支付高级可选项', field_secretKey: '密钥', + field_clientId: 'Client ID', + field_apiKey: 'API Key', field_publishableKey: '公开密钥', field_webhookSecret: 'Webhook 密钥', + field_countryCode: '国家/地区代码', + field_currency: '支付币种', + field_accountId: 'Airwallex 账户 ID', + field_airwallexApiBaseHint: '必须和 API Key 所属环境一致:沙箱/测试密钥使用 https://api-demo.airwallex.com/api/v1,生产密钥使用 https://api.airwallex.com/api/v1。环境混用会返回 credentials_invalid / Access Denied。', + field_paymentCurrencyHint: '默认 CNY。Stripe 和 Airwallex 可按账户支持从下拉项选择 HKD、USD 等币种;微信、支付宝、易支付仍按 CNY。', + field_accountIdHint: '不涉及多账户、组织级密钥或连接账户收款时可以不填;单账户 Scoped API Key 会默认使用所选账户。', field_cid: '支付渠道 ID', field_cidAlipay: '支付宝渠道 ID', field_cidWxpay: '微信渠道 ID', stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:', + stripeWebhookApiVersionHint: 'Webhook 端点的 API 版本请与当前集成的 Stripe SDK 对齐,建议选择 {version};版本不一致可能导致回调事件解析失败。', + airwallexWebhookHint: '请在 Airwallex 后台将以下地址配置为 Webhook 端点;事件至少选择 Payment Intent -> Succeeded(payment_intent.succeeded),建议同时选择 Payment Intent -> Cancelled(payment_intent.cancelled);API version 选择账户默认或最新稳定版本。', + airwallexGuideSummary: '创建 Airwallex Scoped API 密钥时,建议只在账户级权限中为 Payment Acceptance 勾选读取和写入。', + airwallexGuideNote: '不需要勾选 Spend、Payouts、Transfers、Funds Splits、POS 终端等与在线收款无关的权限。Webhook 事件至少选择 payment_intent.succeeded,建议同时选择 payment_intent.cancelled;API version 选择账户默认或最新稳定版本。', limitsTitle: '限额配置', limitSingleMin: '单笔最低', limitSingleMax: '单笔最高', @@ -6587,6 +6600,7 @@ export default { alipay: '支付宝', wxpay: '微信支付', stripe: 'Stripe', + airwallex: 'Airwallex', card: '银行卡', link: 'Link', alipay_direct: '支付宝(直连)', @@ -6672,6 +6686,8 @@ export default { stripeLoadFailed: '支付组件加载失败,请刷新页面重试', stripeMissingParams: '缺少订单ID或支付密钥', stripeNotConfigured: 'Stripe 未配置', + airwallexLoadFailed: 'Airwallex 支付组件加载失败,请刷新页面重试', + airwallexMissingParams: '缺少 Airwallex 支付参数', errors: { tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单', cancelRateLimited: '取消订单过于频繁,请稍后再试', @@ -6717,6 +6733,7 @@ export default { REFUND_AMOUNT_EXCEEDED: '退款金额超过充值金额', REFUND_FAILED: '退款失败', }, + airwallexPay: 'Airwallex 支付', stripePay: '立即支付', stripeSuccessProcessing: '支付成功,正在处理订单...', stripePopup: { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9e7111a4..656421cc 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -316,6 +316,18 @@ const routes: RouteRecordRaw[] = [ requiresPayment: false } }, + { + path: '/payment/airwallex', + name: 'AirwallexPayment', + component: () => import('@/views/user/AirwallexPaymentView.vue'), + meta: { + requiresAuth: false, + requiresAdmin: false, + title: 'Airwallex Payment', + titleKey: 'payment.airwallexPay', + requiresPayment: false + } + }, { path: '/payment/stripe-popup', name: 'StripePopup', @@ -656,7 +668,7 @@ let authInitialized = false const navigationLoading = useNavigationLoadingState() // 延迟初始化预加载,传入 router 实例 let routePrefetch: ReturnType | null = null -const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/legal'] +const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/payment/airwallex', '/legal'] const BACKEND_MODE_CALLBACK_PATHS = [ '/auth/callback', '/auth/linuxdo/callback', diff --git a/frontend/src/style.css b/frontend/src/style.css index acff4abc..1bd13b5f 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -121,6 +121,13 @@ @apply dark:hover:bg-[#635bff]; } + .btn-airwallex { + @apply bg-[#14171A] text-white shadow-md shadow-emerald-500/20; + @apply hover:bg-[#20252a] hover:shadow-lg hover:shadow-emerald-500/25; + @apply dark:bg-[#7AF0C4] dark:text-gray-950 dark:shadow-emerald-500/20; + @apply dark:hover:bg-[#62d9ad]; + } + .btn-alipay { @apply bg-[#00AEEF] text-white shadow-md shadow-[#00AEEF]/25; @apply hover:bg-[#009dd6] hover:shadow-lg hover:shadow-[#00AEEF]/30; diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 77fbe689..816ae902 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -18,7 +18,7 @@ export type OrderStatus = | 'REFUNDED' | 'REFUND_FAILED' -export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay' +export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay' | 'airwallex' export type OrderType = 'balance' | 'subscription' @@ -40,6 +40,7 @@ export interface PaymentConfig { } export interface MethodLimit { + currency?: string daily_limit: number daily_used: number daily_remaining: number @@ -77,6 +78,7 @@ export interface PaymentOrder { user_id: number amount: number pay_amount: number + currency?: string fee_rate: number payment_type: string out_trade_no: string @@ -187,6 +189,10 @@ export interface CreateOrderResult { pay_url?: string qr_code?: string client_secret?: string + intent_id?: string + currency?: string + country_code?: string + payment_env?: string pay_amount: number fee_rate: number expires_at: string diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 26a88bc2..9c9cb455 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -8164,6 +8164,7 @@ const allPaymentTypes = computed(() => [ { value: "alipay", label: t("payment.methods.alipay") }, { value: "wxpay", label: t("payment.methods.wxpay") }, { value: "stripe", label: t("payment.methods.stripe") }, + { value: "airwallex", label: t("payment.methods.airwallex") }, ]); function isPaymentTypeEnabled(type: string): boolean { @@ -8220,6 +8221,7 @@ const providerKeyOptions = computed(() => [ { value: "alipay", label: t("admin.settings.payment.providerAlipay") }, { value: "wxpay", label: t("admin.settings.payment.providerWxpay") }, { value: "stripe", label: t("admin.settings.payment.providerStripe") }, + { value: "airwallex", label: t("admin.settings.payment.providerAirwallex") }, ]); const enabledProviderKeyOptions = computed(() => { diff --git a/frontend/src/views/admin/orders/AdminOrdersView.vue b/frontend/src/views/admin/orders/AdminOrdersView.vue index dd9fa7e6..6619e208 100644 --- a/frontend/src/views/admin/orders/AdminOrdersView.vue +++ b/frontend/src/views/admin/orders/AdminOrdersView.vue @@ -192,6 +192,7 @@ const paymentTypeFilterOptions = computed(() => [ { value: 'alipay', label: t('payment.methods.alipay') }, { value: 'wxpay', label: t('payment.methods.wxpay') }, { value: 'stripe', label: t('payment.methods.stripe') }, + { value: 'airwallex', label: t('payment.methods.airwallex') }, ]) const orderTypeFilterOptions = computed(() => [ diff --git a/frontend/src/views/user/AirwallexPaymentView.vue b/frontend/src/views/user/AirwallexPaymentView.vue new file mode 100644 index 00000000..db0d6903 --- /dev/null +++ b/frontend/src/views/user/AirwallexPaymentView.vue @@ -0,0 +1,133 @@ + + + diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index f8ed37d9..76e052d3 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -45,19 +45,19 @@
{{ t('payment.orders.baseAmount') }} - ¥{{ baseAmount.toFixed(2) }} + {{ formatGatewayAmount(baseAmount) }}
{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%) - ¥{{ feeAmount.toFixed(2) }} + {{ formatGatewayAmount(feeAmount) }}
{{ t('payment.orders.payAmount') }} - ¥{{ order.pay_amount.toFixed(2) }} + {{ formatGatewayAmount(order.pay_amount) }}
{{ t('payment.orders.creditedAmount') }} - {{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.amount.toFixed(2) }} + {{ order.order_type === 'balance' ? '$' + order.amount.toFixed(2) : formatGatewayAmount(order.amount) }}
{{ t('payment.orders.paymentMethod') }} @@ -78,7 +78,7 @@
{{ t('payment.orders.payAmount') }} - ¥{{ returnInfo.money }} + {{ formatGatewayAmount(Number(returnInfo.money) || 0) }}
{{ t('payment.orders.paymentMethod') }} @@ -109,15 +109,18 @@ import { import { usePaymentStore } from '@/stores/payment' import { paymentAPI } from '@/api/payment' import type { PaymentOrder } from '@/types/payment' +import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency' import { normalizePaymentMethodForDisplay, paymentMethodI18nKey } from './paymentUx' -const { t } = useI18n() +const i18n = useI18n() +const { t } = i18n const route = useRoute() const router = useRouter() const paymentStore = usePaymentStore() const order = ref(null) const loading = ref(true) +const currency = ref('CNY') interface ReturnInfo { outTradeNo: string @@ -147,6 +150,15 @@ const feeAmount = computed(() => { return Math.round((order.value.pay_amount - baseAmount.value) * 100) / 100 }) +const localeCode = computed(() => { + const raw = i18n.locale as unknown + if (typeof raw === 'string') return raw + if (raw && typeof raw === 'object' && 'value' in raw) { + return String((raw as { value?: string }).value || '') + } + return undefined +}) + const isSuccess = computed(() => { return isSuccessStatus(order.value?.status) }) @@ -169,6 +181,17 @@ function normalizedOrderPaymentType(paymentType: string): string { return normalizePaymentMethodForDisplay(paymentType) || paymentType } +function formatGatewayAmount(value: number): string { + return formatPaymentAmount(value, currency.value, localeCode.value) +} + +function setResolvedOrder(nextOrder: PaymentOrder | null): void { + order.value = nextOrder + if (nextOrder?.currency) { + currency.value = normalizePaymentCurrency(nextOrder.currency) + } +} + function normalizeOrderStatus(status: string | null | undefined): string { return String(status || '').trim().toUpperCase() } @@ -276,7 +299,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise refreshAttempts.value += 1 const refreshedOrder = await refreshOrder() if (refreshedOrder) { - order.value = refreshedOrder + setResolvedOrder(refreshedOrder) clearRecoverySnapshotForTerminalStatus(refreshedOrder.status) } @@ -301,6 +324,9 @@ onMounted(async () => { if (restored?.orderId) { orderId = restored.orderId } + if (restored?.currency) { + currency.value = normalizePaymentCurrency(restored.currency) + } if (!outTradeNo && restored?.outTradeNo) { outTradeNo = restored.outTradeNo } @@ -308,7 +334,7 @@ onMounted(async () => { if (resumeToken) { const resolvedOrder = await resolveOrderFromResumeToken(resumeToken) if (resolvedOrder) { - order.value = resolvedOrder + setResolvedOrder(resolvedOrder) if (!orderId) { orderId = resolvedOrder.id } @@ -327,7 +353,7 @@ onMounted(async () => { if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) { try { - order.value = await paymentStore.pollOrderStatus(orderId) + setResolvedOrder(await paymentStore.pollOrderStatus(orderId)) } catch (_err: unknown) { // Order lookup failed, will try legacy fallback below when possible. } @@ -336,7 +362,7 @@ onMounted(async () => { if (!order.value && shouldUsePublicOutTradeNo && (!resumeToken || resumeTokenLookupFailed)) { const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo) if (legacyOrder) { - order.value = legacyOrder + setResolvedOrder(legacyOrder) if (!orderId) { orderId = legacyOrder.id } diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 7bbdf708..3c7a85fc 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -21,6 +21,7 @@ :payment-type="paymentState.paymentType" :pay-url="paymentState.payUrl" :order-type="paymentState.orderType" + :currency="paymentState.currency || selectedCurrency" @done="onPaymentDone" @success="onPaymentSuccess" @settled="onPaymentSettled" @@ -60,15 +61,15 @@
{{ t('payment.paymentAmount') }} - ¥{{ validAmount.toFixed(2) }} + {{ formatSelectedPaymentAmount(validAmount) }}
{{ t('payment.fee') }} ({{ feeRate }}%) - ¥{{ feeAmount.toFixed(2) }} + {{ formatSelectedPaymentAmount(feeAmount) }}
{{ t('payment.actualPay') }} - ¥{{ totalAmount.toFixed(2) }} + {{ formatSelectedPaymentAmount(totalAmount) }}
{{ t('payment.creditedBalance') }} @@ -84,7 +85,7 @@ {{ t('common.processing') }} - {{ t('payment.createOrder') }} ¥{{ totalAmount.toFixed(2) }} + {{ t('payment.createOrder') }} {{ formatSelectedPaymentAmount(totalAmount) }} @@ -103,9 +104,9 @@
- ¥{{ selectedPlan.original_price }} + {{ formatSelectedPaymentAmount(selectedPlan.original_price) }} - ¥{{ selectedPlan.price }} + {{ formatSelectedPaymentAmount(selectedPlan.price) }} / {{ planValiditySuffix }}
@@ -149,15 +150,15 @@
{{ t('payment.amountLabel') }} - ¥{{ selectedPlan.price.toFixed(2) }} + {{ formatSelectedPaymentAmount(selectedPlan.price) }}
{{ t('payment.fee') }} ({{ feeRate }}%) - ¥{{ subFeeAmount.toFixed(2) }} + {{ formatSelectedPaymentAmount(subFeeAmount) }}
{{ t('payment.actualPay') }} - ¥{{ subTotalAmount.toFixed(2) }} + {{ formatSelectedPaymentAmount(subTotalAmount) }}
@@ -166,7 +167,7 @@ {{ t('common.processing') }} - {{ t('payment.createOrder') }} ¥{{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }} + {{ t('payment.createOrder') }} {{ formatSelectedPaymentAmount(feeRate > 0 ? subTotalAmount : selectedPlan.price) }} @@ -274,11 +275,13 @@ import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, pl import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue' import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue' import Icon from '@/components/icons/Icon.vue' +import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency' import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue' import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx' import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume' -const { t } = useI18n() +const i18n = useI18n() +const { t } = i18n const route = useRoute() const router = useRouter() const authStore = useAuthStore() @@ -332,6 +335,10 @@ function emptyPaymentState(): PaymentRecoverySnapshot { payUrl: '', outTradeNo: '', clientSecret: '', + intentId: '', + currency: '', + countryCode: '', + paymentEnv: '', payAmount: 0, orderType: '', paymentMode: '', @@ -523,6 +530,19 @@ const globalMaxAmount = computed(() => { // Selected method's limits (for validation and error messages) const selectedLimit = computed(() => visibleMethods.value[selectedMethod.value]) +const selectedCurrency = computed(() => normalizePaymentCurrency(selectedLimit.value?.currency)) +const localeCode = computed(() => { + const raw = i18n.locale as unknown + if (typeof raw === 'string') return raw + if (raw && typeof raw === 'object' && 'value' in raw) { + return String((raw as { value?: string }).value || '') + } + return undefined +}) + +function formatSelectedPaymentAmount(value: number): string { + return formatPaymentAmount(value, selectedCurrency.value, localeCode.value) +} const methodOptions = computed(() => enabledMethods.value.map((type) => { @@ -556,8 +576,8 @@ const amountError = computed(() => { // Selected method can't handle this amount (but others can) const ml = selectedLimit.value if (ml) { - if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: ml.single_min }) - if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: ml.single_max }) + if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: formatSelectedPaymentAmount(ml.single_min) }) + if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: formatSelectedPaymentAmount(ml.single_max) }) } return '' }) @@ -613,6 +633,7 @@ const paymentButtonClass = computed(() => { if (m.includes('alipay')) return 'btn-alipay' if (m.includes('wxpay')) return 'btn-wxpay' if (m === 'stripe') return 'btn-stripe' + if (m === 'airwallex') return 'btn-airwallex' return 'btn-primary' }) @@ -698,7 +719,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n const stripeMethod = visibleMethod === 'stripe' ? '' : visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay' - const stripeRouteUrl = result.client_secret + const stripeRouteUrl = result.client_secret && visibleMethod !== 'airwallex' ? router.resolve({ path: '/payment/stripe', query: { @@ -709,6 +730,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n }, }).href : '' + const airwallexRouteUrl = result.client_secret && result.intent_id + ? router.resolve({ + path: '/payment/airwallex', + query: { + order_id: String(result.order_id), + out_trade_no: result.out_trade_no || undefined, + resume_token: result.resume_token || undefined, + }, + }).href + : '' const decision = decidePaymentLaunch(result, { visibleMethod, orderType, @@ -716,6 +747,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent), stripePopupUrl: stripeRouteUrl, stripeRouteUrl, + airwallexRouteUrl, }) if (decision.kind === 'wechat_oauth' && decision.oauth?.authorize_url) { @@ -745,6 +777,10 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n window.location.href = decision.paymentState.payUrl return } + if (decision.kind === 'airwallex_route') { + window.location.href = decision.paymentState.payUrl + return + } if (decision.kind === 'wechat_jsapi' && decision.jsapi) { try { const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record) diff --git a/frontend/src/views/user/StripePaymentView.vue b/frontend/src/views/user/StripePaymentView.vue index 3f73d4d5..56a5bc2b 100644 --- a/frontend/src/views/user/StripePaymentView.vue +++ b/frontend/src/views/user/StripePaymentView.vue @@ -13,15 +13,15 @@