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) )