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

This commit is contained in:
李建琦
2026-08-21 18:30:13 +08:00
commit 6d655c9903
3584 changed files with 1270640 additions and 0 deletions
@@ -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)),
}
}
+410
View File
@@ -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)
}
}
+353
View File
@@ -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)
}
+568
View File
@@ -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)
}
}