feat: add Airwallex payments and multi-currency support
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
118
backend/internal/payment/currency.go
Normal file
118
backend/internal/payment/currency.go
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
639
backend/internal/payment/provider/airwallex.go
Normal file
639
backend/internal/payment/provider/airwallex.go
Normal file
@@ -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 "<empty>"
|
||||
}
|
||||
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)
|
||||
)
|
||||
352
backend/internal/payment/provider/airwallex_test.go
Normal file
352
backend/internal/payment/provider/airwallex_test.go
Normal file
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user