Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,658 @@
|
||||
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) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
|
||||
refundID := strings.TrimSpace(req.RefundID)
|
||||
if refundID == "" {
|
||||
return nil, fmt.Errorf("airwallex query refund: missing refund id")
|
||||
}
|
||||
token, err := a.accessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("airwallex auth: %w", err)
|
||||
}
|
||||
var resp airwallexRefund
|
||||
if err := a.doJSON(ctx, http.MethodGet, "/pa/refunds/"+url.PathEscape(refundID), token, nil, &resp); err != nil {
|
||||
return nil, fmt.Errorf("airwallex query refund: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(resp.ID) == "" {
|
||||
resp.ID = refundID
|
||||
}
|
||||
return &payment.RefundResponse{RefundID: resp.ID, Status: airwallexRefundProviderStatus(resp.Status)}, 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)
|
||||
)
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
)
|
||||
|
||||
// Alipay product codes.
|
||||
const (
|
||||
alipayProductCodePreCreate = "FACE_TO_FACE_PAYMENT"
|
||||
alipayProductCodeWapPay = "QUICK_WAP_WAY"
|
||||
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
|
||||
)
|
||||
|
||||
// Alipay response constants.
|
||||
const (
|
||||
alipayFundChangeYes = "Y"
|
||||
alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
|
||||
alipayRefundSuffix = "-refund"
|
||||
)
|
||||
|
||||
var (
|
||||
alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
|
||||
return client.TradeWapPay(param)
|
||||
}
|
||||
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
return client.TradePreCreate(ctx, param)
|
||||
}
|
||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||
return client.TradePagePay(param)
|
||||
}
|
||||
)
|
||||
|
||||
// Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
|
||||
type Alipay struct {
|
||||
instanceID string
|
||||
config map[string]string // appId, privateKey, publicKey (or alipayPublicKey), notifyUrl, returnUrl
|
||||
|
||||
mu sync.Mutex
|
||||
client *alipay.Client
|
||||
}
|
||||
|
||||
// NewAlipay creates a new Alipay provider instance.
|
||||
func NewAlipay(instanceID string, config map[string]string) (*Alipay, error) {
|
||||
required := []string{"appId", "privateKey"}
|
||||
for _, k := range required {
|
||||
if config[k] == "" {
|
||||
return nil, fmt.Errorf("alipay config missing required key: %s", k)
|
||||
}
|
||||
}
|
||||
return &Alipay{
|
||||
instanceID: instanceID,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Alipay) getClient() (*alipay.Client, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.client != nil {
|
||||
return a.client, nil
|
||||
}
|
||||
client, err := alipay.New(a.config["appId"], a.config["privateKey"], true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay init client: %w", err)
|
||||
}
|
||||
pubKey := a.config["publicKey"]
|
||||
if pubKey == "" {
|
||||
pubKey = a.config["alipayPublicKey"]
|
||||
}
|
||||
if pubKey == "" {
|
||||
return nil, fmt.Errorf("alipay config missing required key: publicKey (or alipayPublicKey)")
|
||||
}
|
||||
if err := client.LoadAliPayPublicKey(pubKey); err != nil {
|
||||
return nil, fmt.Errorf("alipay load public key: %w", err)
|
||||
}
|
||||
a.client = client
|
||||
return a.client, nil
|
||||
}
|
||||
|
||||
func (a *Alipay) Name() string { return "Alipay" }
|
||||
func (a *Alipay) ProviderKey() string { return payment.TypeAlipay }
|
||||
func (a *Alipay) SupportedTypes() []payment.PaymentType {
|
||||
return []payment.PaymentType{payment.TypeAlipay}
|
||||
}
|
||||
|
||||
func (a *Alipay) MerchantIdentityMetadata() map[string]string {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
appID := strings.TrimSpace(a.config["appId"])
|
||||
if appID == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"app_id": appID}
|
||||
}
|
||||
|
||||
// CreatePayment creates an Alipay payment using the following routing:
|
||||
// - Mobile (H5), default: alipay.trade.wap.pay — browser redirect into Alipay.
|
||||
// - Mobile with AlipayMobilePrecreate: alipay.trade.precreate — return the
|
||||
// dynamic QR payload so the frontend can open it through the Alipay app.
|
||||
// - Desktop, default: prefer alipay.trade.precreate (FACE_TO_FACE_PAYMENT) to
|
||||
// get a scannable QR payload. If precreate is unavailable for the merchant,
|
||||
// fall back to alipay.trade.page.pay and expose pay_url only — the frontend
|
||||
// opens the Alipay checkout in a new tab.
|
||||
// - Desktop, paymentMode == "redirect": skip precreate and go straight to
|
||||
// alipay.trade.page.pay so the frontend always opens the Alipay checkout
|
||||
// in a new tab. Use this when the merchant has not enabled FACE_TO_FACE_PAYMENT.
|
||||
//
|
||||
// Note: alipay.trade.page.pay returns a checkout page URL, not a scannable
|
||||
// payment QR. Never expose it via the QRCode field.
|
||||
func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notifyURL := a.config["notifyUrl"]
|
||||
if req.NotifyURL != "" {
|
||||
notifyURL = req.NotifyURL
|
||||
}
|
||||
returnURL := a.config["returnUrl"]
|
||||
if req.ReturnURL != "" {
|
||||
returnURL = req.ReturnURL
|
||||
}
|
||||
|
||||
if req.IsMobile {
|
||||
if req.AlipayMobilePrecreate {
|
||||
return a.createPrecreateTrade(ctx, client, req, notifyURL)
|
||||
}
|
||||
return a.createWapTrade(client, req, notifyURL, returnURL)
|
||||
}
|
||||
return a.createDesktopTrade(ctx, client, req, notifyURL, returnURL)
|
||||
}
|
||||
|
||||
func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||
param := alipay.TradeWapPay{}
|
||||
param.OutTradeNo = req.OrderID
|
||||
param.TotalAmount = req.Amount
|
||||
param.Subject = req.Subject
|
||||
param.ProductCode = alipayProductCodeWapPay
|
||||
param.NotifyURL = notifyURL
|
||||
param.ReturnURL = returnURL
|
||||
|
||||
payURL, err := alipayTradeWapPay(client, param)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
|
||||
}
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
PayURL: payURL.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||
// Explicit redirect mode: merchant opted into "always open the Alipay
|
||||
// checkout page in a new tab" via the provider instance's payment_mode.
|
||||
// Skip precreate to avoid a wasted API call.
|
||||
if strings.EqualFold(strings.TrimSpace(a.config["paymentMode"]), "redirect") {
|
||||
return a.createPagePayTrade(client, req, notifyURL, returnURL)
|
||||
}
|
||||
|
||||
resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL)
|
||||
if precreateErr == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp, pagePayErr := a.createPagePayTrade(client, req, notifyURL, returnURL)
|
||||
if pagePayErr == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("alipay desktop payment failed: precreate=%v; pagepay=%w", precreateErr, pagePayErr)
|
||||
}
|
||||
|
||||
func (a *Alipay) createPrecreateTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL string) (*payment.CreatePaymentResponse, error) {
|
||||
param := alipay.TradePreCreate{}
|
||||
param.OutTradeNo = req.OrderID
|
||||
param.TotalAmount = req.Amount
|
||||
param.Subject = req.Subject
|
||||
param.ProductCode = alipayProductCodePreCreate
|
||||
param.NotifyURL = notifyURL
|
||||
|
||||
rsp, err := alipayTradePreCreate(ctx, client, param)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradePreCreate: %w", err)
|
||||
}
|
||||
if rsp == nil {
|
||||
return nil, fmt.Errorf("alipay TradePreCreate: empty response")
|
||||
}
|
||||
if rsp.IsFailure() {
|
||||
return nil, fmt.Errorf("alipay TradePreCreate failed: %s", rsp.Error.Error())
|
||||
}
|
||||
if strings.TrimSpace(rsp.QRCode) == "" {
|
||||
return nil, fmt.Errorf("alipay TradePreCreate: empty qr_code")
|
||||
}
|
||||
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
QRCode: rsp.QRCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||
param := alipay.TradePagePay{}
|
||||
param.OutTradeNo = req.OrderID
|
||||
param.TotalAmount = req.Amount
|
||||
param.Subject = req.Subject
|
||||
param.ProductCode = alipayProductCodePagePay
|
||||
param.NotifyURL = notifyURL
|
||||
param.ReturnURL = returnURL
|
||||
|
||||
payURL, err := alipayTradePagePay(client, param)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradePagePay: %w", err)
|
||||
}
|
||||
// Only PayURL is exposed: alipay.trade.page.pay returns a checkout page URL
|
||||
// that must be opened in a browser, not a scannable payment QR. Setting it
|
||||
// as QRCode would let the frontend render an unscannable image.
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
PayURL: payURL.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryOrder queries the trade status via Alipay.
|
||||
func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: tradeNo})
|
||||
if err != nil {
|
||||
if isTradeNotExist(err) {
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: tradeNo,
|
||||
Status: payment.ProviderStatusPending,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("alipay TradeQuery: %w", err)
|
||||
}
|
||||
|
||||
status := payment.ProviderStatusPending
|
||||
switch result.TradeStatus {
|
||||
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
|
||||
status = payment.ProviderStatusPaid
|
||||
case alipay.TradeStatusClosed:
|
||||
status = payment.ProviderStatusFailed
|
||||
}
|
||||
|
||||
amount, err := strconv.ParseFloat(result.TotalAmount, 64)
|
||||
if err != nil {
|
||||
amount, err = parseAlipayAmount(
|
||||
result.TotalAmount,
|
||||
result.ReceiptAmount,
|
||||
result.BuyerPayAmount,
|
||||
result.InvoiceAmount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay parse amount: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: result.TradeNo,
|
||||
Status: status,
|
||||
Amount: amount,
|
||||
PaidAt: result.SendPayDate,
|
||||
Metadata: a.MerchantIdentityMetadata(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyNotification decodes and verifies an Alipay async notification.
|
||||
func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values, err := url.ParseQuery(rawBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay parse notification: %w", err)
|
||||
}
|
||||
|
||||
notification, err := client.DecodeNotification(ctx, values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay verify notification: %w", err)
|
||||
}
|
||||
|
||||
status := payment.ProviderStatusFailed
|
||||
if notification.TradeStatus == alipay.TradeStatusSuccess || notification.TradeStatus == alipay.TradeStatusFinished {
|
||||
status = payment.ProviderStatusSuccess
|
||||
}
|
||||
|
||||
amount, err := strconv.ParseFloat(notification.TotalAmount, 64)
|
||||
if err != nil {
|
||||
amount, err = parseAlipayAmount(
|
||||
notification.TotalAmount,
|
||||
notification.ReceiptAmount,
|
||||
notification.BuyerPayAmount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay parse notification amount: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
metadata := a.MerchantIdentityMetadata()
|
||||
if appID := strings.TrimSpace(notification.AppId); appID != "" {
|
||||
if metadata == nil {
|
||||
metadata = map[string]string{}
|
||||
}
|
||||
metadata["app_id"] = appID
|
||||
}
|
||||
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: notification.TradeNo,
|
||||
OrderID: notification.OutTradeNo,
|
||||
Amount: amount,
|
||||
Status: status,
|
||||
RawData: rawBody,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refund requests a refund through Alipay.
|
||||
func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.TradeRefund(ctx, alipay.TradeRefund{
|
||||
OutTradeNo: req.OrderID,
|
||||
RefundAmount: req.Amount,
|
||||
RefundReason: req.Reason,
|
||||
OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradeRefund: %w", err)
|
||||
}
|
||||
|
||||
refundStatus := payment.ProviderStatusPending
|
||||
if result.FundChange == alipayFundChangeYes {
|
||||
refundStatus = payment.ProviderStatusSuccess
|
||||
}
|
||||
|
||||
refundID := result.TradeNo
|
||||
if refundID == "" {
|
||||
refundID = req.OrderID + alipayRefundSuffix
|
||||
}
|
||||
|
||||
return &payment.RefundResponse{
|
||||
RefundID: refundID,
|
||||
Status: refundStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelPayment closes a pending trade on Alipay.
|
||||
func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.TradeClose(ctx, alipay.TradeClose{OutTradeNo: tradeNo})
|
||||
if err != nil {
|
||||
if isTradeNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("alipay TradeClose: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTradeNotExist(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), alipayErrTradeNotExist)
|
||||
}
|
||||
|
||||
func parseAlipayAmount(values ...string) (float64, error) {
|
||||
for _, raw := range values {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
amount, err := strconv.ParseFloat(raw, 64)
|
||||
if err == nil {
|
||||
return amount, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no valid amount field")
|
||||
}
|
||||
|
||||
// Ensure interface compliance.
|
||||
var (
|
||||
_ payment.Provider = (*Alipay)(nil)
|
||||
_ payment.CancelableProvider = (*Alipay)(nil)
|
||||
_ payment.MerchantIdentityProvider = (*Alipay)(nil)
|
||||
)
|
||||
@@ -0,0 +1,446 @@
|
||||
//go:build unit
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
)
|
||||
|
||||
func TestIsTradeNotExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil error returns false",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "error containing ACQ.TRADE_NOT_EXIST returns true",
|
||||
err: errors.New("alipay: sub_code=ACQ.TRADE_NOT_EXIST, sub_msg=交易不存在"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "error not containing the code returns false",
|
||||
err: errors.New("alipay: sub_code=ACQ.SYSTEM_ERROR, sub_msg=系统错误"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "error with only partial match returns false",
|
||||
err: errors.New("ACQ.TRADE_NOT"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "error with exact constant value returns true",
|
||||
err: errors.New(alipayErrTradeNotExist),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := isTradeNotExist(tt.err)
|
||||
if got != tt.want {
|
||||
t.Errorf("isTradeNotExist(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAlipay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validConfig := map[string]string{
|
||||
"appId": "2021001234567890",
|
||||
"privateKey": "MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...",
|
||||
}
|
||||
|
||||
// helper to clone and override config fields
|
||||
withOverride := func(overrides map[string]string) map[string]string {
|
||||
cfg := make(map[string]string, len(validConfig))
|
||||
for k, v := range validConfig {
|
||||
cfg[k] = v
|
||||
}
|
||||
for k, v := range overrides {
|
||||
cfg[k] = v
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]string
|
||||
wantErr bool
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "valid config succeeds",
|
||||
config: validConfig,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing appId",
|
||||
config: withOverride(map[string]string{"appId": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "appId",
|
||||
},
|
||||
{
|
||||
name: "missing privateKey",
|
||||
config: withOverride(map[string]string{"privateKey": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "privateKey",
|
||||
},
|
||||
{
|
||||
name: "nil config map returns error for appId",
|
||||
config: map[string]string{},
|
||||
wantErr: true,
|
||||
errSubstr: "appId",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := NewAlipay("test-instance", tt.config)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil Alipay instance")
|
||||
}
|
||||
if got.instanceID != "test-instance" {
|
||||
t.Errorf("instanceID = %q, want %q", got.instanceID, "test-instance")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origPagePay := alipayTradePagePay
|
||||
origWapPay := alipayTradeWapPay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradePagePay = origPagePay
|
||||
alipayTradeWapPay = origWapPay
|
||||
})
|
||||
|
||||
preCreateCalls := 0
|
||||
pagePayCalls := 0
|
||||
wapPayCalls := 0
|
||||
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
preCreateCalls++
|
||||
return nil, errors.New("merchant does not have FACE_TO_FACE_PAYMENT")
|
||||
}
|
||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||
pagePayCalls++
|
||||
if param.OutTradeNo != "sub2_100" {
|
||||
t.Fatalf("out_trade_no = %q, want %q", param.OutTradeNo, "sub2_100")
|
||||
}
|
||||
if param.NotifyURL != "https://merchant.example.com/api/v1/payment/webhook/alipay" {
|
||||
t.Fatalf("notify_url = %q", param.NotifyURL)
|
||||
}
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
|
||||
}
|
||||
alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
|
||||
wapPayCalls++
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{}
|
||||
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_100",
|
||||
Amount: "88.00",
|
||||
Subject: "Balance recharge",
|
||||
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if preCreateCalls != 1 {
|
||||
t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
|
||||
}
|
||||
if pagePayCalls != 1 {
|
||||
t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
|
||||
}
|
||||
if wapPayCalls != 0 {
|
||||
t.Fatalf("wap pay calls = %d, want 0", wapPayCalls)
|
||||
}
|
||||
if resp.PayURL == "" {
|
||||
t.Fatal("expected pay_url for desktop page pay")
|
||||
}
|
||||
// page.pay returns a checkout page URL, not a scannable QR payload —
|
||||
// it must never be exposed via QRCode (the frontend would render an
|
||||
// unscannable image from it).
|
||||
if resp.QRCode != "" {
|
||||
t.Fatalf("qr_code = %q, want empty for page pay", resp.QRCode)
|
||||
}
|
||||
}
|
||||
|
||||
// When the provider instance is configured with paymentMode == "redirect",
|
||||
// the desktop flow must skip precreate and go straight to page.pay.
|
||||
func TestCreateTradeRedirectModeSkipsPrecreate(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origPagePay := alipayTradePagePay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradePagePay = origPagePay
|
||||
})
|
||||
|
||||
preCreateCalls := 0
|
||||
pagePayCalls := 0
|
||||
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
preCreateCalls++
|
||||
return &alipay.TradePreCreateRsp{
|
||||
Error: alipay.Error{Code: alipay.CodeSuccess},
|
||||
QRCode: "https://qr.alipay.example.com/precreate-token",
|
||||
}, nil
|
||||
}
|
||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||
pagePayCalls++
|
||||
if param.ProductCode != alipayProductCodePagePay {
|
||||
t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePagePay)
|
||||
}
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{
|
||||
config: map[string]string{"paymentMode": "redirect"},
|
||||
}
|
||||
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_103",
|
||||
Amount: "12.00",
|
||||
Subject: "Balance recharge",
|
||||
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if preCreateCalls != 0 {
|
||||
t.Fatalf("precreate calls = %d, want 0 (redirect mode must skip precreate)", preCreateCalls)
|
||||
}
|
||||
if pagePayCalls != 1 {
|
||||
t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
|
||||
}
|
||||
if resp.PayURL == "" {
|
||||
t.Fatal("expected pay_url for redirect mode")
|
||||
}
|
||||
if resp.QRCode != "" {
|
||||
t.Fatalf("qr_code = %q, want empty for redirect mode", resp.QRCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTradeUsesWapPayForMobile(t *testing.T) {
|
||||
origWapPay := alipayTradeWapPay
|
||||
t.Cleanup(func() {
|
||||
alipayTradeWapPay = origWapPay
|
||||
})
|
||||
|
||||
wapPayCalls := 0
|
||||
alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
|
||||
wapPayCalls++
|
||||
if param.ReturnURL != "https://merchant.example.com/payment/result" {
|
||||
t.Fatalf("return_url = %q", param.ReturnURL)
|
||||
}
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{}
|
||||
resp, err := provider.createWapTrade(&alipay.Client{}, payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_101",
|
||||
Amount: "18.00",
|
||||
Subject: "Balance recharge",
|
||||
IsMobile: true,
|
||||
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if wapPayCalls != 1 {
|
||||
t.Fatalf("wap pay calls = %d, want 1", wapPayCalls)
|
||||
}
|
||||
if resp.PayURL == "" {
|
||||
t.Fatal("expected pay_url for mobile wap pay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentUsesPrecreateForMobileWhenEnabled(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origWapPay := alipayTradeWapPay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradeWapPay = origWapPay
|
||||
})
|
||||
|
||||
precreateCalls := 0
|
||||
wapPayCalls := 0
|
||||
alipayTradePreCreate = func(_ context.Context, _ *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
precreateCalls++
|
||||
if param.OutTradeNo != "sub2_mobile_precreate" {
|
||||
t.Fatalf("out_trade_no = %q", param.OutTradeNo)
|
||||
}
|
||||
if param.ProductCode != alipayProductCodePreCreate {
|
||||
t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePreCreate)
|
||||
}
|
||||
return &alipay.TradePreCreateRsp{
|
||||
Error: alipay.Error{Code: alipay.CodeSuccess},
|
||||
QRCode: "https://qr.alipay.example.com/mobile-dynamic-token",
|
||||
}, nil
|
||||
}
|
||||
alipayTradeWapPay = func(_ *alipay.Client, _ alipay.TradeWapPay) (*url.URL, error) {
|
||||
wapPayCalls++
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{client: &alipay.Client{}, config: map[string]string{}}
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_mobile_precreate",
|
||||
Amount: "28.00",
|
||||
Subject: "Balance recharge",
|
||||
IsMobile: true,
|
||||
AlipayMobilePrecreate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if precreateCalls != 1 || wapPayCalls != 0 {
|
||||
t.Fatalf("precreate calls = %d, wap calls = %d; want 1, 0", precreateCalls, wapPayCalls)
|
||||
}
|
||||
if resp.QRCode != "https://qr.alipay.example.com/mobile-dynamic-token" || resp.PayURL != "" {
|
||||
t.Fatalf("unexpected response: qr_code=%q pay_url=%q", resp.QRCode, resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentKeepsWapPayForMobileWhenPrecreateDisabled(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origWapPay := alipayTradeWapPay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradeWapPay = origWapPay
|
||||
})
|
||||
|
||||
precreateCalls := 0
|
||||
wapPayCalls := 0
|
||||
alipayTradePreCreate = func(_ context.Context, _ *alipay.Client, _ alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
precreateCalls++
|
||||
return nil, errors.New("unexpected precreate call")
|
||||
}
|
||||
alipayTradeWapPay = func(_ *alipay.Client, _ alipay.TradeWapPay) (*url.URL, error) {
|
||||
wapPayCalls++
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{client: &alipay.Client{}, config: map[string]string{}}
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_mobile_wap",
|
||||
Amount: "18.00",
|
||||
Subject: "Balance recharge",
|
||||
IsMobile: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if precreateCalls != 0 || wapPayCalls != 1 {
|
||||
t.Fatalf("precreate calls = %d, wap calls = %d; want 0, 1", precreateCalls, wapPayCalls)
|
||||
}
|
||||
if resp.PayURL == "" || resp.QRCode != "" {
|
||||
t.Fatalf("unexpected response: qr_code=%q pay_url=%q", resp.QRCode, resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTradeUsesPrecreateForDesktopWhenAvailable(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origPagePay := alipayTradePagePay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradePagePay = origPagePay
|
||||
})
|
||||
|
||||
preCreateCalls := 0
|
||||
pagePayCalls := 0
|
||||
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
preCreateCalls++
|
||||
if param.ProductCode != alipayProductCodePreCreate {
|
||||
t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePreCreate)
|
||||
}
|
||||
return &alipay.TradePreCreateRsp{
|
||||
Error: alipay.Error{Code: alipay.CodeSuccess},
|
||||
QRCode: "https://qr.alipay.example.com/precreate-token",
|
||||
}, nil
|
||||
}
|
||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||
pagePayCalls++
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{}
|
||||
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_102",
|
||||
Amount: "66.00",
|
||||
Subject: "Balance recharge",
|
||||
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if preCreateCalls != 1 {
|
||||
t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
|
||||
}
|
||||
if pagePayCalls != 0 {
|
||||
t.Fatalf("page pay calls = %d, want 0", pagePayCalls)
|
||||
}
|
||||
if resp.QRCode != "https://qr.alipay.example.com/precreate-token" {
|
||||
t.Fatalf("qr_code = %q", resp.QRCode)
|
||||
}
|
||||
if resp.PayURL != "" {
|
||||
t.Fatalf("pay_url = %q, want empty for precreate", resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlipayMerchantIdentityMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &Alipay{
|
||||
config: map[string]string{
|
||||
"appId": "2021001234567890",
|
||||
},
|
||||
}
|
||||
|
||||
metadata := provider.MerchantIdentityMetadata()
|
||||
if metadata["app_id"] != "2021001234567890" {
|
||||
t.Fatalf("app_id = %q, want %q", metadata["app_id"], "2021001234567890")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAlipayAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
amount, err := parseAlipayAmount("", "88.00", "77.00")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if amount != 88 {
|
||||
t.Fatalf("amount = %v, want 88", amount)
|
||||
}
|
||||
|
||||
if _, err := parseAlipayAmount("", "not-a-number"); err == nil {
|
||||
t.Fatal("expected error when no valid amount field exists")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
// Package provider contains concrete payment provider implementations.
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
// EasyPay constants.
|
||||
const (
|
||||
easypayCodeSuccess = 1
|
||||
easypayStatusPaid = 1
|
||||
easypayHTTPTimeout = 10 * time.Second
|
||||
maxEasypayResponseSize = 1 << 20 // 1MB
|
||||
maxEasypayErrorSummary = 512
|
||||
tradeStatusSuccess = "TRADE_SUCCESS"
|
||||
signTypeMD5 = "MD5"
|
||||
paymentModePopup = "popup"
|
||||
deviceMobile = "mobile"
|
||||
)
|
||||
|
||||
// EasyPay implements payment.Provider for the EasyPay aggregation platform.
|
||||
type EasyPay struct {
|
||||
instanceID string
|
||||
config map[string]string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type easyPayCustomMethod struct {
|
||||
Type string `json:"type"`
|
||||
UpstreamType string `json:"upstreamType"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
// NewEasyPay creates a new EasyPay provider.
|
||||
// config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay
|
||||
func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) {
|
||||
for _, k := range []string{"pid", "pkey", "apiBase", "notifyUrl", "returnUrl"} {
|
||||
if strings.TrimSpace(config[k]) == "" {
|
||||
return nil, fmt.Errorf("easypay config missing required key: %s", k)
|
||||
}
|
||||
}
|
||||
cfg := make(map[string]string, len(config))
|
||||
for k, v := range config {
|
||||
cfg[k] = v
|
||||
}
|
||||
cfg["apiBase"] = normalizeEasyPayAPIBase(cfg["apiBase"])
|
||||
return &EasyPay{
|
||||
instanceID: instanceID,
|
||||
config: cfg,
|
||||
httpClient: &http.Client{Timeout: easypayHTTPTimeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeEasyPayAPIBase(apiBase string) string {
|
||||
base := strings.TrimSpace(apiBase)
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := url.Parse(base); err == nil && parsed.Scheme != "" && parsed.Host != "" {
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.RawPath = ""
|
||||
parsed.Path = trimEasyPayEndpointPath(parsed.Path)
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
return strings.TrimRight(trimEasyPayEndpointPath(base), "/")
|
||||
}
|
||||
|
||||
func trimEasyPayEndpointPath(path string) string {
|
||||
path = strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
lower := strings.ToLower(path)
|
||||
for _, endpoint := range []string{"/submit.php", "/mapi.php", "/api.php"} {
|
||||
if strings.HasSuffix(lower, endpoint) {
|
||||
return strings.TrimRight(path[:len(path)-len(endpoint)], "/")
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (e *EasyPay) apiBase() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeEasyPayAPIBase(e.config["apiBase"])
|
||||
}
|
||||
|
||||
func (e *EasyPay) Name() string { return "EasyPay" }
|
||||
func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay }
|
||||
func (e *EasyPay) SupportedTypes() []payment.PaymentType {
|
||||
types := []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
|
||||
for _, method := range e.customMethods() {
|
||||
if method.Type != "" {
|
||||
types = append(types, method.Type)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func (e *EasyPay) MerchantIdentityMetadata() map[string]string {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
pid := strings.TrimSpace(e.config["pid"])
|
||||
if pid == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"pid": pid}
|
||||
}
|
||||
|
||||
func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
// Payment mode determined by instance config, not payment type.
|
||||
// "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
|
||||
mode := e.config["paymentMode"]
|
||||
if mode == paymentModePopup {
|
||||
return e.createRedirectPayment(req)
|
||||
}
|
||||
return e.createAPIPayment(ctx, req)
|
||||
}
|
||||
|
||||
// createRedirectPayment builds a submit.php URL for browser redirect.
|
||||
// No server-side API call — the user is redirected to EasyPay's hosted page.
|
||||
// TradeNo is empty; it arrives via the notify callback after payment.
|
||||
func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
notifyURL, returnURL := e.resolveURLs(req)
|
||||
paymentType := e.upstreamPaymentType(req.PaymentType)
|
||||
params := map[string]string{
|
||||
"pid": e.config["pid"], "type": paymentType,
|
||||
"out_trade_no": req.OrderID, "notify_url": notifyURL,
|
||||
"return_url": returnURL, "name": req.Subject,
|
||||
"money": req.Amount,
|
||||
}
|
||||
if cid := e.resolveCID(paymentType); cid != "" {
|
||||
params["cid"] = cid
|
||||
}
|
||||
if req.IsMobile {
|
||||
params["device"] = deviceMobile
|
||||
}
|
||||
params["sign"] = easyPaySign(params, e.config["pkey"])
|
||||
params["sign_type"] = signTypeMD5
|
||||
|
||||
q := url.Values{}
|
||||
for k, v := range params {
|
||||
q.Set(k, v)
|
||||
}
|
||||
payURL := e.apiBase() + "/submit.php?" + q.Encode()
|
||||
return &payment.CreatePaymentResponse{PayURL: payURL}, nil
|
||||
}
|
||||
|
||||
// createAPIPayment calls mapi.php to get payurl/qrcode (existing behavior).
|
||||
func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
notifyURL, returnURL := e.resolveURLs(req)
|
||||
paymentType := e.upstreamPaymentType(req.PaymentType)
|
||||
params := map[string]string{
|
||||
"pid": e.config["pid"], "type": paymentType,
|
||||
"out_trade_no": req.OrderID, "notify_url": notifyURL,
|
||||
"return_url": returnURL, "name": req.Subject,
|
||||
"money": req.Amount, "clientip": req.ClientIP,
|
||||
}
|
||||
if cid := e.resolveCID(paymentType); cid != "" {
|
||||
params["cid"] = cid
|
||||
}
|
||||
if req.IsMobile {
|
||||
params["device"] = deviceMobile
|
||||
}
|
||||
params["sign"] = easyPaySign(params, e.config["pkey"])
|
||||
params["sign_type"] = signTypeMD5
|
||||
|
||||
body, err := e.post(ctx, e.apiBase()+"/mapi.php", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("easypay create: %w", err)
|
||||
}
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TradeNo string `json:"trade_no"`
|
||||
PayURL string `json:"payurl"`
|
||||
PayURL2 string `json:"payurl2"` // H5 mobile payment URL
|
||||
QRCode string `json:"qrcode"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("easypay parse: %w", err)
|
||||
}
|
||||
if resp.Code != easypayCodeSuccess {
|
||||
return nil, fmt.Errorf("easypay error: %s", resp.Msg)
|
||||
}
|
||||
payURL := resp.PayURL
|
||||
if req.IsMobile && resp.PayURL2 != "" {
|
||||
payURL = resp.PayURL2
|
||||
}
|
||||
return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: payURL, QRCode: resp.QRCode}, nil
|
||||
}
|
||||
|
||||
// resolveURLs returns (notifyURL, returnURL) preferring request values,
|
||||
// falling back to instance config.
|
||||
func (e *EasyPay) resolveURLs(req payment.CreatePaymentRequest) (string, string) {
|
||||
notifyURL := req.NotifyURL
|
||||
if notifyURL == "" {
|
||||
notifyURL = e.config["notifyUrl"]
|
||||
}
|
||||
returnURL := req.ReturnURL
|
||||
if returnURL == "" {
|
||||
returnURL = e.config["returnUrl"]
|
||||
}
|
||||
return notifyURL, returnURL
|
||||
}
|
||||
|
||||
func (e *EasyPay) customMethods() []easyPayCustomMethod {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
raw := strings.TrimSpace(e.config["customMethods"])
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var methods []easyPayCustomMethod
|
||||
if err := json.Unmarshal([]byte(raw), &methods); err != nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]easyPayCustomMethod, 0, len(methods))
|
||||
for _, method := range methods {
|
||||
method.Type = strings.TrimSpace(method.Type)
|
||||
method.UpstreamType = strings.TrimSpace(method.UpstreamType)
|
||||
method.DisplayName = strings.TrimSpace(method.DisplayName)
|
||||
if method.Type == "" || method.UpstreamType == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, method)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *EasyPay) upstreamPaymentType(paymentType string) string {
|
||||
paymentType = strings.TrimSpace(paymentType)
|
||||
for _, method := range e.customMethods() {
|
||||
if paymentType == method.Type {
|
||||
return method.UpstreamType
|
||||
}
|
||||
}
|
||||
return paymentType
|
||||
}
|
||||
|
||||
func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
params := map[string]string{
|
||||
"act": "order", "pid": e.config["pid"],
|
||||
"key": e.config["pkey"], "out_trade_no": tradeNo,
|
||||
}
|
||||
body, err := e.post(ctx, e.apiBase()+"/api.php", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("easypay query: %w", err)
|
||||
}
|
||||
type easyPayQueryData struct {
|
||||
TradeStatus *string `json:"trade_status"`
|
||||
Status *int `json:"status"`
|
||||
Money *string `json:"money"`
|
||||
TradeNo *string `json:"trade_no"`
|
||||
}
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TradeStatus *string `json:"trade_status"`
|
||||
Status *int `json:"status"`
|
||||
Money *string `json:"money"`
|
||||
TradeNo *string `json:"trade_no"`
|
||||
Data easyPayQueryData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("easypay parse query: %w", err)
|
||||
}
|
||||
status := payment.ProviderStatusPending
|
||||
if resp.TradeStatus != nil {
|
||||
if *resp.TradeStatus == tradeStatusSuccess {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
} else if resp.Data.TradeStatus != nil {
|
||||
if *resp.Data.TradeStatus == tradeStatusSuccess {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
} else if resp.Status != nil {
|
||||
if *resp.Status == easypayStatusPaid {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
} else if resp.Data.Status != nil && *resp.Data.Status == easypayStatusPaid {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
|
||||
money := ""
|
||||
if resp.Money != nil {
|
||||
money = *resp.Money
|
||||
} else if resp.Data.Money != nil {
|
||||
money = *resp.Data.Money
|
||||
}
|
||||
responseTradeNo := tradeNo
|
||||
if resp.TradeNo != nil {
|
||||
if *resp.TradeNo != "" {
|
||||
responseTradeNo = *resp.TradeNo
|
||||
}
|
||||
} else if resp.Data.TradeNo != nil && *resp.Data.TradeNo != "" {
|
||||
responseTradeNo = *resp.Data.TradeNo
|
||||
}
|
||||
|
||||
amount, _ := strconv.ParseFloat(money, 64)
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: responseTradeNo,
|
||||
Status: status,
|
||||
Amount: amount,
|
||||
Metadata: e.MerchantIdentityMetadata(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
|
||||
values, err := url.ParseQuery(rawBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse notify: %w", err)
|
||||
}
|
||||
// url.ParseQuery already decodes values — no additional decode needed.
|
||||
params := make(map[string]string)
|
||||
for k := range values {
|
||||
params[k] = values.Get(k)
|
||||
}
|
||||
sign := params["sign"]
|
||||
if sign == "" {
|
||||
return nil, fmt.Errorf("missing sign")
|
||||
}
|
||||
if !easyPayVerifySign(params, e.config["pkey"], sign) {
|
||||
return nil, fmt.Errorf("invalid signature")
|
||||
}
|
||||
status := payment.ProviderStatusFailed
|
||||
if params["trade_status"] == tradeStatusSuccess {
|
||||
status = payment.ProviderStatusSuccess
|
||||
}
|
||||
amount, _ := strconv.ParseFloat(params["money"], 64)
|
||||
|
||||
metadata := e.MerchantIdentityMetadata()
|
||||
if pid := strings.TrimSpace(params["pid"]); pid != "" {
|
||||
if metadata == nil {
|
||||
metadata = map[string]string{}
|
||||
}
|
||||
metadata["pid"] = pid
|
||||
}
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: params["trade_no"], OrderID: params["out_trade_no"],
|
||||
Amount: amount, Status: status, RawData: rawBody, Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *EasyPay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
attempts := e.refundAttempts(req)
|
||||
if len(attempts) == 0 {
|
||||
return nil, fmt.Errorf("easypay refund missing order identifier")
|
||||
}
|
||||
var firstErr error
|
||||
for i, attempt := range attempts {
|
||||
body, status, err := e.postRaw(ctx, e.apiBase()+"/api.php?act=refund", attempt.params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("easypay refund request: %w", err)
|
||||
}
|
||||
if err := parseEasyPayRefundResponse(status, body); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if i+1 < len(attempts) && isEasyPayRefundOrderNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &payment.RefundResponse{RefundID: attempt.refundID, Status: payment.ProviderStatusSuccess}, nil
|
||||
}
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
type easyPayRefundAttempt struct {
|
||||
params map[string]string
|
||||
refundID string
|
||||
}
|
||||
|
||||
func (e *EasyPay) refundAttempts(req payment.RefundRequest) []easyPayRefundAttempt {
|
||||
base := map[string]string{
|
||||
"pid": e.config["pid"], "key": e.config["pkey"], "money": req.Amount,
|
||||
}
|
||||
var attempts []easyPayRefundAttempt
|
||||
if orderID := strings.TrimSpace(req.OrderID); orderID != "" {
|
||||
params := cloneStringMap(base)
|
||||
params["out_trade_no"] = orderID
|
||||
attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: orderID})
|
||||
}
|
||||
if tradeNo := strings.TrimSpace(req.TradeNo); tradeNo != "" {
|
||||
params := cloneStringMap(base)
|
||||
params["trade_no"] = tradeNo
|
||||
attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: tradeNo})
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isEasyPayRefundOrderNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
lower := strings.ToLower(msg)
|
||||
return strings.Contains(msg, "订单编号不存在") ||
|
||||
strings.Contains(msg, "订单不存在") ||
|
||||
strings.Contains(lower, "order not found") ||
|
||||
strings.Contains(lower, "not exist")
|
||||
}
|
||||
|
||||
func parseEasyPayRefundResponse(status int, body []byte) error {
|
||||
summary := summarizeEasyPayResponse(body)
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
||||
return fmt.Errorf("easypay refund HTTP %d: %s", status, summary)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("easypay refund empty response (HTTP %d): %s", status, summary)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") ||
|
||||
(strings.HasPrefix(lower, "<") && strings.Contains(lower, "html")) {
|
||||
return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code any `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
|
||||
}
|
||||
if !easyPayResponseCodeIsSuccess(resp.Code) {
|
||||
msg := strings.TrimSpace(resp.Msg)
|
||||
if msg == "" {
|
||||
msg = summary
|
||||
}
|
||||
return fmt.Errorf("easypay refund failed (HTTP %d): %s", status, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func easyPayResponseCodeIsSuccess(code any) bool {
|
||||
switch v := code.(type) {
|
||||
case float64:
|
||||
return int(v) == easypayCodeSuccess
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
return err == nil && n == easypayCodeSuccess
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeEasyPayResponse(body []byte) string {
|
||||
summary := strings.Join(strings.Fields(string(body)), " ")
|
||||
if summary == "" {
|
||||
return "<empty>"
|
||||
}
|
||||
if len(summary) > maxEasypayErrorSummary {
|
||||
truncated := summary[:maxEasypayErrorSummary]
|
||||
for len(truncated) > 0 && !utf8.ValidString(truncated) {
|
||||
truncated = truncated[:len(truncated)-1]
|
||||
}
|
||||
return truncated + "..."
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (e *EasyPay) resolveCID(paymentType string) string {
|
||||
if strings.HasPrefix(paymentType, "alipay") {
|
||||
if v := e.config["cidAlipay"]; v != "" {
|
||||
return v
|
||||
}
|
||||
return e.config["cid"]
|
||||
}
|
||||
if v := e.config["cidWxpay"]; v != "" {
|
||||
return v
|
||||
}
|
||||
return e.config["cid"]
|
||||
}
|
||||
|
||||
func (e *EasyPay) post(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) {
|
||||
body, _, err := e.postRaw(ctx, endpoint, params)
|
||||
return body, err
|
||||
}
|
||||
|
||||
func (e *EasyPay) postRaw(ctx context.Context, endpoint string, params map[string]string) ([]byte, int, error) {
|
||||
form := url.Values{}
|
||||
for k, v := range params {
|
||||
form.Set(k, v)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
client := e.httpClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: easypayHTTPTimeout}
|
||||
}
|
||||
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, maxEasypayResponseSize))
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func easyPaySign(params map[string]string, pkey string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k, v := range params {
|
||||
if k == "sign" || k == "sign_type" || v == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var buf strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
_ = buf.WriteByte('&')
|
||||
}
|
||||
_, _ = buf.WriteString(k + "=" + params[k])
|
||||
}
|
||||
_, _ = buf.WriteString(pkey)
|
||||
hash := md5.Sum([]byte(buf.String()))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func easyPayVerifySign(params map[string]string, pkey string, sign string) bool {
|
||||
return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const orderID = "order-123"
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantStatus string
|
||||
wantTradeNo string
|
||||
wantAmount float64
|
||||
}{
|
||||
{
|
||||
name: "top level trade success is paid",
|
||||
body: `{"code":1,"trade_status":"TRADE_SUCCESS","status":0,"money":"12.34","trade_no":"gateway-123"}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: "gateway-123",
|
||||
wantAmount: 12.34,
|
||||
},
|
||||
{
|
||||
name: "waiting trade status with paid numeric status stays pending",
|
||||
body: `{"code":1,"trade_status":"WAITING","status":1,"money":"12.34","trade_no":"gateway-123"}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: "gateway-123",
|
||||
wantAmount: 12.34,
|
||||
},
|
||||
{
|
||||
name: "empty trade status with paid numeric status stays pending",
|
||||
body: `{"code":1,"trade_status":"","status":1,"money":"12.34"}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: orderID,
|
||||
wantAmount: 12.34,
|
||||
},
|
||||
{
|
||||
name: "nested data trade success is paid",
|
||||
body: `{"code":1,"data":{"trade_status":"TRADE_SUCCESS","status":0,"money":"9.99","trade_no":"data-456"}}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: "data-456",
|
||||
wantAmount: 9.99,
|
||||
},
|
||||
{
|
||||
name: "legacy numeric paid status remains compatible",
|
||||
body: `{"code":1,"status":1,"money":"3.21"}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: orderID,
|
||||
wantAmount: 3.21,
|
||||
},
|
||||
{
|
||||
name: "legacy numeric non paid status is pending",
|
||||
body: `{"code":1,"status":0,"money":"3.21"}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: orderID,
|
||||
wantAmount: 3.21,
|
||||
},
|
||||
{
|
||||
name: "query failure with missing status is pending",
|
||||
body: `{"code":0,"msg":"订单不存在"}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: orderID,
|
||||
},
|
||||
{
|
||||
name: "missing fields are pending",
|
||||
body: `{}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: orderID,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotForm url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want %q", r.Method, http.MethodPost)
|
||||
}
|
||||
if r.URL.Path != "/api.php" {
|
||||
t.Errorf("path = %q, want /api.php", r.URL.Path)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("ParseForm: %v", err)
|
||||
}
|
||||
gotForm = make(url.Values, len(r.PostForm))
|
||||
for key, values := range r.PostForm {
|
||||
gotForm[key] = append([]string(nil), values...)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newTestEasyPay(t, server.URL)
|
||||
resp, err := provider.QueryOrder(context.Background(), orderID)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryOrder returned error: %v", err)
|
||||
}
|
||||
if resp.Status != tt.wantStatus {
|
||||
t.Fatalf("status = %q, want %q (response=%+v)", resp.Status, tt.wantStatus, resp)
|
||||
}
|
||||
if resp.TradeNo != tt.wantTradeNo {
|
||||
t.Fatalf("trade_no = %q, want %q", resp.TradeNo, tt.wantTradeNo)
|
||||
}
|
||||
if resp.Amount != tt.wantAmount {
|
||||
t.Fatalf("amount = %v, want %v", resp.Amount, tt.wantAmount)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"act": "order",
|
||||
"pid": "pid-1",
|
||||
"key": "pkey-1",
|
||||
"out_trade_no": orderID,
|
||||
} {
|
||||
if got := gotForm.Get(key); got != want {
|
||||
t.Fatalf("form[%s] = %q, want %q (form=%v)", key, got, want, gotForm)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
func TestNormalizeEasyPayAPIBase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "https://zpayz.cn", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/mapi.php", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/submit.php", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/api.php", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/api.php?act=refund", want: "https://zpayz.cn"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := normalizeEasyPayAPIBase(tt.input); got != tt.want {
|
||||
t.Fatalf("normalizeEasyPayAPIBase(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayRefundNormalizesAPIBaseAndSendsOutTradeNoOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotPath string
|
||||
var gotQuery url.Values
|
||||
var gotForm url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotQuery = r.URL.Query()
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("ParseForm: %v", err)
|
||||
}
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":1,"msg":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newTestEasyPay(t, server.URL+"/mapi.php")
|
||||
resp, err := provider.Refund(context.Background(), payment.RefundRequest{
|
||||
TradeNo: "trade-123",
|
||||
OrderID: "out-456",
|
||||
Amount: "1.50",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Refund returned error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.Status != payment.ProviderStatusSuccess {
|
||||
t.Fatalf("Refund response = %+v, want success", resp)
|
||||
}
|
||||
if gotPath != "/api.php" {
|
||||
t.Fatalf("refund path = %q, want /api.php", gotPath)
|
||||
}
|
||||
if gotQuery.Get("act") != "refund" {
|
||||
t.Fatalf("refund act query = %q, want refund", gotQuery.Get("act"))
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"pid": "pid-1",
|
||||
"key": "pkey-1",
|
||||
"out_trade_no": "out-456",
|
||||
"money": "1.50",
|
||||
} {
|
||||
if got := gotForm.Get(key); got != want {
|
||||
t.Fatalf("form[%s] = %q, want %q (form=%v)", key, got, want, gotForm)
|
||||
}
|
||||
}
|
||||
if got := gotForm.Get("trade_no"); got != "" {
|
||||
t.Fatalf("form[trade_no] = %q, want empty (form=%v)", got, gotForm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayRefundRetriesWithTradeNoWhenOutTradeNoNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotForms []url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api.php" {
|
||||
t.Errorf("refund path = %q, want /api.php", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("act") != "refund" {
|
||||
t.Errorf("refund act query = %q, want refund", r.URL.Query().Get("act"))
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("ParseForm: %v", err)
|
||||
}
|
||||
gotForms = append(gotForms, r.PostForm)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if len(gotForms) == 1 {
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"订单编号不存在!"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":1,"msg":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newTestEasyPay(t, server.URL+"/mapi.php")
|
||||
resp, err := provider.Refund(context.Background(), payment.RefundRequest{
|
||||
TradeNo: "trade-123",
|
||||
OrderID: "out-456",
|
||||
Amount: "1.50",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Refund returned error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.Status != payment.ProviderStatusSuccess || resp.RefundID != "trade-123" {
|
||||
t.Fatalf("Refund response = %+v, want success with trade refund id", resp)
|
||||
}
|
||||
if len(gotForms) != 2 {
|
||||
t.Fatalf("refund attempts = %d, want 2", len(gotForms))
|
||||
}
|
||||
if got := gotForms[0].Get("out_trade_no"); got != "out-456" {
|
||||
t.Fatalf("first form[out_trade_no] = %q, want out-456 (form=%v)", got, gotForms[0])
|
||||
}
|
||||
if got := gotForms[0].Get("trade_no"); got != "" {
|
||||
t.Fatalf("first form[trade_no] = %q, want empty (form=%v)", got, gotForms[0])
|
||||
}
|
||||
if got := gotForms[1].Get("trade_no"); got != "trade-123" {
|
||||
t.Fatalf("second form[trade_no] = %q, want trade-123 (form=%v)", got, gotForms[1])
|
||||
}
|
||||
if got := gotForms[1].Get("out_trade_no"); got != "" {
|
||||
t.Fatalf("second form[out_trade_no] = %q, want empty (form=%v)", got, gotForms[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayRefundResponseErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{name: "html response", statusCode: http.StatusOK, body: "<html>bad config</html>", want: "non-JSON response (HTTP 200): <html>bad config</html>"},
|
||||
{name: "non json response", statusCode: http.StatusOK, body: "not json", want: "non-JSON response (HTTP 200): not json"},
|
||||
{name: "non 2xx response", statusCode: http.StatusBadGateway, body: "bad gateway", want: "HTTP 502: bad gateway"},
|
||||
{name: "empty response", statusCode: http.StatusOK, body: "", want: "empty response (HTTP 200): <empty>"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newTestEasyPay(t, server.URL)
|
||||
_, err := provider.Refund(context.Background(), payment.RefundRequest{
|
||||
OrderID: "out-456",
|
||||
Amount: "1.50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Refund returned nil error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Refund error = %q, want substring %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeEasyPayResponsePreservesUTF8(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
summary := summarizeEasyPayResponse([]byte(strings.Repeat("错", 171)))
|
||||
if !utf8.ValidString(summary) {
|
||||
t.Fatalf("summarizeEasyPayResponse returned invalid UTF-8: %q", summary)
|
||||
}
|
||||
if !strings.HasSuffix(summary, "...") {
|
||||
t.Fatalf("summarizeEasyPayResponse() = %q, want truncated suffix", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := NewEasyPay("test-instance", map[string]string{
|
||||
"pid": "pid-1",
|
||||
"pkey": "pkey-1",
|
||||
"apiBase": "https://pay.example.com",
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
"paymentMode": paymentModePopup,
|
||||
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2-custom-1",
|
||||
Amount: "1.00",
|
||||
PaymentType: "usdt_trc20",
|
||||
Subject: "Custom EasyPay",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePayment: %v", err)
|
||||
}
|
||||
payURL, err := url.Parse(resp.PayURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse pay url: %v", err)
|
||||
}
|
||||
if got := payURL.Query().Get("type"); got != "usdt" {
|
||||
t.Fatalf("pay url type = %q, want usdt (%s)", got, resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayCustomMethodsResolveCIDFromConfiguredUpstreamType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := NewEasyPay("test-instance", map[string]string{
|
||||
"pid": "pid-1",
|
||||
"pkey": "pkey-1",
|
||||
"apiBase": "https://pay.example.com",
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
"paymentMode": paymentModePopup,
|
||||
"cidAlipay": "cid-alipay",
|
||||
"cidWxpay": "cid-wxpay",
|
||||
"customMethods": `[{"type":"ldc","upstreamType":"alipay","displayName":"LDC"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2-custom-cid",
|
||||
Amount: "1.00",
|
||||
PaymentType: "ldc",
|
||||
Subject: "Custom EasyPay CID",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePayment: %v", err)
|
||||
}
|
||||
payURL, err := url.Parse(resp.PayURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse pay url: %v", err)
|
||||
}
|
||||
if got := payURL.Query().Get("type"); got != "alipay" {
|
||||
t.Fatalf("pay url type = %q, want alipay (%s)", got, resp.PayURL)
|
||||
}
|
||||
if got := payURL.Query().Get("cid"); got != "cid-alipay" {
|
||||
t.Fatalf("pay url cid = %q, want cid-alipay (%s)", got, resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySupportedTypesIncludeCustomMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := NewEasyPay("test-instance", map[string]string{
|
||||
"pid": "pid-1",
|
||||
"pkey": "pkey-1",
|
||||
"apiBase": "https://pay.example.com",
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
}
|
||||
|
||||
got := strings.Join(provider.SupportedTypes(), ",")
|
||||
for _, want := range []string{"alipay", "wxpay", "ldc", "usdt_trc20"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("SupportedTypes() = %q, want it to include %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestEasyPay(t *testing.T, apiBase string) *EasyPay {
|
||||
t.Helper()
|
||||
|
||||
provider, err := NewEasyPay("test-instance", map[string]string{
|
||||
"pid": "pid-1",
|
||||
"pkey": "pkey-1",
|
||||
"apiBase": apiBase,
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEasyPaySignConsistentOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"out_trade_no": "ORDER123",
|
||||
"name": "Test Product",
|
||||
"money": "10.00",
|
||||
}
|
||||
pkey := "test_secret_key"
|
||||
|
||||
sign1 := easyPaySign(params, pkey)
|
||||
sign2 := easyPaySign(params, pkey)
|
||||
if sign1 != sign2 {
|
||||
t.Fatalf("easyPaySign should be deterministic: %q != %q", sign1, sign2)
|
||||
}
|
||||
if len(sign1) != 32 {
|
||||
t.Fatalf("MD5 hex should be 32 chars, got %d", len(sign1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySignExcludesSignAndSignType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pkey := "my_key"
|
||||
base := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
}
|
||||
withSign := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"sign": "should_be_ignored",
|
||||
"sign_type": "MD5",
|
||||
}
|
||||
|
||||
signBase := easyPaySign(base, pkey)
|
||||
signWithExtra := easyPaySign(withSign, pkey)
|
||||
|
||||
if signBase != signWithExtra {
|
||||
t.Fatalf("sign and sign_type should be excluded: base=%q, withExtra=%q", signBase, signWithExtra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySignExcludesEmptyValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pkey := "key123"
|
||||
base := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
}
|
||||
withEmpty := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"device": "",
|
||||
"clientip": "",
|
||||
}
|
||||
|
||||
signBase := easyPaySign(base, pkey)
|
||||
signWithEmpty := easyPaySign(withEmpty, pkey)
|
||||
|
||||
if signBase != signWithEmpty {
|
||||
t.Fatalf("empty values should be excluded: base=%q, withEmpty=%q", signBase, signWithEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayVerifySignValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"out_trade_no": "ORDER456",
|
||||
"money": "25.00",
|
||||
}
|
||||
pkey := "secret"
|
||||
|
||||
sign := easyPaySign(params, pkey)
|
||||
|
||||
// Add sign to params (as would come in a real callback)
|
||||
params["sign"] = sign
|
||||
params["sign_type"] = "MD5"
|
||||
|
||||
if !easyPayVerifySign(params, pkey, sign) {
|
||||
t.Fatal("easyPayVerifySign should return true for a valid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayVerifySignTampered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"out_trade_no": "ORDER789",
|
||||
"money": "50.00",
|
||||
}
|
||||
pkey := "secret"
|
||||
|
||||
sign := easyPaySign(params, pkey)
|
||||
|
||||
// Tamper with the amount
|
||||
params["money"] = "99.99"
|
||||
|
||||
if easyPayVerifySign(params, pkey, sign) {
|
||||
t.Fatal("easyPayVerifySign should return false for tampered params")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayVerifySignWrongKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "wxpay",
|
||||
}
|
||||
|
||||
sign := easyPaySign(params, "correct_key")
|
||||
|
||||
if easyPayVerifySign(params, "wrong_key", sign) {
|
||||
t.Fatal("easyPayVerifySign should return false with wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySignEmptyParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sign := easyPaySign(map[string]string{}, "key123")
|
||||
if sign == "" {
|
||||
t.Fatal("easyPaySign with empty params should still produce a hash")
|
||||
}
|
||||
if len(sign) != 32 {
|
||||
t.Fatalf("MD5 hex should be 32 chars, got %d", len(sign))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySignSortOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pkey := "test_key"
|
||||
params1 := map[string]string{
|
||||
"a": "1",
|
||||
"b": "2",
|
||||
"c": "3",
|
||||
}
|
||||
params2 := map[string]string{
|
||||
"c": "3",
|
||||
"a": "1",
|
||||
"b": "2",
|
||||
}
|
||||
|
||||
sign1 := easyPaySign(params1, pkey)
|
||||
sign2 := easyPaySign(params2, pkey)
|
||||
|
||||
if sign1 != sign2 {
|
||||
t.Fatalf("easyPaySign should be order-independent: %q != %q", sign1, sign2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayVerifySignWrongSignValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
}
|
||||
pkey := "key"
|
||||
|
||||
if easyPayVerifySign(params, pkey, "00000000000000000000000000000000") {
|
||||
t.Fatal("easyPayVerifySign should return false for an incorrect sign value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayMerchantIdentityMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &EasyPay{
|
||||
config: map[string]string{
|
||||
"pid": "1001",
|
||||
},
|
||||
}
|
||||
|
||||
metadata := provider.MerchantIdentityMetadata()
|
||||
if metadata["pid"] != "1001" {
|
||||
t.Fatalf("pid = %q, want %q", metadata["pid"], "1001")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
// CreateProvider creates a Provider from a provider key, instance ID and decrypted config.
|
||||
func CreateProvider(providerKey string, instanceID string, config map[string]string) (payment.Provider, error) {
|
||||
switch providerKey {
|
||||
case payment.TypeEasyPay:
|
||||
return NewEasyPay(instanceID, config)
|
||||
case payment.TypeAlipay:
|
||||
return NewAlipay(instanceID, config)
|
||||
case payment.TypeWxpay:
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
stripe "github.com/stripe/stripe-go/v85"
|
||||
"github.com/stripe/stripe-go/v85/webhook"
|
||||
)
|
||||
|
||||
// Stripe constants.
|
||||
const (
|
||||
stripeEventPaymentSuccess = "payment_intent.succeeded"
|
||||
stripeEventPaymentFailed = "payment_intent.payment_failed"
|
||||
)
|
||||
|
||||
// Stripe implements the payment.CancelableProvider interface for Stripe payments.
|
||||
type Stripe struct {
|
||||
instanceID string
|
||||
config map[string]string
|
||||
|
||||
mu sync.Mutex
|
||||
initialized bool
|
||||
sc *stripe.Client
|
||||
}
|
||||
|
||||
// NewStripe creates a new Stripe provider instance.
|
||||
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: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Stripe) ensureInit() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.initialized {
|
||||
s.sc = stripe.NewClient(s.config["secretKey"])
|
||||
s.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
// GetPublishableKey returns the publishable key for frontend use.
|
||||
func (s *Stripe) GetPublishableKey() string {
|
||||
return s.config["publishableKey"]
|
||||
}
|
||||
|
||||
func (s *Stripe) Name() string { return "Stripe" }
|
||||
func (s *Stripe) ProviderKey() string { return payment.TypeStripe }
|
||||
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"},
|
||||
payment.TypeAlipay: {"alipay"},
|
||||
payment.TypeWxpay: {"wechat_pay"},
|
||||
payment.TypeLink: {"link"},
|
||||
}
|
||||
|
||||
// CreatePayment creates a Stripe PaymentIntent.
|
||||
func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
currency := s.currency()
|
||||
amountInMinorUnit, err := payment.AmountToMinorUnit(req.Amount, currency)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe create payment: %w", err)
|
||||
}
|
||||
|
||||
// Collect all Stripe payment_method_types from the instance's configured sub-methods
|
||||
methods := resolveStripeMethodTypes(req.InstanceSubMethods)
|
||||
|
||||
pmTypes := make([]*string, len(methods))
|
||||
for i, m := range methods {
|
||||
pmTypes[i] = stripe.String(m)
|
||||
}
|
||||
|
||||
params := &stripe.PaymentIntentCreateParams{
|
||||
Amount: stripe.Int64(amountInMinorUnit),
|
||||
Currency: stripe.String(strings.ToLower(currency)),
|
||||
PaymentMethodTypes: pmTypes,
|
||||
Description: stripe.String(req.Subject),
|
||||
Metadata: map[string]string{"orderId": req.OrderID},
|
||||
}
|
||||
|
||||
// WeChat Pay requires payment_method_options with client type
|
||||
if hasStripeMethod(methods, "wechat_pay") {
|
||||
params.PaymentMethodOptions = &stripe.PaymentIntentCreatePaymentMethodOptionsParams{
|
||||
WeChatPay: &stripe.PaymentIntentCreatePaymentMethodOptionsWeChatPayParams{
|
||||
Client: stripe.String("web"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
params.SetIdempotencyKey(fmt.Sprintf("pi-%s", req.OrderID))
|
||||
params.Context = ctx
|
||||
|
||||
pi, err := s.sc.V1PaymentIntents.Create(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe create payment: %w", err)
|
||||
}
|
||||
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: pi.ID,
|
||||
ClientSecret: pi.ClientSecret,
|
||||
Currency: currency,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryOrder retrieves a PaymentIntent by ID.
|
||||
func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
pi, err := s.sc.V1PaymentIntents.Retrieve(ctx, tradeNo, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe query order: %w", err)
|
||||
}
|
||||
|
||||
status := payment.ProviderStatusPending
|
||||
switch pi.Status {
|
||||
case stripe.PaymentIntentStatusSucceeded:
|
||||
status = payment.ProviderStatusPaid
|
||||
case stripe.PaymentIntentStatusCanceled:
|
||||
status = payment.ProviderStatusFailed
|
||||
}
|
||||
|
||||
currency := stripeIntentCurrency(pi.Currency, s.currency())
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: pi.ID,
|
||||
Status: status,
|
||||
Amount: payment.MinorUnitToAmount(pi.Amount, currency),
|
||||
Metadata: map[string]string{
|
||||
"currency": currency,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyNotification verifies a Stripe webhook event.
|
||||
func (s *Stripe) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
|
||||
s.ensureInit()
|
||||
|
||||
webhookSecret := s.config["webhookSecret"]
|
||||
if webhookSecret == "" {
|
||||
return nil, fmt.Errorf("stripe webhookSecret not configured")
|
||||
}
|
||||
|
||||
sig := headers["stripe-signature"]
|
||||
if sig == "" {
|
||||
return nil, fmt.Errorf("stripe notification missing stripe-signature header")
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent([]byte(rawBody), sig, webhookSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe verify notification: %w", err)
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case stripeEventPaymentSuccess:
|
||||
return parseStripePaymentIntent(&event, payment.ProviderStatusSuccess, rawBody)
|
||||
case stripeEventPaymentFailed:
|
||||
return parseStripePaymentIntent(&event, payment.ProviderStatusFailed, rawBody)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string) (*payment.PaymentNotification, error) {
|
||||
var pi stripe.PaymentIntent
|
||||
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.MinorUnitToAmount(pi.Amount, currency),
|
||||
Status: status,
|
||||
RawData: rawBody,
|
||||
Metadata: map[string]string{
|
||||
"currency": currency,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refund creates a Stripe refund.
|
||||
func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
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(amountInMinorUnit),
|
||||
Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)),
|
||||
}
|
||||
params.SetIdempotencyKey(fmt.Sprintf("re-%s-%d", req.OrderID, amountInMinorUnit))
|
||||
params.Context = ctx
|
||||
|
||||
r, err := s.sc.V1Refunds.Create(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe refund: %w", err)
|
||||
}
|
||||
|
||||
refundStatus := payment.ProviderStatusPending
|
||||
if r.Status == stripe.RefundStatusSucceeded {
|
||||
refundStatus = payment.ProviderStatusSuccess
|
||||
}
|
||||
|
||||
return &payment.RefundResponse{
|
||||
RefundID: r.ID,
|
||||
Status: refundStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryRefund retrieves a Stripe refund by refund ID when available, otherwise
|
||||
// falls back to the latest refund for the PaymentIntent.
|
||||
func (s *Stripe) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
var r *stripe.Refund
|
||||
var err error
|
||||
if refundID := strings.TrimSpace(req.RefundID); refundID != "" {
|
||||
r, err = s.sc.V1Refunds.Retrieve(ctx, refundID, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe query refund: %w", err)
|
||||
}
|
||||
} else {
|
||||
tradeNo := strings.TrimSpace(req.TradeNo)
|
||||
if tradeNo == "" {
|
||||
return nil, fmt.Errorf("stripe query refund: missing payment intent id")
|
||||
}
|
||||
params := &stripe.RefundListParams{PaymentIntent: stripe.String(tradeNo)}
|
||||
params.Limit = stripe.Int64(1)
|
||||
list := s.sc.V1Refunds.List(ctx, params)
|
||||
if list.Err() != nil {
|
||||
return nil, fmt.Errorf("stripe query refund: %w", list.Err())
|
||||
}
|
||||
refunds := list.Data()
|
||||
if len(refunds) == 0 {
|
||||
return nil, fmt.Errorf("stripe query refund: no refund found")
|
||||
}
|
||||
r = refunds[0]
|
||||
}
|
||||
|
||||
return &payment.RefundResponse{RefundID: r.ID, Status: stripeRefundProviderStatus(r.Status)}, nil
|
||||
}
|
||||
|
||||
func stripeRefundProviderStatus(status stripe.RefundStatus) string {
|
||||
switch status {
|
||||
case stripe.RefundStatusSucceeded:
|
||||
return payment.ProviderStatusSuccess
|
||||
case stripe.RefundStatusFailed, stripe.RefundStatusCanceled:
|
||||
return payment.ProviderStatusFailed
|
||||
default:
|
||||
return payment.ProviderStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if instanceSubMethods == "" {
|
||||
return []string{"card"}
|
||||
}
|
||||
var methods []string
|
||||
for _, t := range strings.Split(instanceSubMethods, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if mapped, ok := stripePaymentMethodTypes[t]; ok {
|
||||
methods = append(methods, mapped...)
|
||||
}
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
return []string{"card"}
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
// hasStripeMethod checks if the given Stripe method list contains the target method.
|
||||
func hasStripeMethod(methods []string, target string) bool {
|
||||
for _, m := range methods {
|
||||
if m == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CancelPayment cancels a pending PaymentIntent.
|
||||
func (s *Stripe) CancelPayment(ctx context.Context, tradeNo string) error {
|
||||
s.ensureInit()
|
||||
|
||||
_, err := s.sc.V1PaymentIntents.Cancel(ctx, tradeNo, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe cancel payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure interface compliance.
|
||||
var (
|
||||
_ payment.Provider = (*Stripe)(nil)
|
||||
_ payment.CancelableProvider = (*Stripe)(nil)
|
||||
_ payment.MerchantIdentityProvider = (*Stripe)(nil)
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build unit
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/stretchr/testify/require"
|
||||
stripe "github.com/stripe/stripe-go/v85"
|
||||
)
|
||||
|
||||
type stripeRefundBackend struct {
|
||||
params []*stripe.RefundCreateParams
|
||||
}
|
||||
|
||||
func (b *stripeRefundBackend) Call(_ string, _ string, _ string, params stripe.ParamsContainer, v stripe.LastResponseSetter) error {
|
||||
b.params = append(b.params, params.(*stripe.RefundCreateParams))
|
||||
refund := v.(*stripe.Refund)
|
||||
refund.ID = "re_123"
|
||||
refund.Status = stripe.RefundStatusSucceeded
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) CallStreaming(string, string, string, stripe.ParamsContainer, stripe.StreamingLastResponseSetter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) CallRaw(string, string, string, []byte, *stripe.Params, stripe.LastResponseSetter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) CallMultipart(string, string, string, string, *bytes.Buffer, *stripe.Params, stripe.LastResponseSetter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) SetMaxNetworkRetries(int64) {}
|
||||
|
||||
func TestStripeRefundUsesStableAmountSpecificIdempotencyKey(t *testing.T) {
|
||||
backend := &stripeRefundBackend{}
|
||||
client := stripe.NewClient("sk_test", stripe.WithBackends(&stripe.Backends{API: backend}))
|
||||
provider := &Stripe{
|
||||
config: map[string]string{"currency": "CNY"},
|
||||
initialized: true,
|
||||
sc: client,
|
||||
}
|
||||
|
||||
refund := func(amount string) {
|
||||
_, err := provider.Refund(context.Background(), payment.RefundRequest{
|
||||
TradeNo: "pi_123",
|
||||
OrderID: "sub2_order_456",
|
||||
Amount: amount,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
refund("12.34")
|
||||
refund("12.34")
|
||||
refund("12.35")
|
||||
|
||||
require.Len(t, backend.params, 3)
|
||||
require.Equal(t, int64(1234), *backend.params[0].Amount)
|
||||
require.Equal(t, "re-sub2_order_456-1234", *backend.params[0].IdempotencyKey)
|
||||
require.Equal(t, backend.params[0].IdempotencyKey, backend.params[1].IdempotencyKey)
|
||||
require.Equal(t, int64(1235), *backend.params[2].Amount)
|
||||
require.Equal(t, "re-sub2_order_456-1235", *backend.params[2].IdempotencyKey)
|
||||
require.NotEqual(t, *backend.params[0].IdempotencyKey, *backend.params[2].IdempotencyKey)
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||||
)
|
||||
|
||||
// WeChat Pay constants.
|
||||
const (
|
||||
wxpayCurrency = "CNY"
|
||||
wxpayH5Type = "Wap"
|
||||
wxpayResultPath = "/payment/result"
|
||||
)
|
||||
|
||||
const (
|
||||
wxpayMetadataAppID = "appid"
|
||||
wxpayMetadataMerchantID = "mchid"
|
||||
wxpayMetadataCurrency = "currency"
|
||||
wxpayMetadataTradeState = "trade_state"
|
||||
)
|
||||
|
||||
// WeChat Pay create-payment modes.
|
||||
const (
|
||||
wxpayModeNative = "native"
|
||||
wxpayModeH5 = "h5"
|
||||
wxpayModeJSAPI = "jsapi"
|
||||
)
|
||||
|
||||
// WeChat Pay trade states.
|
||||
const (
|
||||
wxpayTradeStateSuccess = "SUCCESS"
|
||||
wxpayTradeStateRefund = "REFUND"
|
||||
wxpayTradeStateClosed = "CLOSED"
|
||||
wxpayTradeStatePayError = "PAYERROR"
|
||||
)
|
||||
|
||||
// WeChat Pay notification event types.
|
||||
const (
|
||||
wxpayEventTransactionSuccess = "TRANSACTION.SUCCESS"
|
||||
)
|
||||
|
||||
var (
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
return svc.Prepay(ctx, req)
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
return svc.Prepay(ctx, req)
|
||||
}
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
return svc.PrepayWithRequestPayment(ctx, req)
|
||||
}
|
||||
)
|
||||
|
||||
type Wxpay struct {
|
||||
instanceID string
|
||||
config map[string]string
|
||||
mu sync.Mutex
|
||||
coreClient *core.Client
|
||||
notifyHandler *notify.Handler
|
||||
}
|
||||
|
||||
const wxpayAPIv3KeyLength = 32
|
||||
|
||||
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
|
||||
// All fields are required. Platform-certificate mode is intentionally unsupported —
|
||||
// WeChat has been migrating all merchants to the pubkey verifier since 2024-10,
|
||||
// and newly-provisioned merchants cannot download platform certificates at all.
|
||||
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "certSerial", "publicKey", "publicKeyId"}
|
||||
for _, k := range required {
|
||||
if config[k] == "" {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
|
||||
WithMetadata(map[string]string{"key": k})
|
||||
}
|
||||
}
|
||||
if len(config["apiV3Key"]) != wxpayAPIv3KeyLength {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY_LENGTH", "invalid_key_length").
|
||||
WithMetadata(map[string]string{
|
||||
"key": "apiV3Key",
|
||||
"expected": strconv.Itoa(wxpayAPIv3KeyLength),
|
||||
"actual": strconv.Itoa(len(config["apiV3Key"])),
|
||||
})
|
||||
}
|
||||
// Parse PEMs eagerly so malformed keys surface at save time, not at order creation.
|
||||
if _, err := utils.LoadPrivateKey(formatPEM(config["privateKey"], "PRIVATE KEY")); err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "privateKey"})
|
||||
}
|
||||
if _, err := utils.LoadPublicKey(formatPEM(config["publicKey"], "PUBLIC KEY")); err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "publicKey"})
|
||||
}
|
||||
return &Wxpay{instanceID: instanceID, config: config}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) Name() string { return "Wxpay" }
|
||||
func (w *Wxpay) ProviderKey() string { return payment.TypeWxpay }
|
||||
func (w *Wxpay) SupportedTypes() []payment.PaymentType {
|
||||
return []payment.PaymentType{payment.TypeWxpay}
|
||||
}
|
||||
|
||||
// ResolveWxpayJSAPIAppID returns the AppID that JSAPI prepay will use for a
|
||||
// given provider config. A dedicated MP AppID takes precedence over the base
|
||||
// merchant AppID.
|
||||
func ResolveWxpayJSAPIAppID(config map[string]string) string {
|
||||
if appID := strings.TrimSpace(config["mpAppId"]); appID != "" {
|
||||
return appID
|
||||
}
|
||||
return strings.TrimSpace(config["appId"])
|
||||
}
|
||||
|
||||
func formatPEM(key, keyType string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if strings.HasPrefix(key, "-----BEGIN") {
|
||||
return key
|
||||
}
|
||||
return fmt.Sprintf("-----BEGIN %s-----\n%s\n-----END %s-----", keyType, key, keyType)
|
||||
}
|
||||
|
||||
func (w *Wxpay) ensureClient() (*core.Client, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.coreClient != nil {
|
||||
return w.coreClient, nil
|
||||
}
|
||||
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "privateKey"})
|
||||
}
|
||||
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "publicKey"})
|
||||
}
|
||||
verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
|
||||
client, err := core.NewClient(context.Background(),
|
||||
option.WithMerchantCredential(w.config["mchId"], w.config["certSerial"], privateKey),
|
||||
option.WithVerifier(verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay init client: %w", err)
|
||||
}
|
||||
handler, err := notify.NewRSANotifyHandler(w.config["apiV3Key"], verifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay init notify handler: %w", err)
|
||||
}
|
||||
w.notifyHandler = handler
|
||||
w.coreClient = client
|
||||
return w.coreClient, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
client, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Request-first, config-fallback (consistent with EasyPay/Alipay)
|
||||
notifyURL := req.NotifyURL
|
||||
if notifyURL == "" {
|
||||
notifyURL = w.config["notifyUrl"]
|
||||
}
|
||||
if notifyURL == "" {
|
||||
return nil, fmt.Errorf("wxpay notifyUrl is required")
|
||||
}
|
||||
totalFen, err := payment.YuanToFen(req.Amount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay create payment: %w", err)
|
||||
}
|
||||
|
||||
mode, err := resolveWxpayCreateMode(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch mode {
|
||||
case wxpayModeJSAPI:
|
||||
return w.prepayJSAPI(ctx, client, req, notifyURL, totalFen)
|
||||
case wxpayModeH5:
|
||||
return w.prepayH5(ctx, client, req, notifyURL, totalFen)
|
||||
case wxpayModeNative:
|
||||
return w.prepayNative(ctx, client, req, notifyURL, totalFen)
|
||||
default:
|
||||
return nil, fmt.Errorf("wxpay create payment: unsupported mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Wxpay) prepayJSAPI(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||
svc := jsapi.JsapiApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
appID := ResolveWxpayJSAPIAppID(w.config)
|
||||
prepayReq := jsapi.PrepayRequest{
|
||||
Appid: core.String(appID),
|
||||
Mchid: core.String(w.config["mchId"]),
|
||||
Description: core.String(req.Subject),
|
||||
OutTradeNo: core.String(req.OrderID),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
Amount: &jsapi.Amount{Total: core.Int64(totalFen), Currency: &cur},
|
||||
Payer: &jsapi.Payer{Openid: core.String(strings.TrimSpace(req.OpenID))},
|
||||
}
|
||||
if clientIP := strings.TrimSpace(req.ClientIP); clientIP != "" {
|
||||
prepayReq.SceneInfo = &jsapi.SceneInfo{PayerClientIp: core.String(clientIP)}
|
||||
}
|
||||
resp, _, err := wxpayJSAPIPrepayWithRequestPayment(ctx, svc, prepayReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay jsapi prepay: %w", err)
|
||||
}
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
ResultType: payment.CreatePaymentResultJSAPIReady,
|
||||
JSAPI: &payment.WechatJSAPIPayload{
|
||||
AppID: wxSV(resp.Appid),
|
||||
TimeStamp: wxSV(resp.TimeStamp),
|
||||
NonceStr: wxSV(resp.NonceStr),
|
||||
Package: wxSV(resp.Package),
|
||||
SignType: wxSV(resp.SignType),
|
||||
PaySign: wxSV(resp.PaySign),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) prepayNative(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||
svc := native.NativeApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
resp, _, err := wxpayNativePrepay(ctx, svc, native.PrepayRequest{
|
||||
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
|
||||
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
Amount: &native.Amount{Total: core.Int64(totalFen), Currency: &cur},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay native prepay: %w", err)
|
||||
}
|
||||
codeURL := ""
|
||||
if resp.CodeUrl != nil {
|
||||
codeURL = *resp.CodeUrl
|
||||
}
|
||||
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, QRCode: codeURL}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||
svc := h5.H5ApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
resp, _, err := wxpayH5Prepay(ctx, svc, h5.PrepayRequest{
|
||||
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
|
||||
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
Amount: &h5.Amount{Total: core.Int64(totalFen), Currency: &cur},
|
||||
SceneInfo: &h5.SceneInfo{PayerClientIp: core.String(req.ClientIP), H5Info: buildWxpayH5Info(w.config)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay h5 prepay: %w", err)
|
||||
}
|
||||
h5URL := ""
|
||||
if resp.H5Url != nil {
|
||||
h5URL = *resp.H5Url
|
||||
}
|
||||
h5URL, err = appendWxpayRedirectURL(h5URL, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
|
||||
}
|
||||
|
||||
func buildWxpayH5Info(config map[string]string) *h5.H5Info {
|
||||
tp := wxpayH5Type
|
||||
info := &h5.H5Info{Type: &tp}
|
||||
if appName := strings.TrimSpace(config["h5AppName"]); appName != "" {
|
||||
info.AppName = core.String(appName)
|
||||
}
|
||||
if appURL := strings.TrimSpace(config["h5AppUrl"]); appURL != "" {
|
||||
info.AppUrl = core.String(appURL)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) {
|
||||
if strings.TrimSpace(req.OpenID) != "" {
|
||||
return wxpayModeJSAPI, nil
|
||||
}
|
||||
if req.IsMobile {
|
||||
if strings.TrimSpace(req.ClientIP) == "" {
|
||||
return "", fmt.Errorf("wxpay H5 payment requires client IP")
|
||||
}
|
||||
return wxpayModeH5, nil
|
||||
}
|
||||
return wxpayModeNative, nil
|
||||
}
|
||||
|
||||
func appendWxpayRedirectURL(h5URL string, req payment.CreatePaymentRequest) (string, error) {
|
||||
h5URL = strings.TrimSpace(h5URL)
|
||||
returnURL := strings.TrimSpace(req.ReturnURL)
|
||||
if h5URL == "" || returnURL == "" {
|
||||
return h5URL, nil
|
||||
}
|
||||
|
||||
redirectURL, err := buildWxpayResultURL(returnURL, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sep := "&"
|
||||
if !strings.Contains(h5URL, "?") {
|
||||
sep = "?"
|
||||
}
|
||||
return h5URL + sep + "redirect_url=" + url.QueryEscape(redirectURL), nil
|
||||
}
|
||||
|
||||
func buildWxpayResultURL(returnURL string, req payment.CreatePaymentRequest) (string, error) {
|
||||
u, err := url.Parse(returnURL)
|
||||
if err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", fmt.Errorf("return URL must be an absolute http(s) URL")
|
||||
}
|
||||
|
||||
values := u.Query()
|
||||
values.Set("out_trade_no", strings.TrimSpace(req.OrderID))
|
||||
if paymentType := strings.TrimSpace(req.PaymentType); paymentType != "" {
|
||||
values.Set("payment_type", paymentType)
|
||||
}
|
||||
if strings.TrimSpace(u.Path) == "" {
|
||||
u.Path = wxpayResultPath
|
||||
}
|
||||
u.RawPath = ""
|
||||
u.RawQuery = values.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func wxSV(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func mapWxState(s string) string {
|
||||
switch s {
|
||||
case wxpayTradeStateSuccess:
|
||||
return payment.ProviderStatusPaid
|
||||
case wxpayTradeStateRefund:
|
||||
return payment.ProviderStatusRefunded
|
||||
case wxpayTradeStateClosed, wxpayTradeStatePayError:
|
||||
return payment.ProviderStatusFailed
|
||||
default:
|
||||
return payment.ProviderStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
func buildWxpayTransactionMetadata(tx *payments.Transaction) map[string]string {
|
||||
if tx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata := map[string]string{}
|
||||
if appID := wxSV(tx.Appid); appID != "" {
|
||||
metadata[wxpayMetadataAppID] = appID
|
||||
}
|
||||
if merchantID := wxSV(tx.Mchid); merchantID != "" {
|
||||
metadata[wxpayMetadataMerchantID] = merchantID
|
||||
}
|
||||
if tradeState := wxSV(tx.TradeState); tradeState != "" {
|
||||
metadata[wxpayMetadataTradeState] = tradeState
|
||||
}
|
||||
if tx.Amount != nil {
|
||||
if currency := wxSV(tx.Amount.Currency); currency != "" {
|
||||
metadata[wxpayMetadataCurrency] = currency
|
||||
}
|
||||
}
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (w *Wxpay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
c, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svc := native.NativeApiService{Client: c}
|
||||
tx, _, err := svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
|
||||
OutTradeNo: core.String(tradeNo), Mchid: core.String(w.config["mchId"]),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay query order: %w", err)
|
||||
}
|
||||
var amt float64
|
||||
if tx.Amount != nil && tx.Amount.Total != nil {
|
||||
amt = payment.FenToYuan(*tx.Amount.Total)
|
||||
}
|
||||
id := tradeNo
|
||||
if tx.TransactionId != nil {
|
||||
id = *tx.TransactionId
|
||||
}
|
||||
pa := ""
|
||||
if tx.SuccessTime != nil {
|
||||
pa = *tx.SuccessTime
|
||||
}
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: id,
|
||||
Status: mapWxState(wxSV(tx.TradeState)),
|
||||
Amount: amt,
|
||||
PaidAt: pa,
|
||||
Metadata: buildWxpayTransactionMetadata(tx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) VerifyNotification(ctx context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
|
||||
if _, err := w.ensureClient(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := http.NewRequestWithContext(ctx, http.MethodPost, "/", io.NopCloser(bytes.NewBufferString(rawBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay construct request: %w", err)
|
||||
}
|
||||
for k, v := range headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
var tx payments.Transaction
|
||||
nr, err := w.notifyHandler.ParseNotifyRequest(ctx, r, &tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay verify notification: %w", err)
|
||||
}
|
||||
if nr.EventType != wxpayEventTransactionSuccess {
|
||||
return nil, nil
|
||||
}
|
||||
var amt float64
|
||||
if tx.Amount != nil && tx.Amount.Total != nil {
|
||||
amt = payment.FenToYuan(*tx.Amount.Total)
|
||||
}
|
||||
st := payment.ProviderStatusFailed
|
||||
if wxSV(tx.TradeState) == wxpayTradeStateSuccess {
|
||||
st = payment.ProviderStatusSuccess
|
||||
}
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: wxSV(tx.TransactionId), OrderID: wxSV(tx.OutTradeNo),
|
||||
Amount: amt, Status: st, RawData: rawBody, Metadata: buildWxpayTransactionMetadata(&tx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
c, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rf, err := payment.YuanToFen(req.Amount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay refund amount: %w", err)
|
||||
}
|
||||
tf, err := w.queryOrderTotalFen(ctx, c, req.OrderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs := refunddomestic.RefundsApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
outRefundNo := wxpayRefundID(req.OrderID, req.Amount)
|
||||
res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{
|
||||
OutTradeNo: core.String(req.OrderID),
|
||||
OutRefundNo: core.String(outRefundNo),
|
||||
Reason: core.String(req.Reason),
|
||||
Amount: &refunddomestic.AmountReq{Refund: core.Int64(rf), Total: core.Int64(tf), Currency: &cur},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay refund: %w", err)
|
||||
}
|
||||
st := payment.ProviderStatusPending
|
||||
if res.Status != nil && *res.Status == refunddomestic.STATUS_SUCCESS {
|
||||
st = payment.ProviderStatusSuccess
|
||||
}
|
||||
return &payment.RefundResponse{RefundID: outRefundNo, Status: st}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
|
||||
c, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outRefundNo := strings.TrimSpace(req.RefundID)
|
||||
if outRefundNo == "" {
|
||||
outRefundNo = wxpayRefundID(req.OrderID, req.Amount)
|
||||
}
|
||||
if outRefundNo == "" {
|
||||
return nil, fmt.Errorf("wxpay query refund: missing refund id")
|
||||
}
|
||||
rs := refunddomestic.RefundsApiService{Client: c}
|
||||
res, _, err := rs.QueryByOutRefundNo(ctx, refunddomestic.QueryByOutRefundNoRequest{
|
||||
OutRefundNo: core.String(outRefundNo),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay query refund: %w", err)
|
||||
}
|
||||
status := payment.ProviderStatusPending
|
||||
if res != nil && res.Status != nil {
|
||||
switch *res.Status {
|
||||
case refunddomestic.STATUS_SUCCESS:
|
||||
status = payment.ProviderStatusSuccess
|
||||
case refunddomestic.STATUS_CLOSED, refunddomestic.STATUS_ABNORMAL:
|
||||
status = payment.ProviderStatusFailed
|
||||
default:
|
||||
status = payment.ProviderStatusPending
|
||||
}
|
||||
}
|
||||
return &payment.RefundResponse{RefundID: outRefundNo, Status: status}, nil
|
||||
}
|
||||
|
||||
func wxpayRefundID(orderID, amount string) string {
|
||||
orderID = strings.TrimSpace(orderID)
|
||||
if orderID == "" {
|
||||
return ""
|
||||
}
|
||||
amount = strings.NewReplacer(".", "", "-", "").Replace(strings.TrimSpace(amount))
|
||||
if amount == "" {
|
||||
return orderID + "-refund"
|
||||
}
|
||||
return orderID + "-refund-" + amount
|
||||
}
|
||||
|
||||
func (w *Wxpay) queryOrderTotalFen(ctx context.Context, c *core.Client, orderID string) (int64, error) {
|
||||
svc := native.NativeApiService{Client: c}
|
||||
tx, _, err := svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
|
||||
OutTradeNo: core.String(orderID), Mchid: core.String(w.config["mchId"]),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("wxpay refund query order: %w", err)
|
||||
}
|
||||
var tf int64
|
||||
if tx.Amount != nil && tx.Amount.Total != nil {
|
||||
tf = *tx.Amount.Total
|
||||
}
|
||||
return tf, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) CancelPayment(ctx context.Context, tradeNo string) error {
|
||||
c, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc := native.NativeApiService{Client: c}
|
||||
_, err = svc.CloseOrder(ctx, native.CloseOrderRequest{
|
||||
OutTradeNo: core.String(tradeNo), Mchid: core.String(w.config["mchId"]),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wxpay cancel payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ payment.Provider = (*Wxpay)(nil)
|
||||
_ payment.CancelableProvider = (*Wxpay)(nil)
|
||||
)
|
||||
@@ -0,0 +1,709 @@
|
||||
//go:build unit
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
|
||||
)
|
||||
|
||||
// generateTestKeyPair returns a fresh RSA 2048 key pair as PEM strings.
|
||||
// The wechatpay-go SDK expects PKCS8 private keys and PKIX public keys.
|
||||
func generateTestKeyPair(t *testing.T) (privPEM, pubPEM string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate rsa key: %v", err)
|
||||
}
|
||||
privDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkcs8: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkix: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
}
|
||||
|
||||
func TestMapWxState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "SUCCESS maps to paid",
|
||||
input: wxpayTradeStateSuccess,
|
||||
want: payment.ProviderStatusPaid,
|
||||
},
|
||||
{
|
||||
name: "REFUND maps to refunded",
|
||||
input: wxpayTradeStateRefund,
|
||||
want: payment.ProviderStatusRefunded,
|
||||
},
|
||||
{
|
||||
name: "CLOSED maps to failed",
|
||||
input: wxpayTradeStateClosed,
|
||||
want: payment.ProviderStatusFailed,
|
||||
},
|
||||
{
|
||||
name: "PAYERROR maps to failed",
|
||||
input: wxpayTradeStatePayError,
|
||||
want: payment.ProviderStatusFailed,
|
||||
},
|
||||
{
|
||||
name: "unknown state maps to pending",
|
||||
input: "NOTPAY",
|
||||
want: payment.ProviderStatusPending,
|
||||
},
|
||||
{
|
||||
name: "empty string maps to pending",
|
||||
input: "",
|
||||
want: payment.ProviderStatusPending,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := mapWxState(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("mapWxState(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWxSV(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input *string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil pointer returns empty string",
|
||||
input: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "non-nil pointer returns value",
|
||||
input: strPtr("hello"),
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "pointer to empty string returns empty string",
|
||||
input: strPtr(""),
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := wxSV(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("wxSV() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWxpayTransactionMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tx := &payments.Transaction{
|
||||
Appid: strPtr("wx-app-id"),
|
||||
Mchid: strPtr("mch-id"),
|
||||
TradeState: strPtr(wxpayTradeStateSuccess),
|
||||
Amount: &payments.TransactionAmount{
|
||||
Currency: strPtr(wxpayCurrency),
|
||||
},
|
||||
}
|
||||
|
||||
metadata := buildWxpayTransactionMetadata(tx)
|
||||
if metadata[wxpayMetadataAppID] != "wx-app-id" {
|
||||
t.Fatalf("appid = %q", metadata[wxpayMetadataAppID])
|
||||
}
|
||||
if metadata[wxpayMetadataMerchantID] != "mch-id" {
|
||||
t.Fatalf("mchid = %q", metadata[wxpayMetadataMerchantID])
|
||||
}
|
||||
if metadata[wxpayMetadataCurrency] != wxpayCurrency {
|
||||
t.Fatalf("currency = %q", metadata[wxpayMetadataCurrency])
|
||||
}
|
||||
if metadata[wxpayMetadataTradeState] != wxpayTradeStateSuccess {
|
||||
t.Fatalf("trade_state = %q", metadata[wxpayMetadataTradeState])
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestFormatPEM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
keyType string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "raw key gets wrapped with headers",
|
||||
key: "MIIBIjANBgkqhki...",
|
||||
keyType: "PUBLIC KEY",
|
||||
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
{
|
||||
name: "already formatted key is returned as-is",
|
||||
key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
|
||||
keyType: "PRIVATE KEY",
|
||||
want: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
{
|
||||
name: "key with leading/trailing whitespace is trimmed before check",
|
||||
key: " \n MIIBIjANBgkqhki... \n ",
|
||||
keyType: "PUBLIC KEY",
|
||||
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
{
|
||||
name: "already formatted key with whitespace is trimmed and returned",
|
||||
key: " -----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY----- ",
|
||||
keyType: "RSA PRIVATE KEY",
|
||||
want: "-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := formatPEM(tt.key, tt.keyType)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatPEM(%q, %q) =\n%s\nwant:\n%s", tt.key, tt.keyType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWxpay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
privPEM, pubPEM := generateTestKeyPair(t)
|
||||
validConfig := map[string]string{
|
||||
"appId": "wx1234567890",
|
||||
"mchId": "1234567890",
|
||||
"privateKey": privPEM,
|
||||
"apiV3Key": "12345678901234567890123456789012", // exactly 32 bytes
|
||||
"publicKey": pubPEM,
|
||||
"publicKeyId": "PUB_KEY_ID_TEST",
|
||||
"certSerial": "SERIAL001",
|
||||
}
|
||||
|
||||
// helper to clone and override config fields
|
||||
withOverride := func(overrides map[string]string) map[string]string {
|
||||
cfg := make(map[string]string, len(validConfig))
|
||||
for k, v := range validConfig {
|
||||
cfg[k] = v
|
||||
}
|
||||
for k, v := range overrides {
|
||||
cfg[k] = v
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]string
|
||||
wantErr bool
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "valid config succeeds",
|
||||
config: validConfig,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing appId",
|
||||
config: withOverride(map[string]string{"appId": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "appId",
|
||||
},
|
||||
{
|
||||
name: "missing mchId",
|
||||
config: withOverride(map[string]string{"mchId": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "mchId",
|
||||
},
|
||||
{
|
||||
name: "missing privateKey",
|
||||
config: withOverride(map[string]string{"privateKey": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "privateKey",
|
||||
},
|
||||
{
|
||||
name: "missing apiV3Key",
|
||||
config: withOverride(map[string]string{"apiV3Key": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "apiV3Key",
|
||||
},
|
||||
{
|
||||
name: "missing certSerial",
|
||||
config: withOverride(map[string]string{"certSerial": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "certSerial",
|
||||
},
|
||||
{
|
||||
name: "missing publicKey",
|
||||
config: withOverride(map[string]string{"publicKey": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "publicKey",
|
||||
},
|
||||
{
|
||||
name: "missing publicKeyId",
|
||||
config: withOverride(map[string]string{"publicKeyId": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "publicKeyId",
|
||||
},
|
||||
{
|
||||
name: "malformed privateKey PEM",
|
||||
config: withOverride(map[string]string{"privateKey": "not-a-valid-pem"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
||||
},
|
||||
{
|
||||
name: "malformed publicKey PEM",
|
||||
config: withOverride(map[string]string{"publicKey": "not-a-valid-pem"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
||||
},
|
||||
{
|
||||
name: "apiV3Key too short",
|
||||
config: withOverride(map[string]string{"apiV3Key": "short"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
||||
},
|
||||
{
|
||||
name: "apiV3Key too long",
|
||||
config: withOverride(map[string]string{"apiV3Key": "123456789012345678901234567890123"}), // 33 bytes
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := NewWxpay("test-instance", tt.config)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil Wxpay instance")
|
||||
}
|
||||
if got.instanceID != "test-instance" {
|
||||
t.Errorf("instanceID = %q, want %q", got.instanceID, "test-instance")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWxpayResultURLPreservesResumeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resultURL, err := buildWxpayResultURL("https://app.example.com/payment/result?order_id=42&resume_token=resume-42&status=success", payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_42",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWxpayResultURL returned error: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(resultURL)
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse returned error: %v", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if parsed.Path != wxpayResultPath {
|
||||
t.Fatalf("path = %q, want %q", parsed.Path, wxpayResultPath)
|
||||
}
|
||||
if query.Get("resume_token") != "resume-42" {
|
||||
t.Fatalf("resume_token = %q, want %q", query.Get("resume_token"), "resume-42")
|
||||
}
|
||||
if query.Get("order_id") != "42" {
|
||||
t.Fatalf("order_id = %q, want %q", query.Get("order_id"), "42")
|
||||
}
|
||||
if query.Get("out_trade_no") != "sub2_42" {
|
||||
t.Fatalf("out_trade_no = %q, want %q", query.Get("out_trade_no"), "sub2_42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWxpayJSAPIAppID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers dedicated mp app id",
|
||||
config: map[string]string{
|
||||
"mpAppId": "wx-mp-app",
|
||||
"appId": "wx-merchant-app",
|
||||
},
|
||||
want: "wx-mp-app",
|
||||
},
|
||||
{
|
||||
name: "falls back to merchant app id",
|
||||
config: map[string]string{
|
||||
"appId": "wx-merchant-app",
|
||||
},
|
||||
want: "wx-merchant-app",
|
||||
},
|
||||
{
|
||||
name: "missing app ids returns empty",
|
||||
config: map[string]string{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := ResolveWxpayJSAPIAppID(tt.config); got != tt.want {
|
||||
t.Fatalf("ResolveWxpayJSAPIAppID() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWxpayCreateMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req payment.CreatePaymentRequest
|
||||
wantMode string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "desktop uses native",
|
||||
req: payment.CreatePaymentRequest{},
|
||||
wantMode: wxpayModeNative,
|
||||
},
|
||||
{
|
||||
name: "mobile uses h5 when client ip is present",
|
||||
req: payment.CreatePaymentRequest{
|
||||
IsMobile: true,
|
||||
ClientIP: "203.0.113.10",
|
||||
},
|
||||
wantMode: wxpayModeH5,
|
||||
},
|
||||
{
|
||||
name: "mobile without client ip returns clear error",
|
||||
req: payment.CreatePaymentRequest{
|
||||
IsMobile: true,
|
||||
},
|
||||
wantErr: "requires client IP",
|
||||
},
|
||||
{
|
||||
name: "openid uses jsapi mode",
|
||||
req: payment.CreatePaymentRequest{
|
||||
OpenID: "openid-123",
|
||||
},
|
||||
wantMode: wxpayModeJSAPI,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := resolveWxpayCreateMode(tt.req)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.wantMode {
|
||||
t.Fatalf("resolveWxpayCreateMode() = %q, want %q", got, tt.wantMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentWithOpenIDReturnsJSAPIResult(t *testing.T) {
|
||||
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
|
||||
origNativePrepay := wxpayNativePrepay
|
||||
origH5Prepay := wxpayH5Prepay
|
||||
t.Cleanup(func() {
|
||||
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
|
||||
wxpayNativePrepay = origNativePrepay
|
||||
wxpayH5Prepay = origH5Prepay
|
||||
})
|
||||
|
||||
jsapiCalls := 0
|
||||
nativeCalls := 0
|
||||
h5Calls := 0
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
jsapiCalls++
|
||||
if got := wxSV(req.Payer.Openid); got != "openid-123" {
|
||||
t.Fatalf("openid = %q, want %q", got, "openid-123")
|
||||
}
|
||||
if req.SceneInfo == nil || wxSV(req.SceneInfo.PayerClientIp) != "203.0.113.10" {
|
||||
t.Fatalf("scene_info payer_client_ip = %q, want %q", wxSV(req.SceneInfo.PayerClientIp), "203.0.113.10")
|
||||
}
|
||||
return &jsapi.PrepayWithRequestPaymentResponse{
|
||||
Appid: core.String("wx123"),
|
||||
TimeStamp: core.String("1712345678"),
|
||||
NonceStr: core.String("nonce-123"),
|
||||
Package: core.String("prepay_id=wx_prepay_123"),
|
||||
SignType: core.String("RSA"),
|
||||
PaySign: core.String("signed-payload"),
|
||||
}, nil, nil
|
||||
}
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
nativeCalls++
|
||||
return &native.PrepayResponse{}, nil, nil
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
h5Calls++
|
||||
return &h5.PrepayResponse{}, nil, nil
|
||||
}
|
||||
|
||||
provider := &Wxpay{
|
||||
config: map[string]string{
|
||||
"appId": "wx123",
|
||||
"mchId": "mch123",
|
||||
},
|
||||
coreClient: &core.Client{},
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_88",
|
||||
Amount: "66.88",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
NotifyURL: "https://merchant.example/payment/notify",
|
||||
OpenID: "openid-123",
|
||||
ClientIP: "203.0.113.10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if jsapiCalls != 1 {
|
||||
t.Fatalf("jsapi prepay calls = %d, want 1", jsapiCalls)
|
||||
}
|
||||
if nativeCalls != 0 {
|
||||
t.Fatalf("native prepay calls = %d, want 0", nativeCalls)
|
||||
}
|
||||
if h5Calls != 0 {
|
||||
t.Fatalf("h5 prepay calls = %d, want 0", h5Calls)
|
||||
}
|
||||
if resp.ResultType != payment.CreatePaymentResultJSAPIReady {
|
||||
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultJSAPIReady)
|
||||
}
|
||||
if resp.JSAPI == nil {
|
||||
t.Fatal("expected jsapi payload, got nil")
|
||||
}
|
||||
if resp.JSAPI.AppID != "wx123" {
|
||||
t.Fatalf("jsapi appId = %q, want %q", resp.JSAPI.AppID, "wx123")
|
||||
}
|
||||
if resp.JSAPI.TimeStamp != "1712345678" {
|
||||
t.Fatalf("jsapi timeStamp = %q, want %q", resp.JSAPI.TimeStamp, "1712345678")
|
||||
}
|
||||
if resp.JSAPI.NonceStr != "nonce-123" {
|
||||
t.Fatalf("jsapi nonceStr = %q, want %q", resp.JSAPI.NonceStr, "nonce-123")
|
||||
}
|
||||
if resp.JSAPI.Package != "prepay_id=wx_prepay_123" {
|
||||
t.Fatalf("jsapi package = %q, want %q", resp.JSAPI.Package, "prepay_id=wx_prepay_123")
|
||||
}
|
||||
if resp.JSAPI.SignType != "RSA" {
|
||||
t.Fatalf("jsapi signType = %q, want %q", resp.JSAPI.SignType, "RSA")
|
||||
}
|
||||
if resp.JSAPI.PaySign != "signed-payload" {
|
||||
t.Fatalf("jsapi paySign = %q, want %q", resp.JSAPI.PaySign, "signed-payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMobileH5IncludesConfiguredSceneInfo(t *testing.T) {
|
||||
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
|
||||
origNativePrepay := wxpayNativePrepay
|
||||
origH5Prepay := wxpayH5Prepay
|
||||
t.Cleanup(func() {
|
||||
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
|
||||
wxpayNativePrepay = origNativePrepay
|
||||
wxpayH5Prepay = origH5Prepay
|
||||
})
|
||||
|
||||
jsapiCalls := 0
|
||||
nativeCalls := 0
|
||||
h5Calls := 0
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
jsapiCalls++
|
||||
return &jsapi.PrepayWithRequestPaymentResponse{}, nil, nil
|
||||
}
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
nativeCalls++
|
||||
return &native.PrepayResponse{}, nil, nil
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
h5Calls++
|
||||
if req.SceneInfo == nil {
|
||||
t.Fatal("expected scene_info, got nil")
|
||||
}
|
||||
if got := wxSV(req.SceneInfo.PayerClientIp); got != "203.0.113.10" {
|
||||
t.Fatalf("scene_info payer_client_ip = %q, want %q", got, "203.0.113.10")
|
||||
}
|
||||
if req.SceneInfo.H5Info == nil {
|
||||
t.Fatal("expected scene_info.h5_info, got nil")
|
||||
}
|
||||
if got := wxSV(req.SceneInfo.H5Info.Type); got != wxpayH5Type {
|
||||
t.Fatalf("scene_info.h5_info.type = %q, want %q", got, wxpayH5Type)
|
||||
}
|
||||
if got := wxSV(req.SceneInfo.H5Info.AppName); got != "Sub2API" {
|
||||
t.Fatalf("scene_info.h5_info.app_name = %q, want %q", got, "Sub2API")
|
||||
}
|
||||
if got := wxSV(req.SceneInfo.H5Info.AppUrl); got != "https://app.example.com" {
|
||||
t.Fatalf("scene_info.h5_info.app_url = %q, want %q", got, "https://app.example.com")
|
||||
}
|
||||
return &h5.PrepayResponse{
|
||||
H5Url: core.String("https://wx.tenpay.example/h5pay?prepay_id=1"),
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
provider := &Wxpay{
|
||||
config: map[string]string{
|
||||
"appId": "wx123",
|
||||
"mchId": "mch123",
|
||||
"h5AppName": "Sub2API",
|
||||
"h5AppUrl": "https://app.example.com",
|
||||
},
|
||||
coreClient: &core.Client{},
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_99",
|
||||
Amount: "66.88",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
Subject: "Balance Recharge",
|
||||
NotifyURL: "https://merchant.example/payment/notify",
|
||||
ReturnURL: "https://merchant.example/payment/result?resume_token=resume-99",
|
||||
ClientIP: "203.0.113.10",
|
||||
IsMobile: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if jsapiCalls != 0 {
|
||||
t.Fatalf("jsapi prepay calls = %d, want 0", jsapiCalls)
|
||||
}
|
||||
if nativeCalls != 0 {
|
||||
t.Fatalf("native prepay calls = %d, want 0", nativeCalls)
|
||||
}
|
||||
if h5Calls != 1 {
|
||||
t.Fatalf("h5 prepay calls = %d, want 1", h5Calls)
|
||||
}
|
||||
if !strings.Contains(resp.PayURL, "redirect_url=") {
|
||||
t.Fatalf("pay_url = %q, want redirect_url query appended", resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMobileH5ReturnsNoAuthErrorWithoutNativeFallback(t *testing.T) {
|
||||
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
|
||||
origNativePrepay := wxpayNativePrepay
|
||||
origH5Prepay := wxpayH5Prepay
|
||||
t.Cleanup(func() {
|
||||
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
|
||||
wxpayNativePrepay = origNativePrepay
|
||||
wxpayH5Prepay = origH5Prepay
|
||||
})
|
||||
|
||||
jsapiCalls := 0
|
||||
nativeCalls := 0
|
||||
h5Calls := 0
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
jsapiCalls++
|
||||
return &jsapi.PrepayWithRequestPaymentResponse{}, nil, nil
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
h5Calls++
|
||||
return nil, nil, errors.New("NO_AUTH")
|
||||
}
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
nativeCalls++
|
||||
return &native.PrepayResponse{
|
||||
CodeUrl: core.String("weixin://wxpay/bizpayurl?pr=fallback-native"),
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
provider := &Wxpay{
|
||||
config: map[string]string{
|
||||
"appId": "wx123",
|
||||
"mchId": "mch123",
|
||||
},
|
||||
coreClient: &core.Client{},
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_100",
|
||||
Amount: "66.88",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
Subject: "Balance Recharge",
|
||||
NotifyURL: "https://merchant.example/payment/notify",
|
||||
ClientIP: "203.0.113.10",
|
||||
IsMobile: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-auth error, got nil")
|
||||
}
|
||||
if jsapiCalls != 0 {
|
||||
t.Fatalf("jsapi prepay calls = %d, want 0", jsapiCalls)
|
||||
}
|
||||
if h5Calls != 1 {
|
||||
t.Fatalf("h5 prepay calls = %d, want 1", h5Calls)
|
||||
}
|
||||
if nativeCalls != 0 {
|
||||
t.Fatalf("native prepay calls = %d, want 0", nativeCalls)
|
||||
}
|
||||
if resp != nil {
|
||||
t.Fatalf("expected nil response, got %+v", resp)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "NO_AUTH") {
|
||||
t.Fatalf("error = %v, want NO_AUTH", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user