Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
package payment
|
||||
|
||||
func YuanToFen(yuanStr string) (int64, error) {
|
||||
return AmountToMinorUnit(yuanStr, DefaultPaymentCurrency)
|
||||
}
|
||||
|
||||
func FenToYuan(fen int64) float64 {
|
||||
return MinorUnitToAmount(fen, DefaultPaymentCurrency)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//go:build unit
|
||||
|
||||
package payment
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestYuanToFen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
// Normal values
|
||||
{name: "one yuan", input: "1.00", want: 100},
|
||||
{name: "ten yuan fifty fen", input: "10.50", want: 1050},
|
||||
{name: "one fen", input: "0.01", want: 1},
|
||||
{name: "large amount", input: "99999.99", want: 9999999},
|
||||
|
||||
// Edge: zero
|
||||
{name: "zero no decimal", input: "0", want: 0},
|
||||
{name: "zero with decimal", input: "0.00", want: 0},
|
||||
|
||||
// IEEE 754 precision edge case: 1.15 * 100 = 114.99999... in float64
|
||||
{name: "ieee754 precision 1.15", input: "1.15", want: 115},
|
||||
|
||||
// More precision edge cases
|
||||
{name: "ieee754 precision 0.1", input: "0.1", want: 10},
|
||||
{name: "ieee754 precision 0.2", input: "0.2", want: 20},
|
||||
{name: "ieee754 precision 33.33", input: "33.33", want: 3333},
|
||||
|
||||
// Large value
|
||||
{name: "hundred thousand", input: "100000.00", want: 10000000},
|
||||
|
||||
// Integer without decimal
|
||||
{name: "integer 5", input: "5", want: 500},
|
||||
{name: "integer 100", input: "100", want: 10000},
|
||||
|
||||
// Single decimal place
|
||||
{name: "single decimal 1.5", input: "1.5", want: 150},
|
||||
|
||||
// Negative values
|
||||
{name: "negative one yuan", input: "-1.00", want: -100},
|
||||
{name: "negative with fen", input: "-10.50", want: -1050},
|
||||
|
||||
// Invalid inputs
|
||||
{name: "empty string", input: "", wantErr: true},
|
||||
{name: "alphabetic", input: "abc", wantErr: true},
|
||||
{name: "double dot", input: "1.2.3", wantErr: true},
|
||||
{name: "spaces", input: " ", wantErr: true},
|
||||
{name: "special chars", input: "$10.00", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := YuanToFen(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("YuanToFen(%q) expected error, got %d", tt.input, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("YuanToFen(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("YuanToFen(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFenToYuan(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fen int64
|
||||
want float64
|
||||
}{
|
||||
{name: "one yuan", fen: 100, want: 1.0},
|
||||
{name: "ten yuan fifty fen", fen: 1050, want: 10.5},
|
||||
{name: "one fen", fen: 1, want: 0.01},
|
||||
{name: "zero", fen: 0, want: 0.0},
|
||||
{name: "large amount", fen: 9999999, want: 99999.99},
|
||||
{name: "negative", fen: -100, want: -1.0},
|
||||
{name: "negative with fen", fen: -1050, want: -10.5},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FenToYuan(tt.fen)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("FenToYuan(%d) = %f, want %f", tt.fen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestYuanToFenRoundTrip(t *testing.T) {
|
||||
// Verify that converting yuan->fen->yuan preserves the value.
|
||||
cases := []struct {
|
||||
yuan string
|
||||
fen int64
|
||||
}{
|
||||
{"0.01", 1},
|
||||
{"1.00", 100},
|
||||
{"10.50", 1050},
|
||||
{"99999.99", 9999999},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
fen, err := YuanToFen(tc.yuan)
|
||||
if err != nil {
|
||||
t.Fatalf("YuanToFen(%q) unexpected error: %v", tc.yuan, err)
|
||||
}
|
||||
if fen != tc.fen {
|
||||
t.Errorf("YuanToFen(%q) = %d, want %d", tc.yuan, fen, tc.fen)
|
||||
}
|
||||
yuan := FenToYuan(fen)
|
||||
// Parse expected yuan back for comparison
|
||||
expectedYuan := FenToYuan(tc.fen)
|
||||
if math.Abs(yuan-expectedYuan) > 1e-9 {
|
||||
t.Errorf("round-trip: FenToYuan(%d) = %f, want %f", fen, yuan, expectedYuan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCurrencyHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
currency string
|
||||
amount string
|
||||
wantMinor int64
|
||||
wantBack float64
|
||||
}{
|
||||
{name: "hkd uses cents", currency: "hkd", amount: "12.34", wantMinor: 1234, wantBack: 12.34},
|
||||
{name: "jpy has no minor unit", currency: "JPY", amount: "12", wantMinor: 12, wantBack: 12},
|
||||
{name: "kwd uses three decimal minor units", currency: "KWD", amount: "12.345", wantMinor: 12345, wantBack: 12.345},
|
||||
{name: "isk uses Stripe legacy two-decimal API amount", currency: "ISK", amount: "12", wantMinor: 1200, wantBack: 12},
|
||||
{name: "ugx uses Stripe legacy two-decimal API amount", currency: "UGX", amount: "12.00", wantMinor: 1200, wantBack: 12},
|
||||
{name: "empty currency defaults to cny", currency: "", amount: "1.23", wantMinor: 123, wantBack: 1.23},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := AmountToMinorUnit(tt.amount, tt.currency)
|
||||
if err != nil {
|
||||
t.Fatalf("AmountToMinorUnit(%q, %q) unexpected error: %v", tt.amount, tt.currency, err)
|
||||
}
|
||||
if got != tt.wantMinor {
|
||||
t.Fatalf("AmountToMinorUnit(%q, %q) = %d, want %d", tt.amount, tt.currency, got, tt.wantMinor)
|
||||
}
|
||||
back := MinorUnitToAmount(got, tt.currency)
|
||||
if math.Abs(back-tt.wantBack) > 1e-9 {
|
||||
t.Fatalf("MinorUnitToAmount(%d, %q) = %f, want %f", got, tt.currency, back, tt.wantBack)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAmountForCurrency(t *testing.T) {
|
||||
tests := []struct {
|
||||
currency string
|
||||
amount float64
|
||||
want string
|
||||
}{
|
||||
{currency: "CNY", amount: 12.3, want: "12.30"},
|
||||
{currency: "JPY", amount: 12, want: "12"},
|
||||
{currency: "KWD", amount: 12.345, want: "12.345"},
|
||||
{currency: "ISK", amount: 12, want: "12"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.currency, func(t *testing.T) {
|
||||
if got := FormatAmountForCurrency(tt.amount, tt.currency); got != tt.want {
|
||||
t.Fatalf("FormatAmountForCurrency(%v, %q) = %q, want %q", tt.amount, tt.currency, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmountToMinorUnitRejectsUnsupportedPrecision(t *testing.T) {
|
||||
if _, err := AmountToMinorUnit("100.50", "JPY"); err == nil {
|
||||
t.Fatal("expected fractional JPY amount to fail")
|
||||
}
|
||||
if _, err := AmountToMinorUnit("100.50", "ISK"); err == nil {
|
||||
t.Fatal("expected fractional ISK amount to fail")
|
||||
}
|
||||
if _, err := AmountToMinorUnit("100.50", "UGX"); err == nil {
|
||||
t.Fatal("expected fractional UGX amount to fail")
|
||||
}
|
||||
if _, err := AmountToMinorUnit("12.345", "HKD"); err == nil {
|
||||
t.Fatal("expected amount with more than two decimal places to fail")
|
||||
}
|
||||
if _, err := AmountToMinorUnit("12.3456", "KWD"); err == nil {
|
||||
t.Fatal("expected amount with more than three decimal places to fail")
|
||||
}
|
||||
if got, err := AmountToMinorUnit("100.00", "JPY"); err != nil || got != 100 {
|
||||
t.Fatalf("AmountToMinorUnit integer-form JPY = (%d, %v), want (100, nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreeDecimalPaymentCurrencies(t *testing.T) {
|
||||
for _, currency := range []string{"BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"} {
|
||||
t.Run(currency, func(t *testing.T) {
|
||||
got, err := AmountToMinorUnit("12.345", currency)
|
||||
if err != nil {
|
||||
t.Fatalf("AmountToMinorUnit(%q, %q) unexpected error: %v", "12.345", currency, err)
|
||||
}
|
||||
if got != 12345 {
|
||||
t.Fatalf("AmountToMinorUnit(%q, %q) = %d, want 12345", "12.345", currency, got)
|
||||
}
|
||||
if back := MinorUnitToAmount(got, currency); math.Abs(back-12.345) > 1e-9 {
|
||||
t.Fatalf("MinorUnitToAmount(%d, %q) = %f, want 12.345", got, currency, back)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaymentCurrencyRejectsInvalidCodes(t *testing.T) {
|
||||
if _, err := NormalizePaymentCurrency("HK"); err == nil {
|
||||
t.Fatal("expected invalid two-letter currency to fail")
|
||||
}
|
||||
if _, err := NormalizePaymentCurrency("US1"); err == nil {
|
||||
t.Fatal("expected non-letter currency to fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AES256KeySize is the required key length (in bytes) for AES-256-GCM.
|
||||
const AES256KeySize = 32
|
||||
|
||||
// Encrypt encrypts plaintext using AES-256-GCM with the given 32-byte key.
|
||||
// The output format is "iv:authTag:ciphertext" where each component is base64-encoded,
|
||||
// matching the Node.js crypto.ts format for cross-compatibility.
|
||||
//
|
||||
// Deprecated: payment provider configs are now stored as plaintext JSON.
|
||||
// This function is kept only for seeding legacy ciphertext in tests and for
|
||||
// the transitional Decrypt fallback. Scheduled for removal after all live
|
||||
// deployments complete migration by re-saving their configs.
|
||||
func Encrypt(plaintext string, key []byte) (string, error) {
|
||||
if len(key) != AES256KeySize {
|
||||
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create AES cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize()) // 12 bytes for GCM
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Seal appends the ciphertext + auth tag
|
||||
sealed := gcm.Seal(nil, nonce, []byte(plaintext), nil)
|
||||
|
||||
// Split sealed into ciphertext and auth tag (last 16 bytes)
|
||||
tagSize := gcm.Overhead()
|
||||
ciphertext := sealed[:len(sealed)-tagSize]
|
||||
authTag := sealed[len(sealed)-tagSize:]
|
||||
|
||||
// Format: iv:authTag:ciphertext (all base64)
|
||||
return fmt.Sprintf("%s:%s:%s",
|
||||
base64.StdEncoding.EncodeToString(nonce),
|
||||
base64.StdEncoding.EncodeToString(authTag),
|
||||
base64.StdEncoding.EncodeToString(ciphertext),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts a ciphertext string produced by Encrypt.
|
||||
// The input format is "iv:authTag:ciphertext" where each component is base64-encoded.
|
||||
//
|
||||
// Deprecated: payment provider configs are now stored as plaintext JSON.
|
||||
// This function remains only as a read-path fallback for pre-migration
|
||||
// ciphertext records. Scheduled for removal once all deployments re-save
|
||||
// their provider configs through the admin UI.
|
||||
func Decrypt(ciphertext string, key []byte) (string, error) {
|
||||
if len(key) != AES256KeySize {
|
||||
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
|
||||
}
|
||||
|
||||
parts := strings.SplitN(ciphertext, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
return "", fmt.Errorf("invalid ciphertext format: expected iv:authTag:ciphertext")
|
||||
}
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode IV: %w", err)
|
||||
}
|
||||
|
||||
authTag, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode auth tag: %w", err)
|
||||
}
|
||||
|
||||
encrypted, err := base64.StdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create AES cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create GCM: %w", err)
|
||||
}
|
||||
|
||||
// Reconstruct the sealed data: ciphertext + authTag
|
||||
sealed := append(encrypted, authTag...)
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, sealed, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeKey(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatalf("generate random key: %v", err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := makeKey(t)
|
||||
|
||||
plaintexts := []string{
|
||||
"hello world",
|
||||
"short",
|
||||
"a longer string with special chars: !@#$%^&*()",
|
||||
`{"key":"value","num":42}`,
|
||||
"你好世界 unicode test 🎉",
|
||||
strings.Repeat("x", 10000),
|
||||
}
|
||||
|
||||
for _, pt := range plaintexts {
|
||||
encrypted, err := Encrypt(pt, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt(%q) error: %v", pt[:min(len(pt), 30)], err)
|
||||
}
|
||||
decrypted, err := Decrypt(encrypted, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt error for plaintext %q: %v", pt[:min(len(pt), 30)], err)
|
||||
}
|
||||
if decrypted != pt {
|
||||
t.Fatalf("round-trip failed: got %q, want %q", decrypted[:min(len(decrypted), 30)], pt[:min(len(pt), 30)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := makeKey(t)
|
||||
|
||||
ct1, err := Encrypt("same plaintext", key)
|
||||
if err != nil {
|
||||
t.Fatalf("first Encrypt error: %v", err)
|
||||
}
|
||||
ct2, err := Encrypt("same plaintext", key)
|
||||
if err != nil {
|
||||
t.Fatalf("second Encrypt error: %v", err)
|
||||
}
|
||||
if ct1 == ct2 {
|
||||
t.Fatal("two encryptions of the same plaintext should produce different ciphertexts (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithWrongKeyFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
key1 := makeKey(t)
|
||||
key2 := makeKey(t)
|
||||
|
||||
encrypted, err := Encrypt("secret data", key1)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt error: %v", err)
|
||||
}
|
||||
|
||||
_, err = Decrypt(encrypted, key2)
|
||||
if err == nil {
|
||||
t.Fatal("Decrypt with wrong key should fail, but got nil error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptRejectsInvalidKeyLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
badKeys := [][]byte{
|
||||
nil,
|
||||
make([]byte, 0),
|
||||
make([]byte, 16),
|
||||
make([]byte, 31),
|
||||
make([]byte, 33),
|
||||
make([]byte, 64),
|
||||
}
|
||||
for _, key := range badKeys {
|
||||
_, err := Encrypt("test", key)
|
||||
if err == nil {
|
||||
t.Fatalf("Encrypt should reject key of length %d", len(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsInvalidKeyLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
badKeys := [][]byte{
|
||||
nil,
|
||||
make([]byte, 16),
|
||||
make([]byte, 33),
|
||||
}
|
||||
for _, key := range badKeys {
|
||||
_, err := Decrypt("dummydata:dummydata:dummydata", key)
|
||||
if err == nil {
|
||||
t.Fatalf("Decrypt should reject key of length %d", len(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptEmptyPlaintext(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := makeKey(t)
|
||||
|
||||
encrypted, err := Encrypt("", key)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt empty plaintext error: %v", err)
|
||||
}
|
||||
decrypted, err := Decrypt(encrypted, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt empty plaintext error: %v", err)
|
||||
}
|
||||
if decrypted != "" {
|
||||
t.Fatalf("expected empty string, got %q", decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptUnicodeJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := makeKey(t)
|
||||
|
||||
jsonContent := `{"name":"测试用户","email":"test@example.com","balance":100.50}`
|
||||
encrypted, err := Encrypt(jsonContent, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt JSON error: %v", err)
|
||||
}
|
||||
decrypted, err := Decrypt(encrypted, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt JSON error: %v", err)
|
||||
}
|
||||
if decrypted != jsonContent {
|
||||
t.Fatalf("JSON round-trip failed: got %q, want %q", decrypted, jsonContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := makeKey(t)
|
||||
|
||||
invalidInputs := []string{
|
||||
"",
|
||||
"nodelimiter",
|
||||
"only:two",
|
||||
"invalid:base64:!!!",
|
||||
}
|
||||
for _, input := range invalidInputs {
|
||||
_, err := Decrypt(input, key)
|
||||
if err == nil {
|
||||
t.Fatalf("Decrypt(%q) should fail but got nil error", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCiphertextFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := makeKey(t)
|
||||
|
||||
encrypted, err := Encrypt("test", key)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt error: %v", err)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(encrypted, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("ciphertext should have format iv:authTag:ciphertext, got %d parts", len(parts))
|
||||
}
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
t.Fatalf("ciphertext part %d is empty", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
const DefaultPaymentCurrency = "CNY"
|
||||
|
||||
type paymentCurrencyAmountUnit struct {
|
||||
apiMinorUnit int
|
||||
maxFractionDigits int
|
||||
}
|
||||
|
||||
var (
|
||||
zeroDecimalAmountUnit = paymentCurrencyAmountUnit{apiMinorUnit: 0, maxFractionDigits: 0}
|
||||
twoDecimalAmountUnit = paymentCurrencyAmountUnit{apiMinorUnit: 2, maxFractionDigits: 2}
|
||||
threeDecimalAmountUnit = paymentCurrencyAmountUnit{apiMinorUnit: 3, maxFractionDigits: 3}
|
||||
stripeLegacyZeroAmount = paymentCurrencyAmountUnit{apiMinorUnit: 2, maxFractionDigits: 0}
|
||||
)
|
||||
|
||||
var paymentCurrencyAmountUnits = map[string]paymentCurrencyAmountUnit{
|
||||
"BIF": zeroDecimalAmountUnit,
|
||||
"CLP": zeroDecimalAmountUnit,
|
||||
"DJF": zeroDecimalAmountUnit,
|
||||
"GNF": zeroDecimalAmountUnit,
|
||||
"JPY": zeroDecimalAmountUnit,
|
||||
"KMF": zeroDecimalAmountUnit,
|
||||
"KRW": zeroDecimalAmountUnit,
|
||||
"MGA": zeroDecimalAmountUnit,
|
||||
"PYG": zeroDecimalAmountUnit,
|
||||
"RWF": zeroDecimalAmountUnit,
|
||||
"VND": zeroDecimalAmountUnit,
|
||||
"VUV": zeroDecimalAmountUnit,
|
||||
"XAF": zeroDecimalAmountUnit,
|
||||
"XOF": zeroDecimalAmountUnit,
|
||||
"XPF": zeroDecimalAmountUnit,
|
||||
"ISK": stripeLegacyZeroAmount,
|
||||
"UGX": stripeLegacyZeroAmount,
|
||||
"BHD": threeDecimalAmountUnit,
|
||||
"IQD": threeDecimalAmountUnit,
|
||||
"JOD": threeDecimalAmountUnit,
|
||||
"KWD": threeDecimalAmountUnit,
|
||||
"LYD": threeDecimalAmountUnit,
|
||||
"OMR": threeDecimalAmountUnit,
|
||||
"TND": threeDecimalAmountUnit,
|
||||
}
|
||||
|
||||
func NormalizePaymentCurrency(raw string) (string, error) {
|
||||
currency := strings.ToUpper(strings.TrimSpace(raw))
|
||||
if currency == "" {
|
||||
return DefaultPaymentCurrency, nil
|
||||
}
|
||||
if len(currency) != 3 {
|
||||
return "", fmt.Errorf("payment currency must be a 3-letter ISO currency code")
|
||||
}
|
||||
for _, ch := range currency {
|
||||
if ch < 'A' || ch > 'Z' {
|
||||
return "", fmt.Errorf("payment currency must be a 3-letter ISO currency code")
|
||||
}
|
||||
}
|
||||
return currency, nil
|
||||
}
|
||||
|
||||
func CurrencyMinorUnit(currency string) int {
|
||||
return paymentCurrencyAmountUnitFor(currency).apiMinorUnit
|
||||
}
|
||||
|
||||
// CurrencyMaxFractionDigits 返回支付金额允许展示和输入的小数位数。
|
||||
func CurrencyMaxFractionDigits(currency string) int {
|
||||
return paymentCurrencyAmountUnitFor(currency).maxFractionDigits
|
||||
}
|
||||
|
||||
// FormatAmountForCurrency 按币种允许的小数位格式化支付金额。
|
||||
func FormatAmountForCurrency(amount float64, currency string) string {
|
||||
return decimal.NewFromFloat(amount).StringFixed(int32(CurrencyMaxFractionDigits(currency)))
|
||||
}
|
||||
|
||||
func paymentCurrencyAmountUnitFor(currency string) paymentCurrencyAmountUnit {
|
||||
normalized, err := NormalizePaymentCurrency(currency)
|
||||
if err != nil {
|
||||
return twoDecimalAmountUnit
|
||||
}
|
||||
if amountUnit, ok := paymentCurrencyAmountUnits[normalized]; ok {
|
||||
return amountUnit
|
||||
}
|
||||
return twoDecimalAmountUnit
|
||||
}
|
||||
|
||||
func AmountToMinorUnit(amountStr, currency string) (int64, error) {
|
||||
d, err := decimal.NewFromString(strings.TrimSpace(amountStr))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid amount: %s", amountStr)
|
||||
}
|
||||
normalizedCurrency, err := NormalizePaymentCurrency(currency)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
amountUnit := paymentCurrencyAmountUnitFor(normalizedCurrency)
|
||||
precisionFactor := decimal.New(1, int32(amountUnit.maxFractionDigits))
|
||||
scaledForPrecision := d.Mul(precisionFactor)
|
||||
if !scaledForPrecision.Equal(scaledForPrecision.Truncate(0)) {
|
||||
if amountUnit.maxFractionDigits == 0 {
|
||||
return 0, fmt.Errorf("payment amount for %s must be a whole number", normalizedCurrency)
|
||||
}
|
||||
return 0, fmt.Errorf("payment amount for %s must not have more than %d decimal places", normalizedCurrency, amountUnit.maxFractionDigits)
|
||||
}
|
||||
factor := decimal.New(1, int32(amountUnit.apiMinorUnit))
|
||||
minorAmount := d.Mul(factor)
|
||||
return minorAmount.IntPart(), nil
|
||||
}
|
||||
|
||||
func MinorUnitToAmount(value int64, currency string) float64 {
|
||||
factor := decimal.New(1, int32(CurrencyMinorUnit(currency)))
|
||||
return decimal.NewFromInt(value).Div(factor).InexactFloat64()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
func CalculatePayAmount(rechargeAmount float64, feeRate float64) string {
|
||||
return CalculatePayAmountForCurrency(rechargeAmount, feeRate, DefaultPaymentCurrency)
|
||||
}
|
||||
|
||||
// CalculatePayAmountForCurrency 按币种精度计算应付金额,手续费向上取整到该币种最小支付单位。
|
||||
func CalculatePayAmountForCurrency(rechargeAmount float64, feeRate float64, currency string) string {
|
||||
fractionDigits := int32(CurrencyMaxFractionDigits(currency))
|
||||
amount := decimal.NewFromFloat(rechargeAmount)
|
||||
if feeRate <= 0 {
|
||||
return amount.StringFixed(fractionDigits)
|
||||
}
|
||||
rate := decimal.NewFromFloat(feeRate)
|
||||
fee := amount.Mul(rate).Div(decimal.NewFromInt(100)).RoundUp(fractionDigits)
|
||||
return amount.Add(fee).StringFixed(fractionDigits)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculatePayAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
amount float64
|
||||
feeRate float64
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "zero fee rate returns same amount",
|
||||
amount: 100.00,
|
||||
feeRate: 0,
|
||||
expected: "100.00",
|
||||
},
|
||||
{
|
||||
name: "negative fee rate returns same amount",
|
||||
amount: 50.00,
|
||||
feeRate: -5,
|
||||
expected: "50.00",
|
||||
},
|
||||
{
|
||||
name: "1 percent fee rate",
|
||||
amount: 100.00,
|
||||
feeRate: 1,
|
||||
expected: "101.00",
|
||||
},
|
||||
{
|
||||
name: "5 percent fee on 200",
|
||||
amount: 200.00,
|
||||
feeRate: 5,
|
||||
expected: "210.00",
|
||||
},
|
||||
{
|
||||
name: "fee rounds UP to 2 decimal places",
|
||||
amount: 100.00,
|
||||
feeRate: 3,
|
||||
expected: "103.00",
|
||||
},
|
||||
{
|
||||
name: "fee rounds UP small remainder",
|
||||
amount: 10.00,
|
||||
feeRate: 3.33,
|
||||
expected: "10.34", // 10 * 3.33 / 100 = 0.333 -> round up -> 0.34
|
||||
},
|
||||
{
|
||||
name: "very small amount",
|
||||
amount: 0.01,
|
||||
feeRate: 1,
|
||||
expected: "0.02", // 0.01 * 1/100 = 0.0001 -> round up -> 0.01 -> total 0.02
|
||||
},
|
||||
{
|
||||
name: "large amount",
|
||||
amount: 99999.99,
|
||||
feeRate: 10,
|
||||
expected: "109999.99", // 99999.99 * 10/100 = 9999.999 -> round up -> 10000.00 -> total 109999.99
|
||||
},
|
||||
{
|
||||
name: "100 percent fee rate doubles amount",
|
||||
amount: 50.00,
|
||||
feeRate: 100,
|
||||
expected: "100.00",
|
||||
},
|
||||
{
|
||||
name: "precision 0.01 fee difference",
|
||||
amount: 100.00,
|
||||
feeRate: 1.01,
|
||||
expected: "101.01", // 100 * 1.01/100 = 1.01
|
||||
},
|
||||
{
|
||||
name: "precision 0.02 fee",
|
||||
amount: 100.00,
|
||||
feeRate: 1.02,
|
||||
expected: "101.02",
|
||||
},
|
||||
{
|
||||
name: "zero amount with positive fee",
|
||||
amount: 0,
|
||||
feeRate: 5,
|
||||
expected: "0.00",
|
||||
},
|
||||
{
|
||||
name: "fractional amount no fee",
|
||||
amount: 19.99,
|
||||
feeRate: 0,
|
||||
expected: "19.99",
|
||||
},
|
||||
{
|
||||
name: "fractional fee that causes rounding up",
|
||||
amount: 33.33,
|
||||
feeRate: 7.77,
|
||||
expected: "35.92", // 33.33 * 7.77 / 100 = 2.589741 -> round up -> 2.59 -> total 35.92
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := CalculatePayAmount(tt.amount, tt.feeRate)
|
||||
if got != tt.expected {
|
||||
t.Fatalf("CalculatePayAmount(%v, %v) = %q, want %q", tt.amount, tt.feeRate, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePayAmountForCurrency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
amount float64
|
||||
feeRate float64
|
||||
currency string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "zero decimal currency rounds fee up to whole unit",
|
||||
amount: 100,
|
||||
feeRate: 2.5,
|
||||
currency: "JPY",
|
||||
expected: "103",
|
||||
},
|
||||
{
|
||||
name: "three decimal currency keeps three decimal places",
|
||||
amount: 12.345,
|
||||
feeRate: 1,
|
||||
currency: "KWD",
|
||||
expected: "12.469",
|
||||
},
|
||||
{
|
||||
name: "stripe legacy zero decimal currency displays whole unit",
|
||||
amount: 100,
|
||||
feeRate: 2.5,
|
||||
currency: "ISK",
|
||||
expected: "103",
|
||||
},
|
||||
{
|
||||
name: "default currency keeps existing two decimal behavior",
|
||||
amount: 10,
|
||||
feeRate: 3.33,
|
||||
currency: "CNY",
|
||||
expected: "10.34",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := CalculatePayAmountForCurrency(tt.amount, tt.feeRate, tt.currency)
|
||||
if got != tt.expected {
|
||||
t.Fatalf("CalculatePayAmountForCurrency(%v, %v, %q) = %q, want %q", tt.amount, tt.feeRate, tt.currency, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||||
)
|
||||
|
||||
// Strategy represents a load balancing strategy for provider instance selection.
|
||||
type Strategy string
|
||||
|
||||
const (
|
||||
StrategyRoundRobin Strategy = "round-robin"
|
||||
StrategyLeastAmount Strategy = "least-amount"
|
||||
)
|
||||
|
||||
// ChannelLimits holds limits for a single payment channel within a provider instance.
|
||||
type ChannelLimits struct {
|
||||
DailyLimit float64 `json:"dailyLimit,omitempty"`
|
||||
SingleMin float64 `json:"singleMin,omitempty"`
|
||||
SingleMax float64 `json:"singleMax,omitempty"`
|
||||
}
|
||||
|
||||
// InstanceLimits holds per-channel limits for a provider instance (JSON).
|
||||
type InstanceLimits map[string]ChannelLimits
|
||||
|
||||
// LoadBalancer selects a provider instance for a given payment type.
|
||||
type LoadBalancer interface {
|
||||
GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error)
|
||||
SelectInstance(ctx context.Context, providerKey string, paymentType PaymentType, strategy Strategy, orderAmount float64) (*InstanceSelection, error)
|
||||
}
|
||||
|
||||
// DefaultLoadBalancer implements LoadBalancer using database queries.
|
||||
type DefaultLoadBalancer struct {
|
||||
db *dbent.Client
|
||||
encryptionKey []byte
|
||||
counter atomic.Uint64
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const wxpayJSAPIAppIDContextKey contextKey = "payment.wxpay.jsapi_app_id"
|
||||
|
||||
// NewDefaultLoadBalancer creates a new load balancer.
|
||||
func NewDefaultLoadBalancer(db *dbent.Client, encryptionKey []byte) *DefaultLoadBalancer {
|
||||
return &DefaultLoadBalancer{db: db, encryptionKey: encryptionKey}
|
||||
}
|
||||
|
||||
func WithWxpayJSAPIAppID(ctx context.Context, appID string) context.Context {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, wxpayJSAPIAppIDContextKey, appID)
|
||||
}
|
||||
|
||||
func wxpayJSAPIAppIDFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
appID, _ := ctx.Value(wxpayJSAPIAppIDContextKey).(string)
|
||||
return strings.TrimSpace(appID)
|
||||
}
|
||||
|
||||
// instanceCandidate pairs an instance with its pre-fetched daily usage.
|
||||
type instanceCandidate struct {
|
||||
inst *dbent.PaymentProviderInstance
|
||||
dailyUsed float64 // includes PENDING orders
|
||||
}
|
||||
|
||||
// SelectInstance picks an enabled instance for the given provider key and payment type.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Query all enabled instances for providerKey, filter by supported paymentType
|
||||
// 2. Batch-query daily usage (PENDING + PAID + COMPLETED + RECHARGING) for all candidates
|
||||
// 3. Filter out instances where: single-min/max violated OR daily remaining < orderAmount
|
||||
// 4. Pick from survivors using the configured strategy (round-robin / least-amount)
|
||||
// 5. If all filtered out, fall back to full list (let the provider itself reject)
|
||||
func (lb *DefaultLoadBalancer) SelectInstance(
|
||||
ctx context.Context,
|
||||
providerKey string,
|
||||
paymentType PaymentType,
|
||||
strategy Strategy,
|
||||
orderAmount float64,
|
||||
) (*InstanceSelection, error) {
|
||||
// Step 1: query enabled instances matching payment type.
|
||||
instances, err := lb.queryEnabledInstances(ctx, providerKey, paymentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 2: batch-fetch daily usage for all candidates.
|
||||
candidates := lb.attachDailyUsage(ctx, instances)
|
||||
|
||||
// Step 3: filter by limits.
|
||||
available := filterByLimits(candidates, paymentType, orderAmount)
|
||||
if len(available) == 0 {
|
||||
slog.Warn("all instances exceeded limits, using full candidate list",
|
||||
"provider", providerKey, "payment_type", paymentType,
|
||||
"order_amount", orderAmount, "count", len(candidates))
|
||||
available = candidates
|
||||
}
|
||||
|
||||
// Step 4: pick by strategy.
|
||||
selected := lb.pickByStrategy(available, strategy)
|
||||
return lb.buildSelection(selected.inst)
|
||||
}
|
||||
|
||||
// queryEnabledInstances returns enabled instances that support paymentType.
|
||||
// When providerKey is non-empty, only instances with that provider key are considered.
|
||||
// When providerKey is empty, instances across all providers are considered,
|
||||
// enabling cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay").
|
||||
func (lb *DefaultLoadBalancer) queryEnabledInstances(
|
||||
ctx context.Context,
|
||||
providerKey string,
|
||||
paymentType PaymentType,
|
||||
) ([]*dbent.PaymentProviderInstance, error) {
|
||||
query := lb.db.PaymentProviderInstance.Query().
|
||||
Where(paymentproviderinstance.Enabled(true))
|
||||
if providerKey != "" {
|
||||
query = query.Where(paymentproviderinstance.ProviderKey(providerKey))
|
||||
}
|
||||
instances, err := query.
|
||||
Order(dbent.Asc(paymentproviderinstance.FieldSortOrder)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query provider instances: %w", err)
|
||||
}
|
||||
|
||||
var matched []*dbent.PaymentProviderInstance
|
||||
expectedWxpayJSAPIAppID := wxpayJSAPIAppIDFromContext(ctx)
|
||||
for _, inst := range instances {
|
||||
// Stripe: match by provider_key because supported_types lists sub-types (card,link,alipay,wxpay),
|
||||
// not "stripe" itself. The checkout page aggregates all sub-types under "stripe".
|
||||
if paymentType == TypeStripe {
|
||||
if inst.ProviderKey == TypeStripe {
|
||||
matched = append(matched, inst)
|
||||
}
|
||||
} else if InstanceSupportsType(inst.SupportedTypes, paymentType) {
|
||||
if expectedWxpayJSAPIAppID != "" && normalizeVisibleMethodSupportType(paymentType) == TypeWxpay && inst.ProviderKey == TypeWxpay {
|
||||
config, cfgErr := lb.decryptConfig(inst.Config)
|
||||
if cfgErr != nil {
|
||||
slog.Warn("skip wxpay instance with unreadable config during jsapi filtering", "instance_id", inst.ID, "error", cfgErr)
|
||||
continue
|
||||
}
|
||||
if resolveWxpayJSAPIAppID(config) != expectedWxpayJSAPIAppID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
matched = append(matched, inst)
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return nil, fmt.Errorf("no enabled instance for payment type %s", paymentType)
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
// attachDailyUsage queries daily usage for each instance in a single pass.
|
||||
// Usage includes PENDING orders to avoid over-committing capacity.
|
||||
func (lb *DefaultLoadBalancer) attachDailyUsage(
|
||||
ctx context.Context,
|
||||
instances []*dbent.PaymentProviderInstance,
|
||||
) []instanceCandidate {
|
||||
todayStart := startOfDay(time.Now())
|
||||
|
||||
// Collect instance IDs.
|
||||
ids := make([]string, len(instances))
|
||||
for i, inst := range instances {
|
||||
ids[i] = fmt.Sprintf("%d", inst.ID)
|
||||
}
|
||||
|
||||
// Batch query: sum pay_amount grouped by provider_instance_id.
|
||||
type row struct {
|
||||
InstanceID string `json:"provider_instance_id"`
|
||||
Sum float64 `json:"sum"`
|
||||
}
|
||||
var rows []row
|
||||
err := lb.db.PaymentOrder.Query().
|
||||
Where(
|
||||
paymentorder.ProviderInstanceIDIn(ids...),
|
||||
paymentorder.StatusIn(
|
||||
OrderStatusPending, OrderStatusPaid,
|
||||
OrderStatusCompleted, OrderStatusRecharging,
|
||||
),
|
||||
paymentorder.CreatedAtGTE(todayStart),
|
||||
).
|
||||
GroupBy(paymentorder.FieldProviderInstanceID).
|
||||
Aggregate(dbent.Sum(paymentorder.FieldPayAmount)).
|
||||
Scan(ctx, &rows)
|
||||
if err != nil {
|
||||
slog.Warn("batch daily usage query failed, treating all as zero", "error", err)
|
||||
}
|
||||
|
||||
usageMap := make(map[string]float64, len(rows))
|
||||
for _, r := range rows {
|
||||
usageMap[r.InstanceID] = r.Sum
|
||||
}
|
||||
|
||||
candidates := make([]instanceCandidate, len(instances))
|
||||
for i, inst := range instances {
|
||||
candidates[i] = instanceCandidate{
|
||||
inst: inst,
|
||||
dailyUsed: usageMap[fmt.Sprintf("%d", inst.ID)],
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// filterByLimits removes instances that cannot accommodate the order:
|
||||
// - orderAmount outside single-transaction [min, max]
|
||||
// - daily remaining capacity (limit - used) < orderAmount
|
||||
func filterByLimits(candidates []instanceCandidate, paymentType PaymentType, orderAmount float64) []instanceCandidate {
|
||||
var result []instanceCandidate
|
||||
for _, c := range candidates {
|
||||
cl := getInstanceChannelLimits(c.inst, paymentType)
|
||||
|
||||
if cl.SingleMin > 0 && orderAmount < cl.SingleMin {
|
||||
slog.Info("order below instance single min, skipping",
|
||||
"instance_id", c.inst.ID, "order", orderAmount, "min", cl.SingleMin)
|
||||
continue
|
||||
}
|
||||
if cl.SingleMax > 0 && orderAmount > cl.SingleMax {
|
||||
slog.Info("order above instance single max, skipping",
|
||||
"instance_id", c.inst.ID, "order", orderAmount, "max", cl.SingleMax)
|
||||
continue
|
||||
}
|
||||
if cl.DailyLimit > 0 && c.dailyUsed+orderAmount > cl.DailyLimit {
|
||||
slog.Info("instance daily remaining insufficient, skipping",
|
||||
"instance_id", c.inst.ID, "used", c.dailyUsed,
|
||||
"order", orderAmount, "limit", cl.DailyLimit)
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getInstanceChannelLimits returns the channel limits for a specific payment type.
|
||||
func getInstanceChannelLimits(inst *dbent.PaymentProviderInstance, paymentType PaymentType) ChannelLimits {
|
||||
if inst.Limits == "" {
|
||||
return ChannelLimits{}
|
||||
}
|
||||
var limits InstanceLimits
|
||||
if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
|
||||
return ChannelLimits{}
|
||||
}
|
||||
// For Stripe, limits are stored under the provider key "stripe".
|
||||
lookupKey := paymentType
|
||||
if inst.ProviderKey == "stripe" {
|
||||
lookupKey = "stripe"
|
||||
}
|
||||
if cl, ok := limits[lookupKey]; ok {
|
||||
return cl
|
||||
}
|
||||
if aliasKey := legacyVisibleMethodAlias(lookupKey); aliasKey != "" {
|
||||
if cl, ok := limits[aliasKey]; ok {
|
||||
return cl
|
||||
}
|
||||
}
|
||||
return ChannelLimits{}
|
||||
}
|
||||
|
||||
// pickByStrategy selects one instance from the available candidates.
|
||||
func (lb *DefaultLoadBalancer) pickByStrategy(candidates []instanceCandidate, strategy Strategy) instanceCandidate {
|
||||
if strategy == StrategyLeastAmount && len(candidates) > 1 {
|
||||
return pickLeastAmount(candidates)
|
||||
}
|
||||
// Default: round-robin.
|
||||
idx := lb.counter.Add(1) % uint64(len(candidates))
|
||||
return candidates[idx]
|
||||
}
|
||||
|
||||
// pickLeastAmount selects the instance with the lowest daily usage.
|
||||
// No extra DB queries — usage was pre-fetched in attachDailyUsage.
|
||||
func pickLeastAmount(candidates []instanceCandidate) instanceCandidate {
|
||||
best := candidates[0]
|
||||
for _, c := range candidates[1:] {
|
||||
if c.dailyUsed < best.dailyUsed {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (lb *DefaultLoadBalancer) buildSelection(selected *dbent.PaymentProviderInstance) (*InstanceSelection, error) {
|
||||
config, err := lb.decryptConfig(selected.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt instance %d config: %w", selected.ID, err)
|
||||
}
|
||||
if config == nil {
|
||||
config = map[string]string{}
|
||||
}
|
||||
|
||||
if selected.PaymentMode != "" {
|
||||
config["paymentMode"] = selected.PaymentMode
|
||||
}
|
||||
|
||||
return &InstanceSelection{
|
||||
InstanceID: fmt.Sprintf("%d", selected.ID),
|
||||
ProviderKey: selected.ProviderKey,
|
||||
Config: config,
|
||||
SupportedTypes: selected.SupportedTypes,
|
||||
PaymentMode: selected.PaymentMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decryptConfig parses a stored provider config.
|
||||
// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext.
|
||||
// Unreadable values (legacy ciphertext without a valid key, or malformed data)
|
||||
// are treated as empty so the service keeps running while the admin re-enters
|
||||
// the config via the UI.
|
||||
//
|
||||
// TODO(deprecated-legacy-ciphertext): The AES fallback branch below is a
|
||||
// transitional compatibility shim for pre-plaintext records. Remove it (and
|
||||
// the encryptionKey field + the Decrypt import) after a few releases once all
|
||||
// live deployments have re-saved their provider configs through the UI.
|
||||
func (lb *DefaultLoadBalancer) decryptConfig(stored string) (map[string]string, error) {
|
||||
if stored == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var config map[string]string
|
||||
if err := json.Unmarshal([]byte(stored), &config); err == nil {
|
||||
return config, nil
|
||||
}
|
||||
// Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
|
||||
if len(lb.encryptionKey) == AES256KeySize {
|
||||
//nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
|
||||
if plaintext, err := Decrypt(stored, lb.encryptionKey); err == nil {
|
||||
if err := json.Unmarshal([]byte(plaintext), &config); err == nil {
|
||||
return config, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Warn("payment provider config unreadable, treating as empty for re-entry",
|
||||
"stored_len", len(stored))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetInstanceDailyAmount returns the total completed order amount for an instance today.
|
||||
func (lb *DefaultLoadBalancer) GetInstanceDailyAmount(ctx context.Context, instanceID string) (float64, error) {
|
||||
todayStart := startOfDay(time.Now())
|
||||
|
||||
var result []struct {
|
||||
Sum float64 `json:"sum"`
|
||||
}
|
||||
err := lb.db.PaymentOrder.Query().
|
||||
Where(
|
||||
paymentorder.ProviderInstanceID(instanceID),
|
||||
paymentorder.StatusIn(OrderStatusCompleted, OrderStatusPaid, OrderStatusRecharging),
|
||||
paymentorder.PaidAtGTE(todayStart),
|
||||
).
|
||||
Aggregate(dbent.Sum(paymentorder.FieldPayAmount)).
|
||||
Scan(ctx, &result)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("query daily amount: %w", err)
|
||||
}
|
||||
if len(result) > 0 {
|
||||
return result[0].Sum, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// InstanceSupportsType checks if the given supported types string includes the target type.
|
||||
// An empty supportedTypes string means all types are supported.
|
||||
func InstanceSupportsType(supportedTypes string, target PaymentType) bool {
|
||||
if supportedTypes == "" {
|
||||
return true
|
||||
}
|
||||
normalizedTarget := normalizeVisibleMethodSupportType(target)
|
||||
for _, t := range strings.Split(supportedTypes, ",") {
|
||||
supported := strings.TrimSpace(t)
|
||||
if supported == target || normalizeVisibleMethodSupportType(supported) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeVisibleMethodSupportType(paymentType PaymentType) PaymentType {
|
||||
switch strings.TrimSpace(paymentType) {
|
||||
case TypeAlipay, TypeAlipayDirect:
|
||||
return TypeAlipay
|
||||
case TypeWxpay, TypeWxpayDirect:
|
||||
return TypeWxpay
|
||||
default:
|
||||
return strings.TrimSpace(paymentType)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyVisibleMethodAlias(paymentType PaymentType) PaymentType {
|
||||
switch normalizeVisibleMethodSupportType(paymentType) {
|
||||
case TypeAlipay:
|
||||
return TypeAlipayDirect
|
||||
case TypeWxpay:
|
||||
return TypeWxpayDirect
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWxpayJSAPIAppID(config map[string]string) string {
|
||||
if appID := strings.TrimSpace(config["mpAppId"]); appID != "" {
|
||||
return appID
|
||||
}
|
||||
return strings.TrimSpace(config["appId"])
|
||||
}
|
||||
|
||||
// GetInstanceConfig decrypts and returns the configuration for a provider instance by ID.
|
||||
func (lb *DefaultLoadBalancer) GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error) {
|
||||
inst, err := lb.db.PaymentProviderInstance.Get(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get instance %d: %w", instanceID, err)
|
||||
}
|
||||
return lb.decryptConfig(inst.Config)
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
//go:build unit
|
||||
|
||||
package payment
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
)
|
||||
|
||||
func TestInstanceSupportsType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
supportedTypes string
|
||||
target PaymentType
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "exact match single type",
|
||||
supportedTypes: "alipay",
|
||||
target: "alipay",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match single type",
|
||||
supportedTypes: "wxpay",
|
||||
target: "alipay",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "match in comma-separated list",
|
||||
supportedTypes: "alipay,wxpay,stripe",
|
||||
target: "wxpay",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "first in comma-separated list",
|
||||
supportedTypes: "alipay,wxpay",
|
||||
target: "alipay",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "last in comma-separated list",
|
||||
supportedTypes: "alipay,wxpay,stripe",
|
||||
target: "stripe",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match in comma-separated list",
|
||||
supportedTypes: "alipay,wxpay",
|
||||
target: "stripe",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty target",
|
||||
supportedTypes: "alipay,wxpay",
|
||||
target: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "types with spaces are trimmed",
|
||||
supportedTypes: " alipay , wxpay ",
|
||||
target: "alipay",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "legacy alipay direct supports canonical visible method",
|
||||
supportedTypes: "alipay_direct",
|
||||
target: "alipay",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "legacy wxpay direct supports canonical visible method",
|
||||
supportedTypes: "wxpay_direct",
|
||||
target: "wxpay",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty supported types means all supported",
|
||||
supportedTypes: "",
|
||||
target: "alipay",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := InstanceSupportsType(tt.supportedTypes, tt.target)
|
||||
if got != tt.expected {
|
||||
t.Fatalf("InstanceSupportsType(%q, %q) = %v, want %v", tt.supportedTypes, tt.target, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceChannelLimitsFallsBackToLegacyDirectAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inst := testInstance(1, TypeAlipay, makeLimitsJSON(TypeAlipayDirect, ChannelLimits{SingleMax: 66}))
|
||||
got := getInstanceChannelLimits(inst, TypeAlipay)
|
||||
if got.SingleMax != 66 {
|
||||
t.Fatalf("getInstanceChannelLimits() = %+v, want SingleMax=66", got)
|
||||
}
|
||||
|
||||
wxInst := testInstance(2, TypeWxpay, makeLimitsJSON(TypeWxpayDirect, ChannelLimits{SingleMin: 8}))
|
||||
wxGot := getInstanceChannelLimits(wxInst, TypeWxpay)
|
||||
if wxGot.SingleMin != 8 {
|
||||
t.Fatalf("getInstanceChannelLimits() = %+v, want SingleMin=8", wxGot)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper to build test PaymentProviderInstance values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func testInstance(id int64, providerKey, limits string) *dbent.PaymentProviderInstance {
|
||||
return &dbent.PaymentProviderInstance{
|
||||
ID: id,
|
||||
ProviderKey: providerKey,
|
||||
Limits: limits,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// makeLimitsJSON builds a limits JSON string for a single payment type.
|
||||
func makeLimitsJSON(paymentType string, cl ChannelLimits) string {
|
||||
m := map[string]ChannelLimits{paymentType: cl}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// filterByLimits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFilterByLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
candidates []instanceCandidate
|
||||
paymentType PaymentType
|
||||
orderAmount float64
|
||||
wantIDs []int64 // expected surviving instance IDs
|
||||
}{
|
||||
{
|
||||
name: "order below SingleMin is filtered out",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10})), dailyUsed: 0},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 5,
|
||||
wantIDs: nil,
|
||||
},
|
||||
{
|
||||
name: "order at exact SingleMin boundary passes",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10})), dailyUsed: 0},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 10,
|
||||
wantIDs: []int64{1},
|
||||
},
|
||||
{
|
||||
name: "order above SingleMax is filtered out",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMax: 100})), dailyUsed: 0},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 150,
|
||||
wantIDs: nil,
|
||||
},
|
||||
{
|
||||
name: "order at exact SingleMax boundary passes",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMax: 100})), dailyUsed: 0},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 100,
|
||||
wantIDs: []int64{1},
|
||||
},
|
||||
{
|
||||
name: "daily used + orderAmount exceeding dailyLimit is filtered out",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 500})), dailyUsed: 480},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 30,
|
||||
wantIDs: nil, // 480+30=510 > 500
|
||||
},
|
||||
{
|
||||
name: "daily used + orderAmount equal to dailyLimit passes (strict greater-than)",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 500})), dailyUsed: 480},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 20,
|
||||
wantIDs: []int64{1}, // 480+20=500, 500 > 500 is false → passes
|
||||
},
|
||||
{
|
||||
name: "daily used + orderAmount below dailyLimit passes",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 500})), dailyUsed: 400},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 50,
|
||||
wantIDs: []int64{1},
|
||||
},
|
||||
{
|
||||
name: "no limits configured passes through",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", ""), dailyUsed: 99999},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 100,
|
||||
wantIDs: []int64{1},
|
||||
},
|
||||
{
|
||||
name: "multiple candidates with partial filtering",
|
||||
candidates: []instanceCandidate{
|
||||
// singleMax=50, order=80 → filtered out
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMax: 50})), dailyUsed: 0},
|
||||
// no limits → passes
|
||||
{inst: testInstance(2, "easypay", ""), dailyUsed: 0},
|
||||
// singleMin=100, order=80 → filtered out
|
||||
{inst: testInstance(3, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 100})), dailyUsed: 0},
|
||||
// daily limit ok → passes (500+80=580 < 1000)
|
||||
{inst: testInstance(4, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 1000})), dailyUsed: 500},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 80,
|
||||
wantIDs: []int64{2, 4},
|
||||
},
|
||||
{
|
||||
name: "zero SingleMin and SingleMax means no single-transaction limit",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 0, SingleMax: 0, DailyLimit: 0})), dailyUsed: 0},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 99999,
|
||||
wantIDs: []int64{1},
|
||||
},
|
||||
{
|
||||
name: "all limits combined - order passes all checks",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10, SingleMax: 200, DailyLimit: 1000})), dailyUsed: 500},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 50,
|
||||
wantIDs: []int64{1},
|
||||
},
|
||||
{
|
||||
name: "all limits combined - order fails SingleMin",
|
||||
candidates: []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10, SingleMax: 200, DailyLimit: 1000})), dailyUsed: 500},
|
||||
},
|
||||
paymentType: "alipay",
|
||||
orderAmount: 5,
|
||||
wantIDs: nil,
|
||||
},
|
||||
{
|
||||
name: "empty candidates returns empty",
|
||||
candidates: nil,
|
||||
paymentType: "alipay",
|
||||
orderAmount: 10,
|
||||
wantIDs: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := filterByLimits(tt.candidates, tt.paymentType, tt.orderAmount)
|
||||
gotIDs := make([]int64, len(got))
|
||||
for i, c := range got {
|
||||
gotIDs[i] = c.inst.ID
|
||||
}
|
||||
if !int64SliceEqual(gotIDs, tt.wantIDs) {
|
||||
t.Fatalf("filterByLimits() returned IDs %v, want %v", gotIDs, tt.wantIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pickLeastAmount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPickLeastAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("picks candidate with lowest dailyUsed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
candidates := []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", ""), dailyUsed: 300},
|
||||
{inst: testInstance(2, "easypay", ""), dailyUsed: 100},
|
||||
{inst: testInstance(3, "easypay", ""), dailyUsed: 200},
|
||||
}
|
||||
got := pickLeastAmount(candidates)
|
||||
if got.inst.ID != 2 {
|
||||
t.Fatalf("pickLeastAmount() picked instance %d, want 2", got.inst.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with equal dailyUsed picks the first one", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
candidates := []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", ""), dailyUsed: 100},
|
||||
{inst: testInstance(2, "easypay", ""), dailyUsed: 100},
|
||||
{inst: testInstance(3, "easypay", ""), dailyUsed: 200},
|
||||
}
|
||||
got := pickLeastAmount(candidates)
|
||||
if got.inst.ID != 1 {
|
||||
t.Fatalf("pickLeastAmount() picked instance %d, want 1 (first with lowest)", got.inst.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single candidate returns that candidate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
candidates := []instanceCandidate{
|
||||
{inst: testInstance(42, "easypay", ""), dailyUsed: 999},
|
||||
}
|
||||
got := pickLeastAmount(candidates)
|
||||
if got.inst.ID != 42 {
|
||||
t.Fatalf("pickLeastAmount() picked instance %d, want 42", got.inst.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero usage among non-zero picks zero", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
candidates := []instanceCandidate{
|
||||
{inst: testInstance(1, "easypay", ""), dailyUsed: 500},
|
||||
{inst: testInstance(2, "easypay", ""), dailyUsed: 0},
|
||||
{inst: testInstance(3, "easypay", ""), dailyUsed: 300},
|
||||
}
|
||||
got := pickLeastAmount(candidates)
|
||||
if got.inst.ID != 2 {
|
||||
t.Fatalf("pickLeastAmount() picked instance %d, want 2", got.inst.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getInstanceChannelLimits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetInstanceChannelLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inst *dbent.PaymentProviderInstance
|
||||
paymentType PaymentType
|
||||
want ChannelLimits
|
||||
}{
|
||||
{
|
||||
name: "empty limits string returns zero ChannelLimits",
|
||||
inst: testInstance(1, "easypay", ""),
|
||||
paymentType: "alipay",
|
||||
want: ChannelLimits{},
|
||||
},
|
||||
{
|
||||
name: "invalid JSON returns zero ChannelLimits",
|
||||
inst: testInstance(1, "easypay", "not-json{"),
|
||||
paymentType: "alipay",
|
||||
want: ChannelLimits{},
|
||||
},
|
||||
{
|
||||
name: "valid JSON with matching payment type",
|
||||
inst: testInstance(1, "easypay",
|
||||
`{"alipay":{"singleMin":5,"singleMax":200,"dailyLimit":1000}}`),
|
||||
paymentType: "alipay",
|
||||
want: ChannelLimits{SingleMin: 5, SingleMax: 200, DailyLimit: 1000},
|
||||
},
|
||||
{
|
||||
name: "payment type not in limits returns zero ChannelLimits",
|
||||
inst: testInstance(1, "easypay",
|
||||
`{"alipay":{"singleMin":5,"singleMax":200}}`),
|
||||
paymentType: "wxpay",
|
||||
want: ChannelLimits{},
|
||||
},
|
||||
{
|
||||
name: "stripe provider uses stripe lookup key regardless of payment type",
|
||||
inst: testInstance(1, "stripe",
|
||||
`{"stripe":{"singleMin":10,"singleMax":500,"dailyLimit":5000}}`),
|
||||
paymentType: "alipay",
|
||||
want: ChannelLimits{SingleMin: 10, SingleMax: 500, DailyLimit: 5000},
|
||||
},
|
||||
{
|
||||
name: "stripe provider ignores payment type key even if present",
|
||||
inst: testInstance(1, "stripe",
|
||||
`{"stripe":{"singleMin":10,"singleMax":500},"alipay":{"singleMin":1,"singleMax":100}}`),
|
||||
paymentType: "alipay",
|
||||
want: ChannelLimits{SingleMin: 10, SingleMax: 500},
|
||||
},
|
||||
{
|
||||
name: "non-stripe provider uses payment type as lookup key",
|
||||
inst: testInstance(1, "easypay",
|
||||
`{"alipay":{"singleMin":5},"wxpay":{"singleMin":10}}`),
|
||||
paymentType: "wxpay",
|
||||
want: ChannelLimits{SingleMin: 10},
|
||||
},
|
||||
{
|
||||
name: "valid JSON with partial limits (only dailyLimit)",
|
||||
inst: testInstance(1, "easypay",
|
||||
`{"alipay":{"dailyLimit":800}}`),
|
||||
paymentType: "alipay",
|
||||
want: ChannelLimits{DailyLimit: 800},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := getInstanceChannelLimits(tt.inst, tt.paymentType)
|
||||
if got != tt.want {
|
||||
t.Fatalf("getInstanceChannelLimits() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// startOfDay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestStartOfDay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in time.Time
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "midday returns midnight of same day",
|
||||
in: time.Date(2025, 6, 15, 14, 30, 45, 123456789, time.UTC),
|
||||
want: time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "midnight returns same time",
|
||||
in: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
want: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "last second of day returns midnight of same day",
|
||||
in: time.Date(2025, 12, 31, 23, 59, 59, 999999999, time.UTC),
|
||||
want: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "preserves timezone location",
|
||||
in: time.Date(2025, 3, 10, 15, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
want: time.Date(2025, 3, 10, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := startOfDay(tt.in)
|
||||
if !got.Equal(tt.want) {
|
||||
t.Fatalf("startOfDay(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
// Also verify location is preserved.
|
||||
if got.Location().String() != tt.want.Location().String() {
|
||||
t.Fatalf("startOfDay() location = %v, want %v", got.Location(), tt.want.Location())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptConfig_PlaintextAndLegacyCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := make([]byte, AES256KeySize)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
wrongKey := make([]byte, AES256KeySize)
|
||||
for i := range wrongKey {
|
||||
wrongKey[i] = byte(0xFF - i)
|
||||
}
|
||||
|
||||
plaintextJSON := `{"appId":"app-123","secret":"sec-xyz"}`
|
||||
|
||||
legacyEncrypted, err := Encrypt(plaintextJSON, key)
|
||||
if err != nil {
|
||||
t.Fatalf("seed Encrypt: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stored string
|
||||
key []byte
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "empty stored returns nil map",
|
||||
stored: "",
|
||||
key: key,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "plaintext JSON parses directly",
|
||||
stored: plaintextJSON,
|
||||
key: nil,
|
||||
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
|
||||
},
|
||||
{
|
||||
name: "plaintext JSON works even with key present",
|
||||
stored: plaintextJSON,
|
||||
key: key,
|
||||
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
|
||||
},
|
||||
{
|
||||
name: "legacy ciphertext with correct key decrypts",
|
||||
stored: legacyEncrypted,
|
||||
key: key,
|
||||
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
|
||||
},
|
||||
{
|
||||
name: "legacy ciphertext with no key treated as empty",
|
||||
stored: legacyEncrypted,
|
||||
key: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "legacy ciphertext with wrong key treated as empty",
|
||||
stored: legacyEncrypted,
|
||||
key: wrongKey,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "garbage data treated as empty",
|
||||
stored: "not-json-and-not-ciphertext",
|
||||
key: key,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lb := NewDefaultLoadBalancer(nil, tt.key)
|
||||
got, err := lb.decryptConfig(tt.stored)
|
||||
if err != nil {
|
||||
t.Fatalf("decryptConfig unexpected error: %v", err)
|
||||
}
|
||||
if !stringMapEqual(got, tt.want) {
|
||||
t.Fatalf("decryptConfig = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stringMapEqual compares two map[string]string values; nil and empty are equal.
|
||||
func stringMapEqual(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if bv, ok := b[k]; !ok || bv != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// int64SliceEqual compares two int64 slices for equality.
|
||||
// Both nil and empty slices are treated as equal.
|
||||
func int64SliceEqual(a, b []int64) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
const (
|
||||
airwallexDemoAPIBase = "https://api-demo.airwallex.com/api/v1"
|
||||
airwallexProdAPIBase = "https://api.airwallex.com/api/v1"
|
||||
airwallexDefaultCountry = "CN"
|
||||
airwallexHTTPTimeout = 15 * time.Second
|
||||
airwallexMaxResponseSize = 1 << 20
|
||||
airwallexMaxErrorSummary = 512
|
||||
airwallexTokenSkew = 2 * time.Minute
|
||||
airwallexWebhookTolerance = 5 * time.Minute
|
||||
|
||||
airwallexEventPaymentSucceeded = "payment_intent.succeeded"
|
||||
airwallexEventPaymentCancelled = "payment_intent.cancelled"
|
||||
|
||||
airwallexPaymentStatusSucceeded = "SUCCEEDED"
|
||||
airwallexPaymentStatusCancelled = "CANCELLED"
|
||||
airwallexRefundStatusReceived = "RECEIVED"
|
||||
airwallexRefundStatusAccepted = "ACCEPTED"
|
||||
airwallexRefundStatusSettled = "SETTLED"
|
||||
airwallexRefundStatusFailed = "FAILED"
|
||||
)
|
||||
|
||||
type Airwallex struct {
|
||||
instanceID string
|
||||
config map[string]string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type airwallexTokenState struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var airwallexAccessTokens sync.Map
|
||||
|
||||
func NewAirwallex(instanceID string, config map[string]string) (*Airwallex, error) {
|
||||
for _, k := range []string{"clientId", "apiKey", "webhookSecret", "apiBase"} {
|
||||
if strings.TrimSpace(config[k]) == "" {
|
||||
return nil, fmt.Errorf("airwallex config missing required key: %s", k)
|
||||
}
|
||||
}
|
||||
cfg := cloneStringMap(config)
|
||||
apiBase, err := normalizeAirwallexAPIBase(cfg["apiBase"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg["apiBase"] = apiBase
|
||||
currency, err := payment.NormalizePaymentCurrency(cfg["currency"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("airwallex config currency: %w", err)
|
||||
}
|
||||
cfg["currency"] = currency
|
||||
countryCode, err := normalizeAirwallexCountryCode(cfg["countryCode"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg["countryCode"] = countryCode
|
||||
return &Airwallex{
|
||||
instanceID: instanceID,
|
||||
config: cfg,
|
||||
httpClient: &http.Client{Timeout: airwallexHTTPTimeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeAirwallexCountryCode(raw string) (string, error) {
|
||||
countryCode := strings.ToUpper(strings.TrimSpace(raw))
|
||||
if countryCode == "" {
|
||||
return airwallexDefaultCountry, nil
|
||||
}
|
||||
if len(countryCode) != 2 {
|
||||
return "", fmt.Errorf("airwallex config countryCode must be a two-letter ISO country code")
|
||||
}
|
||||
for _, ch := range countryCode {
|
||||
if ch < 'A' || ch > 'Z' {
|
||||
return "", fmt.Errorf("airwallex config countryCode must be a two-letter ISO country code")
|
||||
}
|
||||
}
|
||||
return countryCode, nil
|
||||
}
|
||||
|
||||
func normalizeAirwallexAPIBase(raw string) (string, error) {
|
||||
base := strings.TrimSpace(raw)
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("airwallex apiBase is required")
|
||||
}
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("airwallex apiBase must be an HTTPS URL")
|
||||
}
|
||||
host := strings.ToLower(parsed.Host)
|
||||
if host != "api-demo.airwallex.com" && host != "api.airwallex.com" {
|
||||
return "", fmt.Errorf("airwallex apiBase host must be api-demo.airwallex.com or api.airwallex.com")
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.RawPath = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
if parsed.Path == "" {
|
||||
parsed.Path = "/api/v1"
|
||||
}
|
||||
if parsed.Path != "/api/v1" {
|
||||
return "", fmt.Errorf("airwallex apiBase path must be /api/v1")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (a *Airwallex) Name() string { return "空中云汇" }
|
||||
func (a *Airwallex) ProviderKey() string { return payment.TypeAirwallex }
|
||||
func (a *Airwallex) SupportedTypes() []payment.PaymentType {
|
||||
return []payment.PaymentType{payment.TypeAirwallex}
|
||||
}
|
||||
|
||||
func (a *Airwallex) MerchantIdentityMetadata() map[string]string {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
metadata := map[string]string{"currency": a.currency()}
|
||||
if accountID := strings.TrimSpace(a.config["accountId"]); accountID != "" {
|
||||
metadata["account_id"] = accountID
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (a *Airwallex) currency() string {
|
||||
if a == nil {
|
||||
return payment.DefaultPaymentCurrency
|
||||
}
|
||||
currency, err := payment.NormalizePaymentCurrency(a.config["currency"])
|
||||
if err != nil {
|
||||
return payment.DefaultPaymentCurrency
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
func (a *Airwallex) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
amount, err := decimal.NewFromString(req.Amount)
|
||||
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, fmt.Errorf("airwallex create payment: invalid amount %s", req.Amount)
|
||||
}
|
||||
token, err := a.accessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("airwallex auth: %w", err)
|
||||
}
|
||||
|
||||
currency := a.currency()
|
||||
requestID := airwallexDeterministicRequestID("payment-intent", req.OrderID, req.Amount, currency)
|
||||
payload := airwallexCreatePaymentIntentRequest{
|
||||
RequestID: requestID,
|
||||
Amount: newAirwallexRequestAmount(amount),
|
||||
Currency: currency,
|
||||
MerchantOrderID: req.OrderID,
|
||||
ReturnURL: req.ReturnURL,
|
||||
Metadata: map[string]string{
|
||||
"order_id": req.OrderID,
|
||||
},
|
||||
}
|
||||
if descriptor := strings.TrimSpace(a.config["descriptor"]); descriptor != "" {
|
||||
payload.Descriptor = descriptor
|
||||
}
|
||||
|
||||
var intent airwallexPaymentIntent
|
||||
if err := a.doJSON(ctx, http.MethodPost, "/pa/payment_intents/create", token, payload, &intent); err != nil {
|
||||
return nil, fmt.Errorf("airwallex create payment: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(intent.ID) == "" || strings.TrimSpace(intent.ClientSecret) == "" {
|
||||
return nil, fmt.Errorf("airwallex create payment: missing payment intent id or client secret")
|
||||
}
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: intent.ID,
|
||||
ClientSecret: intent.ClientSecret,
|
||||
IntentID: intent.ID,
|
||||
Currency: currency,
|
||||
CountryCode: a.config["countryCode"],
|
||||
PaymentEnv: a.checkoutEnv(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Airwallex) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
intentID := strings.TrimSpace(tradeNo)
|
||||
if intentID == "" {
|
||||
return nil, fmt.Errorf("airwallex query order: missing payment intent id")
|
||||
}
|
||||
token, err := a.accessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("airwallex auth: %w", err)
|
||||
}
|
||||
|
||||
var intent airwallexPaymentIntent
|
||||
if err := a.doJSON(ctx, http.MethodGet, "/pa/payment_intents/"+url.PathEscape(intentID), token, nil, &intent); err != nil {
|
||||
return nil, fmt.Errorf("airwallex query order: %w", err)
|
||||
}
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: intent.ID,
|
||||
Status: airwallexProviderStatus(intent.Status),
|
||||
Amount: intent.Amount.InexactFloat64(),
|
||||
Metadata: a.intentMetadata(intent, ""),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Airwallex) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
|
||||
if err := verifyAirwallexWebhookSignature(rawBody, headers, a.config["webhookSecret"], time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var event airwallexWebhookEvent
|
||||
if err := json.Unmarshal([]byte(rawBody), &event); err != nil {
|
||||
return nil, fmt.Errorf("airwallex parse webhook: %w", err)
|
||||
}
|
||||
switch event.Name {
|
||||
case airwallexEventPaymentSucceeded, airwallexEventPaymentCancelled:
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var intent airwallexPaymentIntent
|
||||
if err := json.Unmarshal(event.Data.Object, &intent); err != nil {
|
||||
return nil, fmt.Errorf("airwallex parse payment intent: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(intent.ID) == "" || strings.TrimSpace(intent.MerchantOrderID) == "" {
|
||||
return nil, fmt.Errorf("airwallex webhook missing payment intent id or merchant_order_id")
|
||||
}
|
||||
status := payment.ProviderStatusFailed
|
||||
if event.Name == airwallexEventPaymentSucceeded {
|
||||
if strings.ToUpper(strings.TrimSpace(intent.Status)) != airwallexPaymentStatusSucceeded {
|
||||
return nil, fmt.Errorf("airwallex succeeded webhook has non-succeeded status: %s", intent.Status)
|
||||
}
|
||||
status = payment.NotificationStatusSuccess
|
||||
}
|
||||
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: intent.ID,
|
||||
OrderID: intent.MerchantOrderID,
|
||||
Amount: intent.Amount.InexactFloat64(),
|
||||
Status: status,
|
||||
RawData: rawBody,
|
||||
Metadata: a.intentMetadata(intent, event.accountID()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Airwallex) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
intentID := strings.TrimSpace(req.TradeNo)
|
||||
if intentID == "" {
|
||||
return nil, fmt.Errorf("airwallex refund missing payment intent id")
|
||||
}
|
||||
amount, err := decimal.NewFromString(req.Amount)
|
||||
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, fmt.Errorf("airwallex refund: invalid amount %s", req.Amount)
|
||||
}
|
||||
token, err := a.accessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("airwallex auth: %w", err)
|
||||
}
|
||||
|
||||
payload := airwallexCreateRefundRequest{
|
||||
RequestID: airwallexDeterministicRequestID("refund", intentID, req.Amount),
|
||||
PaymentIntentID: intentID,
|
||||
Amount: newAirwallexRequestAmount(amount),
|
||||
Reason: strings.TrimSpace(req.Reason),
|
||||
}
|
||||
if payload.Reason == "" {
|
||||
payload.Reason = "refund"
|
||||
}
|
||||
|
||||
var resp airwallexRefund
|
||||
if err := a.doJSON(ctx, http.MethodPost, "/pa/refunds/create", token, payload, &resp); err != nil {
|
||||
return nil, fmt.Errorf("airwallex refund: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(resp.ID) == "" {
|
||||
return nil, fmt.Errorf("airwallex refund: missing refund id")
|
||||
}
|
||||
refundResp := &payment.RefundResponse{
|
||||
RefundID: resp.ID,
|
||||
Status: airwallexRefundProviderStatus(resp.Status),
|
||||
}
|
||||
if refundResp.Status != payment.ProviderStatusSuccess {
|
||||
return refundResp, fmt.Errorf("airwallex refund not settled: status %s", strings.ToUpper(strings.TrimSpace(resp.Status)))
|
||||
}
|
||||
return refundResp, nil
|
||||
}
|
||||
|
||||
func (a *Airwallex) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
|
||||
refundID := strings.TrimSpace(req.RefundID)
|
||||
if refundID == "" {
|
||||
return nil, fmt.Errorf("airwallex query refund: missing refund id")
|
||||
}
|
||||
token, err := a.accessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("airwallex auth: %w", err)
|
||||
}
|
||||
var resp airwallexRefund
|
||||
if err := a.doJSON(ctx, http.MethodGet, "/pa/refunds/"+url.PathEscape(refundID), token, nil, &resp); err != nil {
|
||||
return nil, fmt.Errorf("airwallex query refund: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(resp.ID) == "" {
|
||||
resp.ID = refundID
|
||||
}
|
||||
return &payment.RefundResponse{RefundID: resp.ID, Status: airwallexRefundProviderStatus(resp.Status)}, nil
|
||||
}
|
||||
|
||||
func (a *Airwallex) CancelPayment(ctx context.Context, tradeNo string) error {
|
||||
intentID := strings.TrimSpace(tradeNo)
|
||||
if intentID == "" {
|
||||
return nil
|
||||
}
|
||||
token, err := a.accessToken(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("airwallex auth: %w", err)
|
||||
}
|
||||
var intent airwallexPaymentIntent
|
||||
if err := a.doJSON(ctx, http.MethodPost, "/pa/payment_intents/"+url.PathEscape(intentID)+"/cancel", token, nil, &intent); err != nil {
|
||||
return fmt.Errorf("airwallex cancel payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Airwallex) intentMetadata(intent airwallexPaymentIntent, accountID string) map[string]string {
|
||||
metadata := map[string]string{
|
||||
"currency": strings.ToUpper(strings.TrimSpace(intent.Currency)),
|
||||
"status": strings.ToUpper(strings.TrimSpace(intent.Status)),
|
||||
}
|
||||
if accountID = strings.TrimSpace(accountID); accountID != "" {
|
||||
metadata["account_id"] = accountID
|
||||
} else if configured := strings.TrimSpace(a.config["accountId"]); configured != "" {
|
||||
metadata["account_id"] = configured
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (a *Airwallex) checkoutEnv() string {
|
||||
if strings.EqualFold(a.config["apiBase"], airwallexProdAPIBase) {
|
||||
return "prod"
|
||||
}
|
||||
return "demo"
|
||||
}
|
||||
|
||||
func (a *Airwallex) accessToken(ctx context.Context) (string, error) {
|
||||
cacheKey := a.tokenCacheKey()
|
||||
rawState, _ := airwallexAccessTokens.LoadOrStore(cacheKey, &airwallexTokenState{})
|
||||
state, ok := rawState.(*airwallexTokenState)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("airwallex auth token cache state type mismatch")
|
||||
}
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
if state.token != "" && time.Now().Add(airwallexTokenSkew).Before(state.expiresAt) {
|
||||
return state.token, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.config["apiBase"]+"/authentication/login", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-client-id", a.config["clientId"])
|
||||
req.Header.Set("x-api-key", a.config["apiKey"])
|
||||
if accountID := strings.TrimSpace(a.config["accountId"]); accountID != "" {
|
||||
req.Header.Set("x-login-as", accountID)
|
||||
}
|
||||
|
||||
body, status, err := a.do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
||||
return "", formatAirwallexAuthHTTPError(status, body)
|
||||
}
|
||||
var resp airwallexAuthResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", fmt.Errorf("parse authentication response: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(resp.Token) == "" {
|
||||
return "", fmt.Errorf("authentication response missing token")
|
||||
}
|
||||
expiresAt, err := parseAirwallexTime(resp.ExpiresAt)
|
||||
if err != nil {
|
||||
expiresAt = time.Now().Add(25 * time.Minute)
|
||||
}
|
||||
state.token = resp.Token
|
||||
state.expiresAt = expiresAt
|
||||
return state.token, nil
|
||||
}
|
||||
|
||||
func formatAirwallexAuthHTTPError(status int, body []byte) error {
|
||||
summary := summarizeAirwallexResponse(body)
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return fmt.Errorf("authentication HTTP %d: %s; Airwallex credentials were rejected, check Client ID/API Key, API Base environment (sandbox: https://api-demo.airwallex.com/api/v1, production: https://api.airwallex.com/api/v1), and Account ID (leave it empty for single-account scoped keys)", status, summary)
|
||||
}
|
||||
return fmt.Errorf("authentication HTTP %d: %s", status, summary)
|
||||
}
|
||||
|
||||
func (a *Airwallex) tokenCacheKey() string {
|
||||
sum := sha256.Sum256([]byte(a.config["apiKey"]))
|
||||
return a.config["apiBase"] + "|" + a.config["clientId"] + "|" + strings.TrimSpace(a.config["accountId"]) + "|" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func (a *Airwallex) doJSON(ctx context.Context, method, path, token string, payload any, out any) error {
|
||||
var bodyReader io.Reader
|
||||
if payload != nil {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.config["apiBase"]+path, bodyReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if accountID := strings.TrimSpace(a.config["accountId"]); accountID != "" {
|
||||
req.Header.Set("x-on-behalf-of", accountID)
|
||||
}
|
||||
|
||||
body, status, err := a.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
||||
return fmt.Errorf("HTTP %d: %s", status, summarizeAirwallexResponse(body))
|
||||
}
|
||||
if out == nil || len(bytes.TrimSpace(body)) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Airwallex) do(req *http.Request) ([]byte, int, error) {
|
||||
client := a.httpClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: airwallexHTTPTimeout}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, airwallexMaxResponseSize))
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func airwallexProviderStatus(status string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||
case airwallexPaymentStatusSucceeded:
|
||||
return payment.ProviderStatusPaid
|
||||
case airwallexPaymentStatusCancelled:
|
||||
return payment.ProviderStatusFailed
|
||||
default:
|
||||
return payment.ProviderStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
func airwallexRefundProviderStatus(status string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||
case airwallexRefundStatusSettled:
|
||||
return payment.ProviderStatusSuccess
|
||||
case airwallexRefundStatusFailed:
|
||||
return payment.ProviderStatusFailed
|
||||
case airwallexRefundStatusReceived, airwallexRefundStatusAccepted:
|
||||
return payment.ProviderStatusPending
|
||||
default:
|
||||
return payment.ProviderStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
func airwallexDeterministicRequestID(parts ...string) string {
|
||||
hash := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
|
||||
var id uuid.UUID
|
||||
copy(id[:], hash[:16])
|
||||
id[6] = (id[6] & 0x0f) | 0x40
|
||||
id[8] = (id[8] & 0x3f) | 0x80
|
||||
return id.String()
|
||||
}
|
||||
|
||||
func verifyAirwallexWebhookSignature(rawBody string, headers map[string]string, secret string, now time.Time) error {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
return fmt.Errorf("airwallex webhookSecret not configured")
|
||||
}
|
||||
timestamp := strings.TrimSpace(headers["x-timestamp"])
|
||||
signature := strings.ToLower(strings.TrimSpace(headers["x-signature"]))
|
||||
if timestamp == "" || signature == "" {
|
||||
return fmt.Errorf("airwallex notification missing x-timestamp or x-signature header")
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(timestamp))
|
||||
_, _ = mac.Write([]byte(rawBody))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return fmt.Errorf("airwallex invalid signature")
|
||||
}
|
||||
|
||||
ts, err := parseAirwallexWebhookTimestamp(timestamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
if diff := now.Sub(ts).Abs(); diff > airwallexWebhookTolerance {
|
||||
return fmt.Errorf("airwallex webhook timestamp outside tolerance")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAirwallexWebhookTimestamp(raw string) (time.Time, error) {
|
||||
ts, err := decimal.NewFromString(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("airwallex invalid webhook timestamp")
|
||||
}
|
||||
millis := ts.IntPart()
|
||||
if millis <= 0 {
|
||||
return time.Time{}, fmt.Errorf("airwallex invalid webhook timestamp")
|
||||
}
|
||||
return time.UnixMilli(millis), nil
|
||||
}
|
||||
|
||||
func parseAirwallexTime(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("empty time")
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05-0700", "2006-01-02T15:04:05.000-0700"} {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("invalid time: %s", raw)
|
||||
}
|
||||
|
||||
func summarizeAirwallexResponse(body []byte) string {
|
||||
summary := strings.Join(strings.Fields(string(body)), " ")
|
||||
if summary == "" {
|
||||
return "<empty>"
|
||||
}
|
||||
if len(summary) > airwallexMaxErrorSummary {
|
||||
return summary[:airwallexMaxErrorSummary] + "..."
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
type airwallexAuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type airwallexCreatePaymentIntentRequest struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Amount airwallexRequestAmount `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
MerchantOrderID string `json:"merchant_order_id"`
|
||||
ReturnURL string `json:"return_url,omitempty"`
|
||||
Descriptor string `json:"descriptor,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type airwallexCreateRefundRequest struct {
|
||||
RequestID string `json:"request_id"`
|
||||
PaymentIntentID string `json:"payment_intent_id"`
|
||||
Amount airwallexRequestAmount `json:"amount,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type airwallexRequestAmount struct {
|
||||
decimal.Decimal
|
||||
}
|
||||
|
||||
func newAirwallexRequestAmount(amount decimal.Decimal) airwallexRequestAmount {
|
||||
return airwallexRequestAmount{Decimal: amount}
|
||||
}
|
||||
|
||||
func (a airwallexRequestAmount) MarshalJSON() ([]byte, error) {
|
||||
return []byte(a.String()), nil
|
||||
}
|
||||
|
||||
func (a *airwallexRequestAmount) UnmarshalJSON(data []byte) error {
|
||||
amount, err := decimal.NewFromString(strings.Trim(string(data), `"`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Decimal = amount
|
||||
return nil
|
||||
}
|
||||
|
||||
type airwallexPaymentIntent struct {
|
||||
ID string `json:"id"`
|
||||
RequestID string `json:"request_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
MerchantOrderID string `json:"merchant_order_id"`
|
||||
Amount decimal.Decimal `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
type airwallexRefund struct {
|
||||
ID string `json:"id"`
|
||||
RequestID string `json:"request_id"`
|
||||
PaymentIntentID string `json:"payment_intent_id"`
|
||||
Amount decimal.Decimal `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type airwallexWebhookEvent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AccountID string `json:"accountId"`
|
||||
AccountIDSnake string `json:"account_id"`
|
||||
Data struct {
|
||||
Object json.RawMessage `json:"object"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (e airwallexWebhookEvent) accountID() string {
|
||||
if accountID := strings.TrimSpace(e.AccountID); accountID != "" {
|
||||
return accountID
|
||||
}
|
||||
return strings.TrimSpace(e.AccountIDSnake)
|
||||
}
|
||||
|
||||
var (
|
||||
_ payment.Provider = (*Airwallex)(nil)
|
||||
_ payment.CancelableProvider = (*Airwallex)(nil)
|
||||
_ payment.MerchantIdentityProvider = (*Airwallex)(nil)
|
||||
)
|
||||
@@ -0,0 +1,352 @@
|
||||
//go:build unit
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAirwallexValidatesConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewAirwallex("1", map[string]string{
|
||||
"clientId": "cid",
|
||||
"apiKey": "key",
|
||||
"webhookSecret": "secret",
|
||||
"apiBase": "https://evil.example.com/api/v1",
|
||||
})
|
||||
require.ErrorContains(t, err, "apiBase host")
|
||||
|
||||
_, err = NewAirwallex("1", map[string]string{
|
||||
"clientId": "cid",
|
||||
"apiKey": "key",
|
||||
"webhookSecret": "secret",
|
||||
"apiBase": airwallexDemoAPIBase,
|
||||
"countryCode": "C1",
|
||||
})
|
||||
require.ErrorContains(t, err, "countryCode")
|
||||
|
||||
prov, err := NewAirwallex("1", map[string]string{
|
||||
"clientId": "cid",
|
||||
"apiKey": "key",
|
||||
"webhookSecret": "secret",
|
||||
"apiBase": airwallexDemoAPIBase,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, payment.TypeAirwallex, prov.ProviderKey())
|
||||
require.Equal(t, []payment.PaymentType{payment.TypeAirwallex}, prov.SupportedTypes())
|
||||
require.Equal(t, payment.DefaultPaymentCurrency, prov.config["currency"])
|
||||
require.Equal(t, airwallexDefaultCountry, prov.config["countryCode"])
|
||||
}
|
||||
|
||||
func TestAirwallexCreatePaymentUsesServerAmountAndStableRequestID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var createRequests []airwallexCreatePaymentIntentRequest
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/authentication/login":
|
||||
require.Equal(t, "cid", r.Header.Get("x-client-id"))
|
||||
require.Equal(t, "key", r.Header.Get("x-api-key"))
|
||||
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
||||
case "/api/v1/pa/payment_intents/create":
|
||||
require.Equal(t, "Bearer token-1", r.Header.Get("Authorization"))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), `"amount":12.34`)
|
||||
var payload airwallexCreatePaymentIntentRequest
|
||||
require.NoError(t, json.Unmarshal(body, &payload))
|
||||
createRequests = append(createRequests, payload)
|
||||
_, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
prov := mustTestAirwallexProvider(t, server)
|
||||
resp, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_order",
|
||||
Amount: "12.34",
|
||||
ReturnURL: "https://merchant.example.com/payment/result",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "int_123", resp.TradeNo)
|
||||
require.Equal(t, "secret_123", resp.ClientSecret)
|
||||
require.Equal(t, "int_123", resp.IntentID)
|
||||
require.Equal(t, "CNY", resp.Currency)
|
||||
require.Equal(t, "CN", resp.CountryCode)
|
||||
require.Equal(t, "demo", resp.PaymentEnv)
|
||||
require.Len(t, createRequests, 1)
|
||||
require.Equal(t, "12.34", createRequests[0].Amount.StringFixed(2))
|
||||
require.Equal(t, "CNY", createRequests[0].Currency)
|
||||
require.Equal(t, "sub2_order", createRequests[0].MerchantOrderID)
|
||||
require.Equal(t, airwallexDeterministicRequestID("payment-intent", "sub2_order", "12.34", "CNY"), createRequests[0].RequestID)
|
||||
}
|
||||
|
||||
func TestAirwallexCreatePaymentUsesConfiguredCurrency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var createRequest airwallexCreatePaymentIntentRequest
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/authentication/login":
|
||||
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
||||
case "/api/v1/pa/payment_intents/create":
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(body, &createRequest))
|
||||
_, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"HKD","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
prov, err := NewAirwallex("1", map[string]string{
|
||||
"clientId": "cid",
|
||||
"apiKey": "key",
|
||||
"webhookSecret": "whsec",
|
||||
"apiBase": airwallexDemoAPIBase,
|
||||
"currency": "hkd",
|
||||
"countryCode": "HK",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
prov.config["apiBase"] = server.URL + "/api/v1"
|
||||
prov.httpClient = server.Client()
|
||||
|
||||
resp, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_order",
|
||||
Amount: "12.34",
|
||||
ReturnURL: "https://merchant.example.com/payment/result",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HKD", createRequest.Currency)
|
||||
require.Equal(t, "HKD", resp.Currency)
|
||||
require.Equal(t, "HK", resp.CountryCode)
|
||||
require.Equal(t, "HKD", prov.MerchantIdentityMetadata()["currency"])
|
||||
}
|
||||
|
||||
func TestAirwallexRequestsUseConfiguredAccountID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
paRequestCount := 0
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/authentication/login":
|
||||
require.Equal(t, "acct_123", r.Header.Get("x-login-as"))
|
||||
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
||||
case "/api/v1/pa/payment_intents/create":
|
||||
paRequestCount++
|
||||
require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
|
||||
_, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`))
|
||||
case "/api/v1/pa/payment_intents/int_123":
|
||||
paRequestCount++
|
||||
require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
|
||||
_, _ = w.Write([]byte(`{"id":"int_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"SUCCEEDED"}`))
|
||||
case "/api/v1/pa/refunds/create":
|
||||
paRequestCount++
|
||||
require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
|
||||
_, _ = w.Write([]byte(`{"id":"ref_123","payment_intent_id":"int_123","amount":12.34,"currency":"CNY","status":"SETTLED"}`))
|
||||
case "/api/v1/pa/payment_intents/int_123/cancel":
|
||||
paRequestCount++
|
||||
require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
|
||||
_, _ = w.Write([]byte(`{"id":"int_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"CANCELLED"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
prov, err := NewAirwallex("1", map[string]string{
|
||||
"clientId": "cid",
|
||||
"apiKey": "key",
|
||||
"webhookSecret": "whsec",
|
||||
"apiBase": airwallexDemoAPIBase,
|
||||
"accountId": "acct_123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
prov.config["apiBase"] = server.URL + "/api/v1"
|
||||
prov.httpClient = server.Client()
|
||||
|
||||
_, err = prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_order",
|
||||
Amount: "12.34",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = prov.QueryOrder(context.Background(), "int_123")
|
||||
require.NoError(t, err)
|
||||
_, err = prov.Refund(context.Background(), payment.RefundRequest{
|
||||
TradeNo: "int_123",
|
||||
Amount: "12.34",
|
||||
Reason: "test refund",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, prov.CancelPayment(context.Background(), "int_123"))
|
||||
require.Contains(t, prov.tokenCacheKey(), "acct_123")
|
||||
require.Equal(t, 4, paRequestCount)
|
||||
}
|
||||
|
||||
func TestAirwallexRefundRejectsUnsettledStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, status := range []string{"RECEIVED", "ACCEPTED", "FAILED"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/authentication/login":
|
||||
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
||||
case "/api/v1/pa/refunds/create":
|
||||
_, _ = w.Write([]byte(`{"id":"ref_123","payment_intent_id":"int_123","amount":12.34,"currency":"CNY","status":"` + status + `"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
prov := mustTestAirwallexProvider(t, server)
|
||||
resp, err := prov.Refund(context.Background(), payment.RefundRequest{
|
||||
TradeNo: "int_123",
|
||||
Amount: "12.34",
|
||||
Reason: "test refund",
|
||||
})
|
||||
|
||||
require.ErrorContains(t, err, "airwallex refund not settled")
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, "ref_123", resp.RefundID)
|
||||
if status == airwallexRefundStatusFailed {
|
||||
require.Equal(t, payment.ProviderStatusFailed, resp.Status)
|
||||
} else {
|
||||
require.Equal(t, payment.ProviderStatusPending, resp.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirwallexAuthErrorIncludesCredentialGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v1/authentication/login", r.URL.Path)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":"credentials_invalid","details":["Access Denied"],"message":"UNAUTHORIZED","source":""}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
prov := mustTestAirwallexProvider(t, server)
|
||||
_, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_order",
|
||||
Amount: "12.34",
|
||||
})
|
||||
|
||||
require.ErrorContains(t, err, "credentials_invalid")
|
||||
require.ErrorContains(t, err, "API Base environment")
|
||||
require.ErrorContains(t, err, "https://api-demo.airwallex.com/api/v1")
|
||||
require.ErrorContains(t, err, "https://api.airwallex.com/api/v1")
|
||||
require.ErrorContains(t, err, "Account ID")
|
||||
}
|
||||
|
||||
func TestAirwallexVerifyNotificationRequiresValidSignatureAndCurrency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prov, err := NewAirwallex("1", map[string]string{
|
||||
"clientId": "cid",
|
||||
"apiKey": "key",
|
||||
"webhookSecret": "whsec",
|
||||
"apiBase": airwallexDemoAPIBase,
|
||||
"accountId": "acct_123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
raw := `{"id":"evt_1","name":"payment_intent.succeeded","accountId":"acct_123","data":{"object":{"id":"int_123","merchant_order_id":"sub2_abc","amount":88.66,"currency":"CNY","status":"SUCCEEDED"}}}`
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
headers := signedAirwallexHeaders(raw, timestamp, "whsec")
|
||||
|
||||
n, err := prov.VerifyNotification(context.Background(), raw, headers)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, n)
|
||||
require.Equal(t, "int_123", n.TradeNo)
|
||||
require.Equal(t, "sub2_abc", n.OrderID)
|
||||
require.Equal(t, payment.NotificationStatusSuccess, n.Status)
|
||||
require.InDelta(t, 88.66, n.Amount, 0.0001)
|
||||
require.Equal(t, "CNY", n.Metadata["currency"])
|
||||
require.Equal(t, "acct_123", n.Metadata["account_id"])
|
||||
|
||||
headers["x-signature"] = strings.Repeat("0", 64)
|
||||
_, err = prov.VerifyNotification(context.Background(), raw, headers)
|
||||
require.ErrorContains(t, err, "invalid signature")
|
||||
}
|
||||
|
||||
func TestVerifyAirwallexWebhookSignatureRejectsReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"id":"evt_1"}`
|
||||
timestamp := "1778241600000"
|
||||
headers := signedAirwallexHeaders(raw, timestamp, "whsec")
|
||||
err := verifyAirwallexWebhookSignature(raw, headers, "whsec", time.UnixMilli(1778241600000).Add(airwallexWebhookTolerance+time.Millisecond))
|
||||
require.ErrorContains(t, err, "outside tolerance")
|
||||
}
|
||||
|
||||
func TestAirwallexQueryOrderMapsSucceeded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/authentication/login":
|
||||
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
||||
case "/api/v1/pa/payment_intents/int_123":
|
||||
_, _ = w.Write([]byte(`{"id":"int_123","amount":99.01,"currency":"CNY","merchant_order_id":"sub2_order","status":"SUCCEEDED"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
prov := mustTestAirwallexProvider(t, server)
|
||||
resp, err := prov.QueryOrder(context.Background(), "int_123")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, payment.ProviderStatusPaid, resp.Status)
|
||||
require.InDelta(t, 99.01, resp.Amount, 0.0001)
|
||||
require.Equal(t, "CNY", resp.Metadata["currency"])
|
||||
require.Equal(t, "SUCCEEDED", resp.Metadata["status"])
|
||||
}
|
||||
|
||||
func mustTestAirwallexProvider(t *testing.T, server *httptest.Server) *Airwallex {
|
||||
t.Helper()
|
||||
prov, err := NewAirwallex("1", map[string]string{
|
||||
"clientId": "cid",
|
||||
"apiKey": "key",
|
||||
"webhookSecret": "whsec",
|
||||
"apiBase": airwallexDemoAPIBase,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
prov.config["apiBase"] = server.URL + "/api/v1"
|
||||
prov.httpClient = server.Client()
|
||||
return prov
|
||||
}
|
||||
|
||||
func signedAirwallexHeaders(rawBody, timestamp, secret string) map[string]string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(timestamp))
|
||||
_, _ = mac.Write([]byte(rawBody))
|
||||
return map[string]string{
|
||||
"x-timestamp": timestamp,
|
||||
"x-signature": hex.EncodeToString(mac.Sum(nil)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
)
|
||||
|
||||
// Alipay product codes.
|
||||
const (
|
||||
alipayProductCodePreCreate = "FACE_TO_FACE_PAYMENT"
|
||||
alipayProductCodeWapPay = "QUICK_WAP_WAY"
|
||||
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
|
||||
)
|
||||
|
||||
// Alipay response constants.
|
||||
const (
|
||||
alipayFundChangeYes = "Y"
|
||||
alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
|
||||
alipayRefundSuffix = "-refund"
|
||||
)
|
||||
|
||||
var (
|
||||
alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
|
||||
return client.TradeWapPay(param)
|
||||
}
|
||||
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
return client.TradePreCreate(ctx, param)
|
||||
}
|
||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||
return client.TradePagePay(param)
|
||||
}
|
||||
)
|
||||
|
||||
// Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
|
||||
type Alipay struct {
|
||||
instanceID string
|
||||
config map[string]string // appId, privateKey, publicKey (or alipayPublicKey), notifyUrl, returnUrl
|
||||
|
||||
mu sync.Mutex
|
||||
client *alipay.Client
|
||||
}
|
||||
|
||||
// NewAlipay creates a new Alipay provider instance.
|
||||
func NewAlipay(instanceID string, config map[string]string) (*Alipay, error) {
|
||||
required := []string{"appId", "privateKey"}
|
||||
for _, k := range required {
|
||||
if config[k] == "" {
|
||||
return nil, fmt.Errorf("alipay config missing required key: %s", k)
|
||||
}
|
||||
}
|
||||
return &Alipay{
|
||||
instanceID: instanceID,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Alipay) getClient() (*alipay.Client, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.client != nil {
|
||||
return a.client, nil
|
||||
}
|
||||
client, err := alipay.New(a.config["appId"], a.config["privateKey"], true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay init client: %w", err)
|
||||
}
|
||||
pubKey := a.config["publicKey"]
|
||||
if pubKey == "" {
|
||||
pubKey = a.config["alipayPublicKey"]
|
||||
}
|
||||
if pubKey == "" {
|
||||
return nil, fmt.Errorf("alipay config missing required key: publicKey (or alipayPublicKey)")
|
||||
}
|
||||
if err := client.LoadAliPayPublicKey(pubKey); err != nil {
|
||||
return nil, fmt.Errorf("alipay load public key: %w", err)
|
||||
}
|
||||
a.client = client
|
||||
return a.client, nil
|
||||
}
|
||||
|
||||
func (a *Alipay) Name() string { return "Alipay" }
|
||||
func (a *Alipay) ProviderKey() string { return payment.TypeAlipay }
|
||||
func (a *Alipay) SupportedTypes() []payment.PaymentType {
|
||||
return []payment.PaymentType{payment.TypeAlipay}
|
||||
}
|
||||
|
||||
func (a *Alipay) MerchantIdentityMetadata() map[string]string {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
appID := strings.TrimSpace(a.config["appId"])
|
||||
if appID == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"app_id": appID}
|
||||
}
|
||||
|
||||
// CreatePayment creates an Alipay payment using the following routing:
|
||||
// - Mobile (H5), default: alipay.trade.wap.pay — browser redirect into Alipay.
|
||||
// - Mobile with AlipayMobilePrecreate: alipay.trade.precreate — return the
|
||||
// dynamic QR payload so the frontend can open it through the Alipay app.
|
||||
// - Desktop, default: prefer alipay.trade.precreate (FACE_TO_FACE_PAYMENT) to
|
||||
// get a scannable QR payload. If precreate is unavailable for the merchant,
|
||||
// fall back to alipay.trade.page.pay and expose pay_url only — the frontend
|
||||
// opens the Alipay checkout in a new tab.
|
||||
// - Desktop, paymentMode == "redirect": skip precreate and go straight to
|
||||
// alipay.trade.page.pay so the frontend always opens the Alipay checkout
|
||||
// in a new tab. Use this when the merchant has not enabled FACE_TO_FACE_PAYMENT.
|
||||
//
|
||||
// Note: alipay.trade.page.pay returns a checkout page URL, not a scannable
|
||||
// payment QR. Never expose it via the QRCode field.
|
||||
func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notifyURL := a.config["notifyUrl"]
|
||||
if req.NotifyURL != "" {
|
||||
notifyURL = req.NotifyURL
|
||||
}
|
||||
returnURL := a.config["returnUrl"]
|
||||
if req.ReturnURL != "" {
|
||||
returnURL = req.ReturnURL
|
||||
}
|
||||
|
||||
if req.IsMobile {
|
||||
if req.AlipayMobilePrecreate {
|
||||
return a.createPrecreateTrade(ctx, client, req, notifyURL)
|
||||
}
|
||||
return a.createWapTrade(client, req, notifyURL, returnURL)
|
||||
}
|
||||
return a.createDesktopTrade(ctx, client, req, notifyURL, returnURL)
|
||||
}
|
||||
|
||||
func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||
param := alipay.TradeWapPay{}
|
||||
param.OutTradeNo = req.OrderID
|
||||
param.TotalAmount = req.Amount
|
||||
param.Subject = req.Subject
|
||||
param.ProductCode = alipayProductCodeWapPay
|
||||
param.NotifyURL = notifyURL
|
||||
param.ReturnURL = returnURL
|
||||
|
||||
payURL, err := alipayTradeWapPay(client, param)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
|
||||
}
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
PayURL: payURL.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||
// Explicit redirect mode: merchant opted into "always open the Alipay
|
||||
// checkout page in a new tab" via the provider instance's payment_mode.
|
||||
// Skip precreate to avoid a wasted API call.
|
||||
if strings.EqualFold(strings.TrimSpace(a.config["paymentMode"]), "redirect") {
|
||||
return a.createPagePayTrade(client, req, notifyURL, returnURL)
|
||||
}
|
||||
|
||||
resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL)
|
||||
if precreateErr == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp, pagePayErr := a.createPagePayTrade(client, req, notifyURL, returnURL)
|
||||
if pagePayErr == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("alipay desktop payment failed: precreate=%v; pagepay=%w", precreateErr, pagePayErr)
|
||||
}
|
||||
|
||||
func (a *Alipay) createPrecreateTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL string) (*payment.CreatePaymentResponse, error) {
|
||||
param := alipay.TradePreCreate{}
|
||||
param.OutTradeNo = req.OrderID
|
||||
param.TotalAmount = req.Amount
|
||||
param.Subject = req.Subject
|
||||
param.ProductCode = alipayProductCodePreCreate
|
||||
param.NotifyURL = notifyURL
|
||||
|
||||
rsp, err := alipayTradePreCreate(ctx, client, param)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradePreCreate: %w", err)
|
||||
}
|
||||
if rsp == nil {
|
||||
return nil, fmt.Errorf("alipay TradePreCreate: empty response")
|
||||
}
|
||||
if rsp.IsFailure() {
|
||||
return nil, fmt.Errorf("alipay TradePreCreate failed: %s", rsp.Error.Error())
|
||||
}
|
||||
if strings.TrimSpace(rsp.QRCode) == "" {
|
||||
return nil, fmt.Errorf("alipay TradePreCreate: empty qr_code")
|
||||
}
|
||||
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
QRCode: rsp.QRCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||
param := alipay.TradePagePay{}
|
||||
param.OutTradeNo = req.OrderID
|
||||
param.TotalAmount = req.Amount
|
||||
param.Subject = req.Subject
|
||||
param.ProductCode = alipayProductCodePagePay
|
||||
param.NotifyURL = notifyURL
|
||||
param.ReturnURL = returnURL
|
||||
|
||||
payURL, err := alipayTradePagePay(client, param)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradePagePay: %w", err)
|
||||
}
|
||||
// Only PayURL is exposed: alipay.trade.page.pay returns a checkout page URL
|
||||
// that must be opened in a browser, not a scannable payment QR. Setting it
|
||||
// as QRCode would let the frontend render an unscannable image.
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
PayURL: payURL.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryOrder queries the trade status via Alipay.
|
||||
func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: tradeNo})
|
||||
if err != nil {
|
||||
if isTradeNotExist(err) {
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: tradeNo,
|
||||
Status: payment.ProviderStatusPending,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("alipay TradeQuery: %w", err)
|
||||
}
|
||||
|
||||
status := payment.ProviderStatusPending
|
||||
switch result.TradeStatus {
|
||||
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
|
||||
status = payment.ProviderStatusPaid
|
||||
case alipay.TradeStatusClosed:
|
||||
status = payment.ProviderStatusFailed
|
||||
}
|
||||
|
||||
amount, err := strconv.ParseFloat(result.TotalAmount, 64)
|
||||
if err != nil {
|
||||
amount, err = parseAlipayAmount(
|
||||
result.TotalAmount,
|
||||
result.ReceiptAmount,
|
||||
result.BuyerPayAmount,
|
||||
result.InvoiceAmount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay parse amount: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: result.TradeNo,
|
||||
Status: status,
|
||||
Amount: amount,
|
||||
PaidAt: result.SendPayDate,
|
||||
Metadata: a.MerchantIdentityMetadata(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyNotification decodes and verifies an Alipay async notification.
|
||||
func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values, err := url.ParseQuery(rawBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay parse notification: %w", err)
|
||||
}
|
||||
|
||||
notification, err := client.DecodeNotification(ctx, values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay verify notification: %w", err)
|
||||
}
|
||||
|
||||
status := payment.ProviderStatusFailed
|
||||
if notification.TradeStatus == alipay.TradeStatusSuccess || notification.TradeStatus == alipay.TradeStatusFinished {
|
||||
status = payment.ProviderStatusSuccess
|
||||
}
|
||||
|
||||
amount, err := strconv.ParseFloat(notification.TotalAmount, 64)
|
||||
if err != nil {
|
||||
amount, err = parseAlipayAmount(
|
||||
notification.TotalAmount,
|
||||
notification.ReceiptAmount,
|
||||
notification.BuyerPayAmount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay parse notification amount: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
metadata := a.MerchantIdentityMetadata()
|
||||
if appID := strings.TrimSpace(notification.AppId); appID != "" {
|
||||
if metadata == nil {
|
||||
metadata = map[string]string{}
|
||||
}
|
||||
metadata["app_id"] = appID
|
||||
}
|
||||
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: notification.TradeNo,
|
||||
OrderID: notification.OutTradeNo,
|
||||
Amount: amount,
|
||||
Status: status,
|
||||
RawData: rawBody,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refund requests a refund through Alipay.
|
||||
func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.TradeRefund(ctx, alipay.TradeRefund{
|
||||
OutTradeNo: req.OrderID,
|
||||
RefundAmount: req.Amount,
|
||||
RefundReason: req.Reason,
|
||||
OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradeRefund: %w", err)
|
||||
}
|
||||
|
||||
refundStatus := payment.ProviderStatusPending
|
||||
if result.FundChange == alipayFundChangeYes {
|
||||
refundStatus = payment.ProviderStatusSuccess
|
||||
}
|
||||
|
||||
refundID := result.TradeNo
|
||||
if refundID == "" {
|
||||
refundID = req.OrderID + alipayRefundSuffix
|
||||
}
|
||||
|
||||
return &payment.RefundResponse{
|
||||
RefundID: refundID,
|
||||
Status: refundStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelPayment closes a pending trade on Alipay.
|
||||
func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.TradeClose(ctx, alipay.TradeClose{OutTradeNo: tradeNo})
|
||||
if err != nil {
|
||||
if isTradeNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("alipay TradeClose: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTradeNotExist(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), alipayErrTradeNotExist)
|
||||
}
|
||||
|
||||
func parseAlipayAmount(values ...string) (float64, error) {
|
||||
for _, raw := range values {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
amount, err := strconv.ParseFloat(raw, 64)
|
||||
if err == nil {
|
||||
return amount, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no valid amount field")
|
||||
}
|
||||
|
||||
// Ensure interface compliance.
|
||||
var (
|
||||
_ payment.Provider = (*Alipay)(nil)
|
||||
_ payment.CancelableProvider = (*Alipay)(nil)
|
||||
_ payment.MerchantIdentityProvider = (*Alipay)(nil)
|
||||
)
|
||||
@@ -0,0 +1,446 @@
|
||||
//go:build unit
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
)
|
||||
|
||||
func TestIsTradeNotExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil error returns false",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "error containing ACQ.TRADE_NOT_EXIST returns true",
|
||||
err: errors.New("alipay: sub_code=ACQ.TRADE_NOT_EXIST, sub_msg=交易不存在"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "error not containing the code returns false",
|
||||
err: errors.New("alipay: sub_code=ACQ.SYSTEM_ERROR, sub_msg=系统错误"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "error with only partial match returns false",
|
||||
err: errors.New("ACQ.TRADE_NOT"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "error with exact constant value returns true",
|
||||
err: errors.New(alipayErrTradeNotExist),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := isTradeNotExist(tt.err)
|
||||
if got != tt.want {
|
||||
t.Errorf("isTradeNotExist(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAlipay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validConfig := map[string]string{
|
||||
"appId": "2021001234567890",
|
||||
"privateKey": "MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...",
|
||||
}
|
||||
|
||||
// helper to clone and override config fields
|
||||
withOverride := func(overrides map[string]string) map[string]string {
|
||||
cfg := make(map[string]string, len(validConfig))
|
||||
for k, v := range validConfig {
|
||||
cfg[k] = v
|
||||
}
|
||||
for k, v := range overrides {
|
||||
cfg[k] = v
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]string
|
||||
wantErr bool
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "valid config succeeds",
|
||||
config: validConfig,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing appId",
|
||||
config: withOverride(map[string]string{"appId": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "appId",
|
||||
},
|
||||
{
|
||||
name: "missing privateKey",
|
||||
config: withOverride(map[string]string{"privateKey": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "privateKey",
|
||||
},
|
||||
{
|
||||
name: "nil config map returns error for appId",
|
||||
config: map[string]string{},
|
||||
wantErr: true,
|
||||
errSubstr: "appId",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := NewAlipay("test-instance", tt.config)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil Alipay instance")
|
||||
}
|
||||
if got.instanceID != "test-instance" {
|
||||
t.Errorf("instanceID = %q, want %q", got.instanceID, "test-instance")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origPagePay := alipayTradePagePay
|
||||
origWapPay := alipayTradeWapPay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradePagePay = origPagePay
|
||||
alipayTradeWapPay = origWapPay
|
||||
})
|
||||
|
||||
preCreateCalls := 0
|
||||
pagePayCalls := 0
|
||||
wapPayCalls := 0
|
||||
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
preCreateCalls++
|
||||
return nil, errors.New("merchant does not have FACE_TO_FACE_PAYMENT")
|
||||
}
|
||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||
pagePayCalls++
|
||||
if param.OutTradeNo != "sub2_100" {
|
||||
t.Fatalf("out_trade_no = %q, want %q", param.OutTradeNo, "sub2_100")
|
||||
}
|
||||
if param.NotifyURL != "https://merchant.example.com/api/v1/payment/webhook/alipay" {
|
||||
t.Fatalf("notify_url = %q", param.NotifyURL)
|
||||
}
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
|
||||
}
|
||||
alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
|
||||
wapPayCalls++
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{}
|
||||
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_100",
|
||||
Amount: "88.00",
|
||||
Subject: "Balance recharge",
|
||||
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if preCreateCalls != 1 {
|
||||
t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
|
||||
}
|
||||
if pagePayCalls != 1 {
|
||||
t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
|
||||
}
|
||||
if wapPayCalls != 0 {
|
||||
t.Fatalf("wap pay calls = %d, want 0", wapPayCalls)
|
||||
}
|
||||
if resp.PayURL == "" {
|
||||
t.Fatal("expected pay_url for desktop page pay")
|
||||
}
|
||||
// page.pay returns a checkout page URL, not a scannable QR payload —
|
||||
// it must never be exposed via QRCode (the frontend would render an
|
||||
// unscannable image from it).
|
||||
if resp.QRCode != "" {
|
||||
t.Fatalf("qr_code = %q, want empty for page pay", resp.QRCode)
|
||||
}
|
||||
}
|
||||
|
||||
// When the provider instance is configured with paymentMode == "redirect",
|
||||
// the desktop flow must skip precreate and go straight to page.pay.
|
||||
func TestCreateTradeRedirectModeSkipsPrecreate(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origPagePay := alipayTradePagePay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradePagePay = origPagePay
|
||||
})
|
||||
|
||||
preCreateCalls := 0
|
||||
pagePayCalls := 0
|
||||
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
preCreateCalls++
|
||||
return &alipay.TradePreCreateRsp{
|
||||
Error: alipay.Error{Code: alipay.CodeSuccess},
|
||||
QRCode: "https://qr.alipay.example.com/precreate-token",
|
||||
}, nil
|
||||
}
|
||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||
pagePayCalls++
|
||||
if param.ProductCode != alipayProductCodePagePay {
|
||||
t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePagePay)
|
||||
}
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{
|
||||
config: map[string]string{"paymentMode": "redirect"},
|
||||
}
|
||||
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_103",
|
||||
Amount: "12.00",
|
||||
Subject: "Balance recharge",
|
||||
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if preCreateCalls != 0 {
|
||||
t.Fatalf("precreate calls = %d, want 0 (redirect mode must skip precreate)", preCreateCalls)
|
||||
}
|
||||
if pagePayCalls != 1 {
|
||||
t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
|
||||
}
|
||||
if resp.PayURL == "" {
|
||||
t.Fatal("expected pay_url for redirect mode")
|
||||
}
|
||||
if resp.QRCode != "" {
|
||||
t.Fatalf("qr_code = %q, want empty for redirect mode", resp.QRCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTradeUsesWapPayForMobile(t *testing.T) {
|
||||
origWapPay := alipayTradeWapPay
|
||||
t.Cleanup(func() {
|
||||
alipayTradeWapPay = origWapPay
|
||||
})
|
||||
|
||||
wapPayCalls := 0
|
||||
alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
|
||||
wapPayCalls++
|
||||
if param.ReturnURL != "https://merchant.example.com/payment/result" {
|
||||
t.Fatalf("return_url = %q", param.ReturnURL)
|
||||
}
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{}
|
||||
resp, err := provider.createWapTrade(&alipay.Client{}, payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_101",
|
||||
Amount: "18.00",
|
||||
Subject: "Balance recharge",
|
||||
IsMobile: true,
|
||||
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if wapPayCalls != 1 {
|
||||
t.Fatalf("wap pay calls = %d, want 1", wapPayCalls)
|
||||
}
|
||||
if resp.PayURL == "" {
|
||||
t.Fatal("expected pay_url for mobile wap pay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentUsesPrecreateForMobileWhenEnabled(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origWapPay := alipayTradeWapPay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradeWapPay = origWapPay
|
||||
})
|
||||
|
||||
precreateCalls := 0
|
||||
wapPayCalls := 0
|
||||
alipayTradePreCreate = func(_ context.Context, _ *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
precreateCalls++
|
||||
if param.OutTradeNo != "sub2_mobile_precreate" {
|
||||
t.Fatalf("out_trade_no = %q", param.OutTradeNo)
|
||||
}
|
||||
if param.ProductCode != alipayProductCodePreCreate {
|
||||
t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePreCreate)
|
||||
}
|
||||
return &alipay.TradePreCreateRsp{
|
||||
Error: alipay.Error{Code: alipay.CodeSuccess},
|
||||
QRCode: "https://qr.alipay.example.com/mobile-dynamic-token",
|
||||
}, nil
|
||||
}
|
||||
alipayTradeWapPay = func(_ *alipay.Client, _ alipay.TradeWapPay) (*url.URL, error) {
|
||||
wapPayCalls++
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{client: &alipay.Client{}, config: map[string]string{}}
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_mobile_precreate",
|
||||
Amount: "28.00",
|
||||
Subject: "Balance recharge",
|
||||
IsMobile: true,
|
||||
AlipayMobilePrecreate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if precreateCalls != 1 || wapPayCalls != 0 {
|
||||
t.Fatalf("precreate calls = %d, wap calls = %d; want 1, 0", precreateCalls, wapPayCalls)
|
||||
}
|
||||
if resp.QRCode != "https://qr.alipay.example.com/mobile-dynamic-token" || resp.PayURL != "" {
|
||||
t.Fatalf("unexpected response: qr_code=%q pay_url=%q", resp.QRCode, resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentKeepsWapPayForMobileWhenPrecreateDisabled(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origWapPay := alipayTradeWapPay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradeWapPay = origWapPay
|
||||
})
|
||||
|
||||
precreateCalls := 0
|
||||
wapPayCalls := 0
|
||||
alipayTradePreCreate = func(_ context.Context, _ *alipay.Client, _ alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
precreateCalls++
|
||||
return nil, errors.New("unexpected precreate call")
|
||||
}
|
||||
alipayTradeWapPay = func(_ *alipay.Client, _ alipay.TradeWapPay) (*url.URL, error) {
|
||||
wapPayCalls++
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{client: &alipay.Client{}, config: map[string]string{}}
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_mobile_wap",
|
||||
Amount: "18.00",
|
||||
Subject: "Balance recharge",
|
||||
IsMobile: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if precreateCalls != 0 || wapPayCalls != 1 {
|
||||
t.Fatalf("precreate calls = %d, wap calls = %d; want 0, 1", precreateCalls, wapPayCalls)
|
||||
}
|
||||
if resp.PayURL == "" || resp.QRCode != "" {
|
||||
t.Fatalf("unexpected response: qr_code=%q pay_url=%q", resp.QRCode, resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTradeUsesPrecreateForDesktopWhenAvailable(t *testing.T) {
|
||||
origPreCreate := alipayTradePreCreate
|
||||
origPagePay := alipayTradePagePay
|
||||
t.Cleanup(func() {
|
||||
alipayTradePreCreate = origPreCreate
|
||||
alipayTradePagePay = origPagePay
|
||||
})
|
||||
|
||||
preCreateCalls := 0
|
||||
pagePayCalls := 0
|
||||
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||
preCreateCalls++
|
||||
if param.ProductCode != alipayProductCodePreCreate {
|
||||
t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePreCreate)
|
||||
}
|
||||
return &alipay.TradePreCreateRsp{
|
||||
Error: alipay.Error{Code: alipay.CodeSuccess},
|
||||
QRCode: "https://qr.alipay.example.com/precreate-token",
|
||||
}, nil
|
||||
}
|
||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||
pagePayCalls++
|
||||
return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
|
||||
}
|
||||
|
||||
provider := &Alipay{}
|
||||
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_102",
|
||||
Amount: "66.00",
|
||||
Subject: "Balance recharge",
|
||||
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if preCreateCalls != 1 {
|
||||
t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
|
||||
}
|
||||
if pagePayCalls != 0 {
|
||||
t.Fatalf("page pay calls = %d, want 0", pagePayCalls)
|
||||
}
|
||||
if resp.QRCode != "https://qr.alipay.example.com/precreate-token" {
|
||||
t.Fatalf("qr_code = %q", resp.QRCode)
|
||||
}
|
||||
if resp.PayURL != "" {
|
||||
t.Fatalf("pay_url = %q, want empty for precreate", resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlipayMerchantIdentityMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &Alipay{
|
||||
config: map[string]string{
|
||||
"appId": "2021001234567890",
|
||||
},
|
||||
}
|
||||
|
||||
metadata := provider.MerchantIdentityMetadata()
|
||||
if metadata["app_id"] != "2021001234567890" {
|
||||
t.Fatalf("app_id = %q, want %q", metadata["app_id"], "2021001234567890")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAlipayAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
amount, err := parseAlipayAmount("", "88.00", "77.00")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if amount != 88 {
|
||||
t.Fatalf("amount = %v, want 88", amount)
|
||||
}
|
||||
|
||||
if _, err := parseAlipayAmount("", "not-a-number"); err == nil {
|
||||
t.Fatal("expected error when no valid amount field exists")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
// Package provider contains concrete payment provider implementations.
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
// EasyPay constants.
|
||||
const (
|
||||
easypayCodeSuccess = 1
|
||||
easypayStatusPaid = 1
|
||||
easypayHTTPTimeout = 10 * time.Second
|
||||
maxEasypayResponseSize = 1 << 20 // 1MB
|
||||
maxEasypayErrorSummary = 512
|
||||
tradeStatusSuccess = "TRADE_SUCCESS"
|
||||
signTypeMD5 = "MD5"
|
||||
paymentModePopup = "popup"
|
||||
deviceMobile = "mobile"
|
||||
)
|
||||
|
||||
// EasyPay implements payment.Provider for the EasyPay aggregation platform.
|
||||
type EasyPay struct {
|
||||
instanceID string
|
||||
config map[string]string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type easyPayCustomMethod struct {
|
||||
Type string `json:"type"`
|
||||
UpstreamType string `json:"upstreamType"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
// NewEasyPay creates a new EasyPay provider.
|
||||
// config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay
|
||||
func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) {
|
||||
for _, k := range []string{"pid", "pkey", "apiBase", "notifyUrl", "returnUrl"} {
|
||||
if strings.TrimSpace(config[k]) == "" {
|
||||
return nil, fmt.Errorf("easypay config missing required key: %s", k)
|
||||
}
|
||||
}
|
||||
cfg := make(map[string]string, len(config))
|
||||
for k, v := range config {
|
||||
cfg[k] = v
|
||||
}
|
||||
cfg["apiBase"] = normalizeEasyPayAPIBase(cfg["apiBase"])
|
||||
return &EasyPay{
|
||||
instanceID: instanceID,
|
||||
config: cfg,
|
||||
httpClient: &http.Client{Timeout: easypayHTTPTimeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeEasyPayAPIBase(apiBase string) string {
|
||||
base := strings.TrimSpace(apiBase)
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := url.Parse(base); err == nil && parsed.Scheme != "" && parsed.Host != "" {
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.RawPath = ""
|
||||
parsed.Path = trimEasyPayEndpointPath(parsed.Path)
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
return strings.TrimRight(trimEasyPayEndpointPath(base), "/")
|
||||
}
|
||||
|
||||
func trimEasyPayEndpointPath(path string) string {
|
||||
path = strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
lower := strings.ToLower(path)
|
||||
for _, endpoint := range []string{"/submit.php", "/mapi.php", "/api.php"} {
|
||||
if strings.HasSuffix(lower, endpoint) {
|
||||
return strings.TrimRight(path[:len(path)-len(endpoint)], "/")
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (e *EasyPay) apiBase() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeEasyPayAPIBase(e.config["apiBase"])
|
||||
}
|
||||
|
||||
func (e *EasyPay) Name() string { return "EasyPay" }
|
||||
func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay }
|
||||
func (e *EasyPay) SupportedTypes() []payment.PaymentType {
|
||||
types := []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
|
||||
for _, method := range e.customMethods() {
|
||||
if method.Type != "" {
|
||||
types = append(types, method.Type)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func (e *EasyPay) MerchantIdentityMetadata() map[string]string {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
pid := strings.TrimSpace(e.config["pid"])
|
||||
if pid == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"pid": pid}
|
||||
}
|
||||
|
||||
func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
// Payment mode determined by instance config, not payment type.
|
||||
// "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
|
||||
mode := e.config["paymentMode"]
|
||||
if mode == paymentModePopup {
|
||||
return e.createRedirectPayment(req)
|
||||
}
|
||||
return e.createAPIPayment(ctx, req)
|
||||
}
|
||||
|
||||
// createRedirectPayment builds a submit.php URL for browser redirect.
|
||||
// No server-side API call — the user is redirected to EasyPay's hosted page.
|
||||
// TradeNo is empty; it arrives via the notify callback after payment.
|
||||
func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
notifyURL, returnURL := e.resolveURLs(req)
|
||||
paymentType := e.upstreamPaymentType(req.PaymentType)
|
||||
params := map[string]string{
|
||||
"pid": e.config["pid"], "type": paymentType,
|
||||
"out_trade_no": req.OrderID, "notify_url": notifyURL,
|
||||
"return_url": returnURL, "name": req.Subject,
|
||||
"money": req.Amount,
|
||||
}
|
||||
if cid := e.resolveCID(paymentType); cid != "" {
|
||||
params["cid"] = cid
|
||||
}
|
||||
if req.IsMobile {
|
||||
params["device"] = deviceMobile
|
||||
}
|
||||
params["sign"] = easyPaySign(params, e.config["pkey"])
|
||||
params["sign_type"] = signTypeMD5
|
||||
|
||||
q := url.Values{}
|
||||
for k, v := range params {
|
||||
q.Set(k, v)
|
||||
}
|
||||
payURL := e.apiBase() + "/submit.php?" + q.Encode()
|
||||
return &payment.CreatePaymentResponse{PayURL: payURL}, nil
|
||||
}
|
||||
|
||||
// createAPIPayment calls mapi.php to get payurl/qrcode (existing behavior).
|
||||
func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
notifyURL, returnURL := e.resolveURLs(req)
|
||||
paymentType := e.upstreamPaymentType(req.PaymentType)
|
||||
params := map[string]string{
|
||||
"pid": e.config["pid"], "type": paymentType,
|
||||
"out_trade_no": req.OrderID, "notify_url": notifyURL,
|
||||
"return_url": returnURL, "name": req.Subject,
|
||||
"money": req.Amount, "clientip": req.ClientIP,
|
||||
}
|
||||
if cid := e.resolveCID(paymentType); cid != "" {
|
||||
params["cid"] = cid
|
||||
}
|
||||
if req.IsMobile {
|
||||
params["device"] = deviceMobile
|
||||
}
|
||||
params["sign"] = easyPaySign(params, e.config["pkey"])
|
||||
params["sign_type"] = signTypeMD5
|
||||
|
||||
body, err := e.post(ctx, e.apiBase()+"/mapi.php", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("easypay create: %w", err)
|
||||
}
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TradeNo string `json:"trade_no"`
|
||||
PayURL string `json:"payurl"`
|
||||
PayURL2 string `json:"payurl2"` // H5 mobile payment URL
|
||||
QRCode string `json:"qrcode"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("easypay parse: %w", err)
|
||||
}
|
||||
if resp.Code != easypayCodeSuccess {
|
||||
return nil, fmt.Errorf("easypay error: %s", resp.Msg)
|
||||
}
|
||||
payURL := resp.PayURL
|
||||
if req.IsMobile && resp.PayURL2 != "" {
|
||||
payURL = resp.PayURL2
|
||||
}
|
||||
return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: payURL, QRCode: resp.QRCode}, nil
|
||||
}
|
||||
|
||||
// resolveURLs returns (notifyURL, returnURL) preferring request values,
|
||||
// falling back to instance config.
|
||||
func (e *EasyPay) resolveURLs(req payment.CreatePaymentRequest) (string, string) {
|
||||
notifyURL := req.NotifyURL
|
||||
if notifyURL == "" {
|
||||
notifyURL = e.config["notifyUrl"]
|
||||
}
|
||||
returnURL := req.ReturnURL
|
||||
if returnURL == "" {
|
||||
returnURL = e.config["returnUrl"]
|
||||
}
|
||||
return notifyURL, returnURL
|
||||
}
|
||||
|
||||
func (e *EasyPay) customMethods() []easyPayCustomMethod {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
raw := strings.TrimSpace(e.config["customMethods"])
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var methods []easyPayCustomMethod
|
||||
if err := json.Unmarshal([]byte(raw), &methods); err != nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]easyPayCustomMethod, 0, len(methods))
|
||||
for _, method := range methods {
|
||||
method.Type = strings.TrimSpace(method.Type)
|
||||
method.UpstreamType = strings.TrimSpace(method.UpstreamType)
|
||||
method.DisplayName = strings.TrimSpace(method.DisplayName)
|
||||
if method.Type == "" || method.UpstreamType == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, method)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *EasyPay) upstreamPaymentType(paymentType string) string {
|
||||
paymentType = strings.TrimSpace(paymentType)
|
||||
for _, method := range e.customMethods() {
|
||||
if paymentType == method.Type {
|
||||
return method.UpstreamType
|
||||
}
|
||||
}
|
||||
return paymentType
|
||||
}
|
||||
|
||||
func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
params := map[string]string{
|
||||
"act": "order", "pid": e.config["pid"],
|
||||
"key": e.config["pkey"], "out_trade_no": tradeNo,
|
||||
}
|
||||
body, err := e.post(ctx, e.apiBase()+"/api.php", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("easypay query: %w", err)
|
||||
}
|
||||
type easyPayQueryData struct {
|
||||
TradeStatus *string `json:"trade_status"`
|
||||
Status *int `json:"status"`
|
||||
Money *string `json:"money"`
|
||||
TradeNo *string `json:"trade_no"`
|
||||
}
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TradeStatus *string `json:"trade_status"`
|
||||
Status *int `json:"status"`
|
||||
Money *string `json:"money"`
|
||||
TradeNo *string `json:"trade_no"`
|
||||
Data easyPayQueryData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("easypay parse query: %w", err)
|
||||
}
|
||||
status := payment.ProviderStatusPending
|
||||
if resp.TradeStatus != nil {
|
||||
if *resp.TradeStatus == tradeStatusSuccess {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
} else if resp.Data.TradeStatus != nil {
|
||||
if *resp.Data.TradeStatus == tradeStatusSuccess {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
} else if resp.Status != nil {
|
||||
if *resp.Status == easypayStatusPaid {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
} else if resp.Data.Status != nil && *resp.Data.Status == easypayStatusPaid {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
|
||||
money := ""
|
||||
if resp.Money != nil {
|
||||
money = *resp.Money
|
||||
} else if resp.Data.Money != nil {
|
||||
money = *resp.Data.Money
|
||||
}
|
||||
responseTradeNo := tradeNo
|
||||
if resp.TradeNo != nil {
|
||||
if *resp.TradeNo != "" {
|
||||
responseTradeNo = *resp.TradeNo
|
||||
}
|
||||
} else if resp.Data.TradeNo != nil && *resp.Data.TradeNo != "" {
|
||||
responseTradeNo = *resp.Data.TradeNo
|
||||
}
|
||||
|
||||
amount, _ := strconv.ParseFloat(money, 64)
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: responseTradeNo,
|
||||
Status: status,
|
||||
Amount: amount,
|
||||
Metadata: e.MerchantIdentityMetadata(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
|
||||
values, err := url.ParseQuery(rawBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse notify: %w", err)
|
||||
}
|
||||
// url.ParseQuery already decodes values — no additional decode needed.
|
||||
params := make(map[string]string)
|
||||
for k := range values {
|
||||
params[k] = values.Get(k)
|
||||
}
|
||||
sign := params["sign"]
|
||||
if sign == "" {
|
||||
return nil, fmt.Errorf("missing sign")
|
||||
}
|
||||
if !easyPayVerifySign(params, e.config["pkey"], sign) {
|
||||
return nil, fmt.Errorf("invalid signature")
|
||||
}
|
||||
status := payment.ProviderStatusFailed
|
||||
if params["trade_status"] == tradeStatusSuccess {
|
||||
status = payment.ProviderStatusSuccess
|
||||
}
|
||||
amount, _ := strconv.ParseFloat(params["money"], 64)
|
||||
|
||||
metadata := e.MerchantIdentityMetadata()
|
||||
if pid := strings.TrimSpace(params["pid"]); pid != "" {
|
||||
if metadata == nil {
|
||||
metadata = map[string]string{}
|
||||
}
|
||||
metadata["pid"] = pid
|
||||
}
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: params["trade_no"], OrderID: params["out_trade_no"],
|
||||
Amount: amount, Status: status, RawData: rawBody, Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *EasyPay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
attempts := e.refundAttempts(req)
|
||||
if len(attempts) == 0 {
|
||||
return nil, fmt.Errorf("easypay refund missing order identifier")
|
||||
}
|
||||
var firstErr error
|
||||
for i, attempt := range attempts {
|
||||
body, status, err := e.postRaw(ctx, e.apiBase()+"/api.php?act=refund", attempt.params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("easypay refund request: %w", err)
|
||||
}
|
||||
if err := parseEasyPayRefundResponse(status, body); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if i+1 < len(attempts) && isEasyPayRefundOrderNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &payment.RefundResponse{RefundID: attempt.refundID, Status: payment.ProviderStatusSuccess}, nil
|
||||
}
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
type easyPayRefundAttempt struct {
|
||||
params map[string]string
|
||||
refundID string
|
||||
}
|
||||
|
||||
func (e *EasyPay) refundAttempts(req payment.RefundRequest) []easyPayRefundAttempt {
|
||||
base := map[string]string{
|
||||
"pid": e.config["pid"], "key": e.config["pkey"], "money": req.Amount,
|
||||
}
|
||||
var attempts []easyPayRefundAttempt
|
||||
if orderID := strings.TrimSpace(req.OrderID); orderID != "" {
|
||||
params := cloneStringMap(base)
|
||||
params["out_trade_no"] = orderID
|
||||
attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: orderID})
|
||||
}
|
||||
if tradeNo := strings.TrimSpace(req.TradeNo); tradeNo != "" {
|
||||
params := cloneStringMap(base)
|
||||
params["trade_no"] = tradeNo
|
||||
attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: tradeNo})
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isEasyPayRefundOrderNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
lower := strings.ToLower(msg)
|
||||
return strings.Contains(msg, "订单编号不存在") ||
|
||||
strings.Contains(msg, "订单不存在") ||
|
||||
strings.Contains(lower, "order not found") ||
|
||||
strings.Contains(lower, "not exist")
|
||||
}
|
||||
|
||||
func parseEasyPayRefundResponse(status int, body []byte) error {
|
||||
summary := summarizeEasyPayResponse(body)
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
||||
return fmt.Errorf("easypay refund HTTP %d: %s", status, summary)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("easypay refund empty response (HTTP %d): %s", status, summary)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") ||
|
||||
(strings.HasPrefix(lower, "<") && strings.Contains(lower, "html")) {
|
||||
return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code any `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
|
||||
}
|
||||
if !easyPayResponseCodeIsSuccess(resp.Code) {
|
||||
msg := strings.TrimSpace(resp.Msg)
|
||||
if msg == "" {
|
||||
msg = summary
|
||||
}
|
||||
return fmt.Errorf("easypay refund failed (HTTP %d): %s", status, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func easyPayResponseCodeIsSuccess(code any) bool {
|
||||
switch v := code.(type) {
|
||||
case float64:
|
||||
return int(v) == easypayCodeSuccess
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
return err == nil && n == easypayCodeSuccess
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeEasyPayResponse(body []byte) string {
|
||||
summary := strings.Join(strings.Fields(string(body)), " ")
|
||||
if summary == "" {
|
||||
return "<empty>"
|
||||
}
|
||||
if len(summary) > maxEasypayErrorSummary {
|
||||
truncated := summary[:maxEasypayErrorSummary]
|
||||
for len(truncated) > 0 && !utf8.ValidString(truncated) {
|
||||
truncated = truncated[:len(truncated)-1]
|
||||
}
|
||||
return truncated + "..."
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (e *EasyPay) resolveCID(paymentType string) string {
|
||||
if strings.HasPrefix(paymentType, "alipay") {
|
||||
if v := e.config["cidAlipay"]; v != "" {
|
||||
return v
|
||||
}
|
||||
return e.config["cid"]
|
||||
}
|
||||
if v := e.config["cidWxpay"]; v != "" {
|
||||
return v
|
||||
}
|
||||
return e.config["cid"]
|
||||
}
|
||||
|
||||
func (e *EasyPay) post(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) {
|
||||
body, _, err := e.postRaw(ctx, endpoint, params)
|
||||
return body, err
|
||||
}
|
||||
|
||||
func (e *EasyPay) postRaw(ctx context.Context, endpoint string, params map[string]string) ([]byte, int, error) {
|
||||
form := url.Values{}
|
||||
for k, v := range params {
|
||||
form.Set(k, v)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
client := e.httpClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: easypayHTTPTimeout}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxEasypayResponseSize))
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func easyPaySign(params map[string]string, pkey string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k, v := range params {
|
||||
if k == "sign" || k == "sign_type" || v == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var buf strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
_ = buf.WriteByte('&')
|
||||
}
|
||||
_, _ = buf.WriteString(k + "=" + params[k])
|
||||
}
|
||||
_, _ = buf.WriteString(pkey)
|
||||
hash := md5.Sum([]byte(buf.String()))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func easyPayVerifySign(params map[string]string, pkey string, sign string) bool {
|
||||
return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const orderID = "order-123"
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantStatus string
|
||||
wantTradeNo string
|
||||
wantAmount float64
|
||||
}{
|
||||
{
|
||||
name: "top level trade success is paid",
|
||||
body: `{"code":1,"trade_status":"TRADE_SUCCESS","status":0,"money":"12.34","trade_no":"gateway-123"}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: "gateway-123",
|
||||
wantAmount: 12.34,
|
||||
},
|
||||
{
|
||||
name: "waiting trade status with paid numeric status stays pending",
|
||||
body: `{"code":1,"trade_status":"WAITING","status":1,"money":"12.34","trade_no":"gateway-123"}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: "gateway-123",
|
||||
wantAmount: 12.34,
|
||||
},
|
||||
{
|
||||
name: "empty trade status with paid numeric status stays pending",
|
||||
body: `{"code":1,"trade_status":"","status":1,"money":"12.34"}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: orderID,
|
||||
wantAmount: 12.34,
|
||||
},
|
||||
{
|
||||
name: "nested data trade success is paid",
|
||||
body: `{"code":1,"data":{"trade_status":"TRADE_SUCCESS","status":0,"money":"9.99","trade_no":"data-456"}}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: "data-456",
|
||||
wantAmount: 9.99,
|
||||
},
|
||||
{
|
||||
name: "legacy numeric paid status remains compatible",
|
||||
body: `{"code":1,"status":1,"money":"3.21"}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: orderID,
|
||||
wantAmount: 3.21,
|
||||
},
|
||||
{
|
||||
name: "legacy numeric non paid status is pending",
|
||||
body: `{"code":1,"status":0,"money":"3.21"}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: orderID,
|
||||
wantAmount: 3.21,
|
||||
},
|
||||
{
|
||||
name: "query failure with missing status is pending",
|
||||
body: `{"code":0,"msg":"订单不存在"}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: orderID,
|
||||
},
|
||||
{
|
||||
name: "missing fields are pending",
|
||||
body: `{}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: orderID,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotForm url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want %q", r.Method, http.MethodPost)
|
||||
}
|
||||
if r.URL.Path != "/api.php" {
|
||||
t.Errorf("path = %q, want /api.php", r.URL.Path)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("ParseForm: %v", err)
|
||||
}
|
||||
gotForm = make(url.Values, len(r.PostForm))
|
||||
for key, values := range r.PostForm {
|
||||
gotForm[key] = append([]string(nil), values...)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newTestEasyPay(t, server.URL)
|
||||
resp, err := provider.QueryOrder(context.Background(), orderID)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryOrder returned error: %v", err)
|
||||
}
|
||||
if resp.Status != tt.wantStatus {
|
||||
t.Fatalf("status = %q, want %q (response=%+v)", resp.Status, tt.wantStatus, resp)
|
||||
}
|
||||
if resp.TradeNo != tt.wantTradeNo {
|
||||
t.Fatalf("trade_no = %q, want %q", resp.TradeNo, tt.wantTradeNo)
|
||||
}
|
||||
if resp.Amount != tt.wantAmount {
|
||||
t.Fatalf("amount = %v, want %v", resp.Amount, tt.wantAmount)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"act": "order",
|
||||
"pid": "pid-1",
|
||||
"key": "pkey-1",
|
||||
"out_trade_no": orderID,
|
||||
} {
|
||||
if got := gotForm.Get(key); got != want {
|
||||
t.Fatalf("form[%s] = %q, want %q (form=%v)", key, got, want, gotForm)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
func TestNormalizeEasyPayAPIBase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "https://zpayz.cn", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/mapi.php", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/submit.php", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/api.php", want: "https://zpayz.cn"},
|
||||
{input: "https://zpayz.cn/api.php?act=refund", want: "https://zpayz.cn"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := normalizeEasyPayAPIBase(tt.input); got != tt.want {
|
||||
t.Fatalf("normalizeEasyPayAPIBase(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayRefundNormalizesAPIBaseAndSendsOutTradeNoOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotPath string
|
||||
var gotQuery url.Values
|
||||
var gotForm url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotQuery = r.URL.Query()
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("ParseForm: %v", err)
|
||||
}
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":1,"msg":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newTestEasyPay(t, server.URL+"/mapi.php")
|
||||
resp, err := provider.Refund(context.Background(), payment.RefundRequest{
|
||||
TradeNo: "trade-123",
|
||||
OrderID: "out-456",
|
||||
Amount: "1.50",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Refund returned error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.Status != payment.ProviderStatusSuccess {
|
||||
t.Fatalf("Refund response = %+v, want success", resp)
|
||||
}
|
||||
if gotPath != "/api.php" {
|
||||
t.Fatalf("refund path = %q, want /api.php", gotPath)
|
||||
}
|
||||
if gotQuery.Get("act") != "refund" {
|
||||
t.Fatalf("refund act query = %q, want refund", gotQuery.Get("act"))
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"pid": "pid-1",
|
||||
"key": "pkey-1",
|
||||
"out_trade_no": "out-456",
|
||||
"money": "1.50",
|
||||
} {
|
||||
if got := gotForm.Get(key); got != want {
|
||||
t.Fatalf("form[%s] = %q, want %q (form=%v)", key, got, want, gotForm)
|
||||
}
|
||||
}
|
||||
if got := gotForm.Get("trade_no"); got != "" {
|
||||
t.Fatalf("form[trade_no] = %q, want empty (form=%v)", got, gotForm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayRefundRetriesWithTradeNoWhenOutTradeNoNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotForms []url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api.php" {
|
||||
t.Errorf("refund path = %q, want /api.php", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("act") != "refund" {
|
||||
t.Errorf("refund act query = %q, want refund", r.URL.Query().Get("act"))
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("ParseForm: %v", err)
|
||||
}
|
||||
gotForms = append(gotForms, r.PostForm)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if len(gotForms) == 1 {
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"订单编号不存在!"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":1,"msg":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newTestEasyPay(t, server.URL+"/mapi.php")
|
||||
resp, err := provider.Refund(context.Background(), payment.RefundRequest{
|
||||
TradeNo: "trade-123",
|
||||
OrderID: "out-456",
|
||||
Amount: "1.50",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Refund returned error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.Status != payment.ProviderStatusSuccess || resp.RefundID != "trade-123" {
|
||||
t.Fatalf("Refund response = %+v, want success with trade refund id", resp)
|
||||
}
|
||||
if len(gotForms) != 2 {
|
||||
t.Fatalf("refund attempts = %d, want 2", len(gotForms))
|
||||
}
|
||||
if got := gotForms[0].Get("out_trade_no"); got != "out-456" {
|
||||
t.Fatalf("first form[out_trade_no] = %q, want out-456 (form=%v)", got, gotForms[0])
|
||||
}
|
||||
if got := gotForms[0].Get("trade_no"); got != "" {
|
||||
t.Fatalf("first form[trade_no] = %q, want empty (form=%v)", got, gotForms[0])
|
||||
}
|
||||
if got := gotForms[1].Get("trade_no"); got != "trade-123" {
|
||||
t.Fatalf("second form[trade_no] = %q, want trade-123 (form=%v)", got, gotForms[1])
|
||||
}
|
||||
if got := gotForms[1].Get("out_trade_no"); got != "" {
|
||||
t.Fatalf("second form[out_trade_no] = %q, want empty (form=%v)", got, gotForms[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayRefundResponseErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{name: "html response", statusCode: http.StatusOK, body: "<html>bad config</html>", want: "non-JSON response (HTTP 200): <html>bad config</html>"},
|
||||
{name: "non json response", statusCode: http.StatusOK, body: "not json", want: "non-JSON response (HTTP 200): not json"},
|
||||
{name: "non 2xx response", statusCode: http.StatusBadGateway, body: "bad gateway", want: "HTTP 502: bad gateway"},
|
||||
{name: "empty response", statusCode: http.StatusOK, body: "", want: "empty response (HTTP 200): <empty>"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := newTestEasyPay(t, server.URL)
|
||||
_, err := provider.Refund(context.Background(), payment.RefundRequest{
|
||||
OrderID: "out-456",
|
||||
Amount: "1.50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Refund returned nil error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Refund error = %q, want substring %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeEasyPayResponsePreservesUTF8(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
summary := summarizeEasyPayResponse([]byte(strings.Repeat("错", 171)))
|
||||
if !utf8.ValidString(summary) {
|
||||
t.Fatalf("summarizeEasyPayResponse returned invalid UTF-8: %q", summary)
|
||||
}
|
||||
if !strings.HasSuffix(summary, "...") {
|
||||
t.Fatalf("summarizeEasyPayResponse() = %q, want truncated suffix", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := NewEasyPay("test-instance", map[string]string{
|
||||
"pid": "pid-1",
|
||||
"pkey": "pkey-1",
|
||||
"apiBase": "https://pay.example.com",
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
"paymentMode": paymentModePopup,
|
||||
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2-custom-1",
|
||||
Amount: "1.00",
|
||||
PaymentType: "usdt_trc20",
|
||||
Subject: "Custom EasyPay",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePayment: %v", err)
|
||||
}
|
||||
payURL, err := url.Parse(resp.PayURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse pay url: %v", err)
|
||||
}
|
||||
if got := payURL.Query().Get("type"); got != "usdt" {
|
||||
t.Fatalf("pay url type = %q, want usdt (%s)", got, resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayCustomMethodsResolveCIDFromConfiguredUpstreamType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := NewEasyPay("test-instance", map[string]string{
|
||||
"pid": "pid-1",
|
||||
"pkey": "pkey-1",
|
||||
"apiBase": "https://pay.example.com",
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
"paymentMode": paymentModePopup,
|
||||
"cidAlipay": "cid-alipay",
|
||||
"cidWxpay": "cid-wxpay",
|
||||
"customMethods": `[{"type":"ldc","upstreamType":"alipay","displayName":"LDC"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2-custom-cid",
|
||||
Amount: "1.00",
|
||||
PaymentType: "ldc",
|
||||
Subject: "Custom EasyPay CID",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePayment: %v", err)
|
||||
}
|
||||
payURL, err := url.Parse(resp.PayURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse pay url: %v", err)
|
||||
}
|
||||
if got := payURL.Query().Get("type"); got != "alipay" {
|
||||
t.Fatalf("pay url type = %q, want alipay (%s)", got, resp.PayURL)
|
||||
}
|
||||
if got := payURL.Query().Get("cid"); got != "cid-alipay" {
|
||||
t.Fatalf("pay url cid = %q, want cid-alipay (%s)", got, resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySupportedTypesIncludeCustomMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, err := NewEasyPay("test-instance", map[string]string{
|
||||
"pid": "pid-1",
|
||||
"pkey": "pkey-1",
|
||||
"apiBase": "https://pay.example.com",
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
}
|
||||
|
||||
got := strings.Join(provider.SupportedTypes(), ",")
|
||||
for _, want := range []string{"alipay", "wxpay", "ldc", "usdt_trc20"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("SupportedTypes() = %q, want it to include %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestEasyPay(t *testing.T, apiBase string) *EasyPay {
|
||||
t.Helper()
|
||||
|
||||
provider, err := NewEasyPay("test-instance", map[string]string{
|
||||
"pid": "pid-1",
|
||||
"pkey": "pkey-1",
|
||||
"apiBase": apiBase,
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEasyPaySignConsistentOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"out_trade_no": "ORDER123",
|
||||
"name": "Test Product",
|
||||
"money": "10.00",
|
||||
}
|
||||
pkey := "test_secret_key"
|
||||
|
||||
sign1 := easyPaySign(params, pkey)
|
||||
sign2 := easyPaySign(params, pkey)
|
||||
if sign1 != sign2 {
|
||||
t.Fatalf("easyPaySign should be deterministic: %q != %q", sign1, sign2)
|
||||
}
|
||||
if len(sign1) != 32 {
|
||||
t.Fatalf("MD5 hex should be 32 chars, got %d", len(sign1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySignExcludesSignAndSignType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pkey := "my_key"
|
||||
base := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
}
|
||||
withSign := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"sign": "should_be_ignored",
|
||||
"sign_type": "MD5",
|
||||
}
|
||||
|
||||
signBase := easyPaySign(base, pkey)
|
||||
signWithExtra := easyPaySign(withSign, pkey)
|
||||
|
||||
if signBase != signWithExtra {
|
||||
t.Fatalf("sign and sign_type should be excluded: base=%q, withExtra=%q", signBase, signWithExtra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySignExcludesEmptyValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pkey := "key123"
|
||||
base := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
}
|
||||
withEmpty := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"device": "",
|
||||
"clientip": "",
|
||||
}
|
||||
|
||||
signBase := easyPaySign(base, pkey)
|
||||
signWithEmpty := easyPaySign(withEmpty, pkey)
|
||||
|
||||
if signBase != signWithEmpty {
|
||||
t.Fatalf("empty values should be excluded: base=%q, withEmpty=%q", signBase, signWithEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayVerifySignValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"out_trade_no": "ORDER456",
|
||||
"money": "25.00",
|
||||
}
|
||||
pkey := "secret"
|
||||
|
||||
sign := easyPaySign(params, pkey)
|
||||
|
||||
// Add sign to params (as would come in a real callback)
|
||||
params["sign"] = sign
|
||||
params["sign_type"] = "MD5"
|
||||
|
||||
if !easyPayVerifySign(params, pkey, sign) {
|
||||
t.Fatal("easyPayVerifySign should return true for a valid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayVerifySignTampered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
"out_trade_no": "ORDER789",
|
||||
"money": "50.00",
|
||||
}
|
||||
pkey := "secret"
|
||||
|
||||
sign := easyPaySign(params, pkey)
|
||||
|
||||
// Tamper with the amount
|
||||
params["money"] = "99.99"
|
||||
|
||||
if easyPayVerifySign(params, pkey, sign) {
|
||||
t.Fatal("easyPayVerifySign should return false for tampered params")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayVerifySignWrongKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "wxpay",
|
||||
}
|
||||
|
||||
sign := easyPaySign(params, "correct_key")
|
||||
|
||||
if easyPayVerifySign(params, "wrong_key", sign) {
|
||||
t.Fatal("easyPayVerifySign should return false with wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySignEmptyParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sign := easyPaySign(map[string]string{}, "key123")
|
||||
if sign == "" {
|
||||
t.Fatal("easyPaySign with empty params should still produce a hash")
|
||||
}
|
||||
if len(sign) != 32 {
|
||||
t.Fatalf("MD5 hex should be 32 chars, got %d", len(sign))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPaySignSortOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pkey := "test_key"
|
||||
params1 := map[string]string{
|
||||
"a": "1",
|
||||
"b": "2",
|
||||
"c": "3",
|
||||
}
|
||||
params2 := map[string]string{
|
||||
"c": "3",
|
||||
"a": "1",
|
||||
"b": "2",
|
||||
}
|
||||
|
||||
sign1 := easyPaySign(params1, pkey)
|
||||
sign2 := easyPaySign(params2, pkey)
|
||||
|
||||
if sign1 != sign2 {
|
||||
t.Fatalf("easyPaySign should be order-independent: %q != %q", sign1, sign2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayVerifySignWrongSignValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := map[string]string{
|
||||
"pid": "1001",
|
||||
"type": "alipay",
|
||||
}
|
||||
pkey := "key"
|
||||
|
||||
if easyPayVerifySign(params, pkey, "00000000000000000000000000000000") {
|
||||
t.Fatal("easyPayVerifySign should return false for an incorrect sign value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayMerchantIdentityMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &EasyPay{
|
||||
config: map[string]string{
|
||||
"pid": "1001",
|
||||
},
|
||||
}
|
||||
|
||||
metadata := provider.MerchantIdentityMetadata()
|
||||
if metadata["pid"] != "1001" {
|
||||
t.Fatalf("pid = %q, want %q", metadata["pid"], "1001")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
// CreateProvider creates a Provider from a provider key, instance ID and decrypted config.
|
||||
func CreateProvider(providerKey string, instanceID string, config map[string]string) (payment.Provider, error) {
|
||||
switch providerKey {
|
||||
case payment.TypeEasyPay:
|
||||
return NewEasyPay(instanceID, config)
|
||||
case payment.TypeAlipay:
|
||||
return NewAlipay(instanceID, config)
|
||||
case payment.TypeWxpay:
|
||||
return NewWxpay(instanceID, config)
|
||||
case payment.TypeStripe:
|
||||
return NewStripe(instanceID, config)
|
||||
case payment.TypeAirwallex:
|
||||
return NewAirwallex(instanceID, config)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown provider key: %s", providerKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
stripe "github.com/stripe/stripe-go/v85"
|
||||
"github.com/stripe/stripe-go/v85/webhook"
|
||||
)
|
||||
|
||||
// Stripe constants.
|
||||
const (
|
||||
stripeEventPaymentSuccess = "payment_intent.succeeded"
|
||||
stripeEventPaymentFailed = "payment_intent.payment_failed"
|
||||
)
|
||||
|
||||
// Stripe implements the payment.CancelableProvider interface for Stripe payments.
|
||||
type Stripe struct {
|
||||
instanceID string
|
||||
config map[string]string
|
||||
|
||||
mu sync.Mutex
|
||||
initialized bool
|
||||
sc *stripe.Client
|
||||
}
|
||||
|
||||
// NewStripe creates a new Stripe provider instance.
|
||||
func NewStripe(instanceID string, config map[string]string) (*Stripe, error) {
|
||||
if config["secretKey"] == "" {
|
||||
return nil, fmt.Errorf("stripe config missing required key: secretKey")
|
||||
}
|
||||
cfg := cloneStringMap(config)
|
||||
currency, err := payment.NormalizePaymentCurrency(cfg["currency"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe config currency: %w", err)
|
||||
}
|
||||
cfg["currency"] = currency
|
||||
return &Stripe{
|
||||
instanceID: instanceID,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Stripe) ensureInit() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.initialized {
|
||||
s.sc = stripe.NewClient(s.config["secretKey"])
|
||||
s.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
// GetPublishableKey returns the publishable key for frontend use.
|
||||
func (s *Stripe) GetPublishableKey() string {
|
||||
return s.config["publishableKey"]
|
||||
}
|
||||
|
||||
func (s *Stripe) Name() string { return "Stripe" }
|
||||
func (s *Stripe) ProviderKey() string { return payment.TypeStripe }
|
||||
func (s *Stripe) SupportedTypes() []payment.PaymentType {
|
||||
return []payment.PaymentType{payment.TypeStripe}
|
||||
}
|
||||
|
||||
func (s *Stripe) MerchantIdentityMetadata() map[string]string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"currency": s.currency()}
|
||||
}
|
||||
|
||||
func (s *Stripe) currency() string {
|
||||
if s == nil {
|
||||
return payment.DefaultPaymentCurrency
|
||||
}
|
||||
currency, err := payment.NormalizePaymentCurrency(s.config["currency"])
|
||||
if err != nil {
|
||||
return payment.DefaultPaymentCurrency
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
// stripePaymentMethodTypes maps our PaymentType to Stripe payment_method_types.
|
||||
var stripePaymentMethodTypes = map[string][]string{
|
||||
payment.TypeCard: {"card"},
|
||||
payment.TypeAlipay: {"alipay"},
|
||||
payment.TypeWxpay: {"wechat_pay"},
|
||||
payment.TypeLink: {"link"},
|
||||
}
|
||||
|
||||
// CreatePayment creates a Stripe PaymentIntent.
|
||||
func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
currency := s.currency()
|
||||
amountInMinorUnit, err := payment.AmountToMinorUnit(req.Amount, currency)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe create payment: %w", err)
|
||||
}
|
||||
|
||||
// Collect all Stripe payment_method_types from the instance's configured sub-methods
|
||||
methods := resolveStripeMethodTypes(req.InstanceSubMethods)
|
||||
|
||||
pmTypes := make([]*string, len(methods))
|
||||
for i, m := range methods {
|
||||
pmTypes[i] = stripe.String(m)
|
||||
}
|
||||
|
||||
params := &stripe.PaymentIntentCreateParams{
|
||||
Amount: stripe.Int64(amountInMinorUnit),
|
||||
Currency: stripe.String(strings.ToLower(currency)),
|
||||
PaymentMethodTypes: pmTypes,
|
||||
Description: stripe.String(req.Subject),
|
||||
Metadata: map[string]string{"orderId": req.OrderID},
|
||||
}
|
||||
|
||||
// WeChat Pay requires payment_method_options with client type
|
||||
if hasStripeMethod(methods, "wechat_pay") {
|
||||
params.PaymentMethodOptions = &stripe.PaymentIntentCreatePaymentMethodOptionsParams{
|
||||
WeChatPay: &stripe.PaymentIntentCreatePaymentMethodOptionsWeChatPayParams{
|
||||
Client: stripe.String("web"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
params.SetIdempotencyKey(fmt.Sprintf("pi-%s", req.OrderID))
|
||||
params.Context = ctx
|
||||
|
||||
pi, err := s.sc.V1PaymentIntents.Create(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe create payment: %w", err)
|
||||
}
|
||||
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: pi.ID,
|
||||
ClientSecret: pi.ClientSecret,
|
||||
Currency: currency,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryOrder retrieves a PaymentIntent by ID.
|
||||
func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
pi, err := s.sc.V1PaymentIntents.Retrieve(ctx, tradeNo, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe query order: %w", err)
|
||||
}
|
||||
|
||||
status := payment.ProviderStatusPending
|
||||
switch pi.Status {
|
||||
case stripe.PaymentIntentStatusSucceeded:
|
||||
status = payment.ProviderStatusPaid
|
||||
case stripe.PaymentIntentStatusCanceled:
|
||||
status = payment.ProviderStatusFailed
|
||||
}
|
||||
|
||||
currency := stripeIntentCurrency(pi.Currency, s.currency())
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: pi.ID,
|
||||
Status: status,
|
||||
Amount: payment.MinorUnitToAmount(pi.Amount, currency),
|
||||
Metadata: map[string]string{
|
||||
"currency": currency,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyNotification verifies a Stripe webhook event.
|
||||
func (s *Stripe) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
|
||||
s.ensureInit()
|
||||
|
||||
webhookSecret := s.config["webhookSecret"]
|
||||
if webhookSecret == "" {
|
||||
return nil, fmt.Errorf("stripe webhookSecret not configured")
|
||||
}
|
||||
|
||||
sig := headers["stripe-signature"]
|
||||
if sig == "" {
|
||||
return nil, fmt.Errorf("stripe notification missing stripe-signature header")
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent([]byte(rawBody), sig, webhookSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe verify notification: %w", err)
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case stripeEventPaymentSuccess:
|
||||
return parseStripePaymentIntent(&event, payment.ProviderStatusSuccess, rawBody)
|
||||
case stripeEventPaymentFailed:
|
||||
return parseStripePaymentIntent(&event, payment.ProviderStatusFailed, rawBody)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string) (*payment.PaymentNotification, error) {
|
||||
var pi stripe.PaymentIntent
|
||||
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
|
||||
return nil, fmt.Errorf("stripe parse payment_intent: %w", err)
|
||||
}
|
||||
currency := stripeIntentCurrency(pi.Currency, payment.DefaultPaymentCurrency)
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: pi.ID,
|
||||
OrderID: pi.Metadata["orderId"],
|
||||
Amount: payment.MinorUnitToAmount(pi.Amount, currency),
|
||||
Status: status,
|
||||
RawData: rawBody,
|
||||
Metadata: map[string]string{
|
||||
"currency": currency,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refund creates a Stripe refund.
|
||||
func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
amountInMinorUnit, err := payment.AmountToMinorUnit(req.Amount, s.currency())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe refund: %w", err)
|
||||
}
|
||||
|
||||
params := &stripe.RefundCreateParams{
|
||||
PaymentIntent: stripe.String(req.TradeNo),
|
||||
Amount: stripe.Int64(amountInMinorUnit),
|
||||
Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)),
|
||||
}
|
||||
params.SetIdempotencyKey(fmt.Sprintf("re-%s-%d", req.OrderID, amountInMinorUnit))
|
||||
params.Context = ctx
|
||||
|
||||
r, err := s.sc.V1Refunds.Create(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe refund: %w", err)
|
||||
}
|
||||
|
||||
refundStatus := payment.ProviderStatusPending
|
||||
if r.Status == stripe.RefundStatusSucceeded {
|
||||
refundStatus = payment.ProviderStatusSuccess
|
||||
}
|
||||
|
||||
return &payment.RefundResponse{
|
||||
RefundID: r.ID,
|
||||
Status: refundStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryRefund retrieves a Stripe refund by refund ID when available, otherwise
|
||||
// falls back to the latest refund for the PaymentIntent.
|
||||
func (s *Stripe) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
var r *stripe.Refund
|
||||
var err error
|
||||
if refundID := strings.TrimSpace(req.RefundID); refundID != "" {
|
||||
r, err = s.sc.V1Refunds.Retrieve(ctx, refundID, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe query refund: %w", err)
|
||||
}
|
||||
} else {
|
||||
tradeNo := strings.TrimSpace(req.TradeNo)
|
||||
if tradeNo == "" {
|
||||
return nil, fmt.Errorf("stripe query refund: missing payment intent id")
|
||||
}
|
||||
params := &stripe.RefundListParams{PaymentIntent: stripe.String(tradeNo)}
|
||||
params.Limit = stripe.Int64(1)
|
||||
list := s.sc.V1Refunds.List(ctx, params)
|
||||
if list.Err() != nil {
|
||||
return nil, fmt.Errorf("stripe query refund: %w", list.Err())
|
||||
}
|
||||
refunds := list.Data()
|
||||
if len(refunds) == 0 {
|
||||
return nil, fmt.Errorf("stripe query refund: no refund found")
|
||||
}
|
||||
r = refunds[0]
|
||||
}
|
||||
|
||||
return &payment.RefundResponse{RefundID: r.ID, Status: stripeRefundProviderStatus(r.Status)}, nil
|
||||
}
|
||||
|
||||
func stripeRefundProviderStatus(status stripe.RefundStatus) string {
|
||||
switch status {
|
||||
case stripe.RefundStatusSucceeded:
|
||||
return payment.ProviderStatusSuccess
|
||||
case stripe.RefundStatusFailed, stripe.RefundStatusCanceled:
|
||||
return payment.ProviderStatusFailed
|
||||
default:
|
||||
return payment.ProviderStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
func stripeIntentCurrency(raw stripe.Currency, fallback string) string {
|
||||
currency, err := payment.NormalizePaymentCurrency(string(raw))
|
||||
if err != nil || currency == payment.DefaultPaymentCurrency && strings.TrimSpace(string(raw)) == "" {
|
||||
normalizedFallback, fallbackErr := payment.NormalizePaymentCurrency(fallback)
|
||||
if fallbackErr == nil {
|
||||
return normalizedFallback
|
||||
}
|
||||
return payment.DefaultPaymentCurrency
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
// resolveStripeMethodTypes converts instance supported_types (comma-separated)
|
||||
// into Stripe API payment_method_types. Falls back to ["card"] if empty.
|
||||
func resolveStripeMethodTypes(instanceSubMethods string) []string {
|
||||
if instanceSubMethods == "" {
|
||||
return []string{"card"}
|
||||
}
|
||||
var methods []string
|
||||
for _, t := range strings.Split(instanceSubMethods, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if mapped, ok := stripePaymentMethodTypes[t]; ok {
|
||||
methods = append(methods, mapped...)
|
||||
}
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
return []string{"card"}
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
// hasStripeMethod checks if the given Stripe method list contains the target method.
|
||||
func hasStripeMethod(methods []string, target string) bool {
|
||||
for _, m := range methods {
|
||||
if m == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CancelPayment cancels a pending PaymentIntent.
|
||||
func (s *Stripe) CancelPayment(ctx context.Context, tradeNo string) error {
|
||||
s.ensureInit()
|
||||
|
||||
_, err := s.sc.V1PaymentIntents.Cancel(ctx, tradeNo, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe cancel payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure interface compliance.
|
||||
var (
|
||||
_ payment.Provider = (*Stripe)(nil)
|
||||
_ payment.CancelableProvider = (*Stripe)(nil)
|
||||
_ payment.MerchantIdentityProvider = (*Stripe)(nil)
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build unit
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/stretchr/testify/require"
|
||||
stripe "github.com/stripe/stripe-go/v85"
|
||||
)
|
||||
|
||||
type stripeRefundBackend struct {
|
||||
params []*stripe.RefundCreateParams
|
||||
}
|
||||
|
||||
func (b *stripeRefundBackend) Call(_ string, _ string, _ string, params stripe.ParamsContainer, v stripe.LastResponseSetter) error {
|
||||
b.params = append(b.params, params.(*stripe.RefundCreateParams))
|
||||
refund := v.(*stripe.Refund)
|
||||
refund.ID = "re_123"
|
||||
refund.Status = stripe.RefundStatusSucceeded
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) CallStreaming(string, string, string, stripe.ParamsContainer, stripe.StreamingLastResponseSetter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) CallRaw(string, string, string, []byte, *stripe.Params, stripe.LastResponseSetter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) CallMultipart(string, string, string, string, *bytes.Buffer, *stripe.Params, stripe.LastResponseSetter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) SetMaxNetworkRetries(int64) {}
|
||||
|
||||
func TestStripeRefundUsesStableAmountSpecificIdempotencyKey(t *testing.T) {
|
||||
backend := &stripeRefundBackend{}
|
||||
client := stripe.NewClient("sk_test", stripe.WithBackends(&stripe.Backends{API: backend}))
|
||||
provider := &Stripe{
|
||||
config: map[string]string{"currency": "CNY"},
|
||||
initialized: true,
|
||||
sc: client,
|
||||
}
|
||||
|
||||
refund := func(amount string) {
|
||||
_, err := provider.Refund(context.Background(), payment.RefundRequest{
|
||||
TradeNo: "pi_123",
|
||||
OrderID: "sub2_order_456",
|
||||
Amount: amount,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
refund("12.34")
|
||||
refund("12.34")
|
||||
refund("12.35")
|
||||
|
||||
require.Len(t, backend.params, 3)
|
||||
require.Equal(t, int64(1234), *backend.params[0].Amount)
|
||||
require.Equal(t, "re-sub2_order_456-1234", *backend.params[0].IdempotencyKey)
|
||||
require.Equal(t, backend.params[0].IdempotencyKey, backend.params[1].IdempotencyKey)
|
||||
require.Equal(t, int64(1235), *backend.params[2].Amount)
|
||||
require.Equal(t, "re-sub2_order_456-1235", *backend.params[2].IdempotencyKey)
|
||||
require.NotEqual(t, *backend.params[0].IdempotencyKey, *backend.params[2].IdempotencyKey)
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||||
)
|
||||
|
||||
// WeChat Pay constants.
|
||||
const (
|
||||
wxpayCurrency = "CNY"
|
||||
wxpayH5Type = "Wap"
|
||||
wxpayResultPath = "/payment/result"
|
||||
)
|
||||
|
||||
const (
|
||||
wxpayMetadataAppID = "appid"
|
||||
wxpayMetadataMerchantID = "mchid"
|
||||
wxpayMetadataCurrency = "currency"
|
||||
wxpayMetadataTradeState = "trade_state"
|
||||
)
|
||||
|
||||
// WeChat Pay create-payment modes.
|
||||
const (
|
||||
wxpayModeNative = "native"
|
||||
wxpayModeH5 = "h5"
|
||||
wxpayModeJSAPI = "jsapi"
|
||||
)
|
||||
|
||||
// WeChat Pay trade states.
|
||||
const (
|
||||
wxpayTradeStateSuccess = "SUCCESS"
|
||||
wxpayTradeStateRefund = "REFUND"
|
||||
wxpayTradeStateClosed = "CLOSED"
|
||||
wxpayTradeStatePayError = "PAYERROR"
|
||||
)
|
||||
|
||||
// WeChat Pay notification event types.
|
||||
const (
|
||||
wxpayEventTransactionSuccess = "TRANSACTION.SUCCESS"
|
||||
)
|
||||
|
||||
var (
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
return svc.Prepay(ctx, req)
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
return svc.Prepay(ctx, req)
|
||||
}
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
return svc.PrepayWithRequestPayment(ctx, req)
|
||||
}
|
||||
)
|
||||
|
||||
type Wxpay struct {
|
||||
instanceID string
|
||||
config map[string]string
|
||||
mu sync.Mutex
|
||||
coreClient *core.Client
|
||||
notifyHandler *notify.Handler
|
||||
}
|
||||
|
||||
const wxpayAPIv3KeyLength = 32
|
||||
|
||||
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
|
||||
// All fields are required. Platform-certificate mode is intentionally unsupported —
|
||||
// WeChat has been migrating all merchants to the pubkey verifier since 2024-10,
|
||||
// and newly-provisioned merchants cannot download platform certificates at all.
|
||||
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "certSerial", "publicKey", "publicKeyId"}
|
||||
for _, k := range required {
|
||||
if config[k] == "" {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
|
||||
WithMetadata(map[string]string{"key": k})
|
||||
}
|
||||
}
|
||||
if len(config["apiV3Key"]) != wxpayAPIv3KeyLength {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY_LENGTH", "invalid_key_length").
|
||||
WithMetadata(map[string]string{
|
||||
"key": "apiV3Key",
|
||||
"expected": strconv.Itoa(wxpayAPIv3KeyLength),
|
||||
"actual": strconv.Itoa(len(config["apiV3Key"])),
|
||||
})
|
||||
}
|
||||
// Parse PEMs eagerly so malformed keys surface at save time, not at order creation.
|
||||
if _, err := utils.LoadPrivateKey(formatPEM(config["privateKey"], "PRIVATE KEY")); err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "privateKey"})
|
||||
}
|
||||
if _, err := utils.LoadPublicKey(formatPEM(config["publicKey"], "PUBLIC KEY")); err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "publicKey"})
|
||||
}
|
||||
return &Wxpay{instanceID: instanceID, config: config}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) Name() string { return "Wxpay" }
|
||||
func (w *Wxpay) ProviderKey() string { return payment.TypeWxpay }
|
||||
func (w *Wxpay) SupportedTypes() []payment.PaymentType {
|
||||
return []payment.PaymentType{payment.TypeWxpay}
|
||||
}
|
||||
|
||||
// ResolveWxpayJSAPIAppID returns the AppID that JSAPI prepay will use for a
|
||||
// given provider config. A dedicated MP AppID takes precedence over the base
|
||||
// merchant AppID.
|
||||
func ResolveWxpayJSAPIAppID(config map[string]string) string {
|
||||
if appID := strings.TrimSpace(config["mpAppId"]); appID != "" {
|
||||
return appID
|
||||
}
|
||||
return strings.TrimSpace(config["appId"])
|
||||
}
|
||||
|
||||
func formatPEM(key, keyType string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if strings.HasPrefix(key, "-----BEGIN") {
|
||||
return key
|
||||
}
|
||||
return fmt.Sprintf("-----BEGIN %s-----\n%s\n-----END %s-----", keyType, key, keyType)
|
||||
}
|
||||
|
||||
func (w *Wxpay) ensureClient() (*core.Client, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.coreClient != nil {
|
||||
return w.coreClient, nil
|
||||
}
|
||||
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "privateKey"})
|
||||
}
|
||||
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "publicKey"})
|
||||
}
|
||||
verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
|
||||
client, err := core.NewClient(context.Background(),
|
||||
option.WithMerchantCredential(w.config["mchId"], w.config["certSerial"], privateKey),
|
||||
option.WithVerifier(verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay init client: %w", err)
|
||||
}
|
||||
handler, err := notify.NewRSANotifyHandler(w.config["apiV3Key"], verifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay init notify handler: %w", err)
|
||||
}
|
||||
w.notifyHandler = handler
|
||||
w.coreClient = client
|
||||
return w.coreClient, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
client, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Request-first, config-fallback (consistent with EasyPay/Alipay)
|
||||
notifyURL := req.NotifyURL
|
||||
if notifyURL == "" {
|
||||
notifyURL = w.config["notifyUrl"]
|
||||
}
|
||||
if notifyURL == "" {
|
||||
return nil, fmt.Errorf("wxpay notifyUrl is required")
|
||||
}
|
||||
totalFen, err := payment.YuanToFen(req.Amount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay create payment: %w", err)
|
||||
}
|
||||
|
||||
mode, err := resolveWxpayCreateMode(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch mode {
|
||||
case wxpayModeJSAPI:
|
||||
return w.prepayJSAPI(ctx, client, req, notifyURL, totalFen)
|
||||
case wxpayModeH5:
|
||||
return w.prepayH5(ctx, client, req, notifyURL, totalFen)
|
||||
case wxpayModeNative:
|
||||
return w.prepayNative(ctx, client, req, notifyURL, totalFen)
|
||||
default:
|
||||
return nil, fmt.Errorf("wxpay create payment: unsupported mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Wxpay) prepayJSAPI(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||
svc := jsapi.JsapiApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
appID := ResolveWxpayJSAPIAppID(w.config)
|
||||
prepayReq := jsapi.PrepayRequest{
|
||||
Appid: core.String(appID),
|
||||
Mchid: core.String(w.config["mchId"]),
|
||||
Description: core.String(req.Subject),
|
||||
OutTradeNo: core.String(req.OrderID),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
Amount: &jsapi.Amount{Total: core.Int64(totalFen), Currency: &cur},
|
||||
Payer: &jsapi.Payer{Openid: core.String(strings.TrimSpace(req.OpenID))},
|
||||
}
|
||||
if clientIP := strings.TrimSpace(req.ClientIP); clientIP != "" {
|
||||
prepayReq.SceneInfo = &jsapi.SceneInfo{PayerClientIp: core.String(clientIP)}
|
||||
}
|
||||
resp, _, err := wxpayJSAPIPrepayWithRequestPayment(ctx, svc, prepayReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay jsapi prepay: %w", err)
|
||||
}
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
ResultType: payment.CreatePaymentResultJSAPIReady,
|
||||
JSAPI: &payment.WechatJSAPIPayload{
|
||||
AppID: wxSV(resp.Appid),
|
||||
TimeStamp: wxSV(resp.TimeStamp),
|
||||
NonceStr: wxSV(resp.NonceStr),
|
||||
Package: wxSV(resp.Package),
|
||||
SignType: wxSV(resp.SignType),
|
||||
PaySign: wxSV(resp.PaySign),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) prepayNative(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||
svc := native.NativeApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
resp, _, err := wxpayNativePrepay(ctx, svc, native.PrepayRequest{
|
||||
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
|
||||
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
Amount: &native.Amount{Total: core.Int64(totalFen), Currency: &cur},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay native prepay: %w", err)
|
||||
}
|
||||
codeURL := ""
|
||||
if resp.CodeUrl != nil {
|
||||
codeURL = *resp.CodeUrl
|
||||
}
|
||||
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, QRCode: codeURL}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||
svc := h5.H5ApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
resp, _, err := wxpayH5Prepay(ctx, svc, h5.PrepayRequest{
|
||||
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
|
||||
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
Amount: &h5.Amount{Total: core.Int64(totalFen), Currency: &cur},
|
||||
SceneInfo: &h5.SceneInfo{PayerClientIp: core.String(req.ClientIP), H5Info: buildWxpayH5Info(w.config)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay h5 prepay: %w", err)
|
||||
}
|
||||
h5URL := ""
|
||||
if resp.H5Url != nil {
|
||||
h5URL = *resp.H5Url
|
||||
}
|
||||
h5URL, err = appendWxpayRedirectURL(h5URL, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
|
||||
}
|
||||
|
||||
func buildWxpayH5Info(config map[string]string) *h5.H5Info {
|
||||
tp := wxpayH5Type
|
||||
info := &h5.H5Info{Type: &tp}
|
||||
if appName := strings.TrimSpace(config["h5AppName"]); appName != "" {
|
||||
info.AppName = core.String(appName)
|
||||
}
|
||||
if appURL := strings.TrimSpace(config["h5AppUrl"]); appURL != "" {
|
||||
info.AppUrl = core.String(appURL)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) {
|
||||
if strings.TrimSpace(req.OpenID) != "" {
|
||||
return wxpayModeJSAPI, nil
|
||||
}
|
||||
if req.IsMobile {
|
||||
if strings.TrimSpace(req.ClientIP) == "" {
|
||||
return "", fmt.Errorf("wxpay H5 payment requires client IP")
|
||||
}
|
||||
return wxpayModeH5, nil
|
||||
}
|
||||
return wxpayModeNative, nil
|
||||
}
|
||||
|
||||
func appendWxpayRedirectURL(h5URL string, req payment.CreatePaymentRequest) (string, error) {
|
||||
h5URL = strings.TrimSpace(h5URL)
|
||||
returnURL := strings.TrimSpace(req.ReturnURL)
|
||||
if h5URL == "" || returnURL == "" {
|
||||
return h5URL, nil
|
||||
}
|
||||
|
||||
redirectURL, err := buildWxpayResultURL(returnURL, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sep := "&"
|
||||
if !strings.Contains(h5URL, "?") {
|
||||
sep = "?"
|
||||
}
|
||||
return h5URL + sep + "redirect_url=" + url.QueryEscape(redirectURL), nil
|
||||
}
|
||||
|
||||
func buildWxpayResultURL(returnURL string, req payment.CreatePaymentRequest) (string, error) {
|
||||
u, err := url.Parse(returnURL)
|
||||
if err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", fmt.Errorf("return URL must be an absolute http(s) URL")
|
||||
}
|
||||
|
||||
values := u.Query()
|
||||
values.Set("out_trade_no", strings.TrimSpace(req.OrderID))
|
||||
if paymentType := strings.TrimSpace(req.PaymentType); paymentType != "" {
|
||||
values.Set("payment_type", paymentType)
|
||||
}
|
||||
if strings.TrimSpace(u.Path) == "" {
|
||||
u.Path = wxpayResultPath
|
||||
}
|
||||
u.RawPath = ""
|
||||
u.RawQuery = values.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func wxSV(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func mapWxState(s string) string {
|
||||
switch s {
|
||||
case wxpayTradeStateSuccess:
|
||||
return payment.ProviderStatusPaid
|
||||
case wxpayTradeStateRefund:
|
||||
return payment.ProviderStatusRefunded
|
||||
case wxpayTradeStateClosed, wxpayTradeStatePayError:
|
||||
return payment.ProviderStatusFailed
|
||||
default:
|
||||
return payment.ProviderStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
func buildWxpayTransactionMetadata(tx *payments.Transaction) map[string]string {
|
||||
if tx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata := map[string]string{}
|
||||
if appID := wxSV(tx.Appid); appID != "" {
|
||||
metadata[wxpayMetadataAppID] = appID
|
||||
}
|
||||
if merchantID := wxSV(tx.Mchid); merchantID != "" {
|
||||
metadata[wxpayMetadataMerchantID] = merchantID
|
||||
}
|
||||
if tradeState := wxSV(tx.TradeState); tradeState != "" {
|
||||
metadata[wxpayMetadataTradeState] = tradeState
|
||||
}
|
||||
if tx.Amount != nil {
|
||||
if currency := wxSV(tx.Amount.Currency); currency != "" {
|
||||
metadata[wxpayMetadataCurrency] = currency
|
||||
}
|
||||
}
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (w *Wxpay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
c, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svc := native.NativeApiService{Client: c}
|
||||
tx, _, err := svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
|
||||
OutTradeNo: core.String(tradeNo), Mchid: core.String(w.config["mchId"]),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay query order: %w", err)
|
||||
}
|
||||
var amt float64
|
||||
if tx.Amount != nil && tx.Amount.Total != nil {
|
||||
amt = payment.FenToYuan(*tx.Amount.Total)
|
||||
}
|
||||
id := tradeNo
|
||||
if tx.TransactionId != nil {
|
||||
id = *tx.TransactionId
|
||||
}
|
||||
pa := ""
|
||||
if tx.SuccessTime != nil {
|
||||
pa = *tx.SuccessTime
|
||||
}
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: id,
|
||||
Status: mapWxState(wxSV(tx.TradeState)),
|
||||
Amount: amt,
|
||||
PaidAt: pa,
|
||||
Metadata: buildWxpayTransactionMetadata(tx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) VerifyNotification(ctx context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
|
||||
if _, err := w.ensureClient(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := http.NewRequestWithContext(ctx, http.MethodPost, "/", io.NopCloser(bytes.NewBufferString(rawBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay construct request: %w", err)
|
||||
}
|
||||
for k, v := range headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
var tx payments.Transaction
|
||||
nr, err := w.notifyHandler.ParseNotifyRequest(ctx, r, &tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay verify notification: %w", err)
|
||||
}
|
||||
if nr.EventType != wxpayEventTransactionSuccess {
|
||||
return nil, nil
|
||||
}
|
||||
var amt float64
|
||||
if tx.Amount != nil && tx.Amount.Total != nil {
|
||||
amt = payment.FenToYuan(*tx.Amount.Total)
|
||||
}
|
||||
st := payment.ProviderStatusFailed
|
||||
if wxSV(tx.TradeState) == wxpayTradeStateSuccess {
|
||||
st = payment.ProviderStatusSuccess
|
||||
}
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: wxSV(tx.TransactionId), OrderID: wxSV(tx.OutTradeNo),
|
||||
Amount: amt, Status: st, RawData: rawBody, Metadata: buildWxpayTransactionMetadata(&tx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
c, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rf, err := payment.YuanToFen(req.Amount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay refund amount: %w", err)
|
||||
}
|
||||
tf, err := w.queryOrderTotalFen(ctx, c, req.OrderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs := refunddomestic.RefundsApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
outRefundNo := wxpayRefundID(req.OrderID, req.Amount)
|
||||
res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{
|
||||
OutTradeNo: core.String(req.OrderID),
|
||||
OutRefundNo: core.String(outRefundNo),
|
||||
Reason: core.String(req.Reason),
|
||||
Amount: &refunddomestic.AmountReq{Refund: core.Int64(rf), Total: core.Int64(tf), Currency: &cur},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay refund: %w", err)
|
||||
}
|
||||
st := payment.ProviderStatusPending
|
||||
if res.Status != nil && *res.Status == refunddomestic.STATUS_SUCCESS {
|
||||
st = payment.ProviderStatusSuccess
|
||||
}
|
||||
return &payment.RefundResponse{RefundID: outRefundNo, Status: st}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
|
||||
c, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outRefundNo := strings.TrimSpace(req.RefundID)
|
||||
if outRefundNo == "" {
|
||||
outRefundNo = wxpayRefundID(req.OrderID, req.Amount)
|
||||
}
|
||||
if outRefundNo == "" {
|
||||
return nil, fmt.Errorf("wxpay query refund: missing refund id")
|
||||
}
|
||||
rs := refunddomestic.RefundsApiService{Client: c}
|
||||
res, _, err := rs.QueryByOutRefundNo(ctx, refunddomestic.QueryByOutRefundNoRequest{
|
||||
OutRefundNo: core.String(outRefundNo),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay query refund: %w", err)
|
||||
}
|
||||
status := payment.ProviderStatusPending
|
||||
if res != nil && res.Status != nil {
|
||||
switch *res.Status {
|
||||
case refunddomestic.STATUS_SUCCESS:
|
||||
status = payment.ProviderStatusSuccess
|
||||
case refunddomestic.STATUS_CLOSED, refunddomestic.STATUS_ABNORMAL:
|
||||
status = payment.ProviderStatusFailed
|
||||
default:
|
||||
status = payment.ProviderStatusPending
|
||||
}
|
||||
}
|
||||
return &payment.RefundResponse{RefundID: outRefundNo, Status: status}, nil
|
||||
}
|
||||
|
||||
func wxpayRefundID(orderID, amount string) string {
|
||||
orderID = strings.TrimSpace(orderID)
|
||||
if orderID == "" {
|
||||
return ""
|
||||
}
|
||||
amount = strings.NewReplacer(".", "", "-", "").Replace(strings.TrimSpace(amount))
|
||||
if amount == "" {
|
||||
return orderID + "-refund"
|
||||
}
|
||||
return orderID + "-refund-" + amount
|
||||
}
|
||||
|
||||
func (w *Wxpay) queryOrderTotalFen(ctx context.Context, c *core.Client, orderID string) (int64, error) {
|
||||
svc := native.NativeApiService{Client: c}
|
||||
tx, _, err := svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
|
||||
OutTradeNo: core.String(orderID), Mchid: core.String(w.config["mchId"]),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("wxpay refund query order: %w", err)
|
||||
}
|
||||
var tf int64
|
||||
if tx.Amount != nil && tx.Amount.Total != nil {
|
||||
tf = *tx.Amount.Total
|
||||
}
|
||||
return tf, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) CancelPayment(ctx context.Context, tradeNo string) error {
|
||||
c, err := w.ensureClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc := native.NativeApiService{Client: c}
|
||||
_, err = svc.CloseOrder(ctx, native.CloseOrderRequest{
|
||||
OutTradeNo: core.String(tradeNo), Mchid: core.String(w.config["mchId"]),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wxpay cancel payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ payment.Provider = (*Wxpay)(nil)
|
||||
_ payment.CancelableProvider = (*Wxpay)(nil)
|
||||
)
|
||||
@@ -0,0 +1,709 @@
|
||||
//go:build unit
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
|
||||
)
|
||||
|
||||
// generateTestKeyPair returns a fresh RSA 2048 key pair as PEM strings.
|
||||
// The wechatpay-go SDK expects PKCS8 private keys and PKIX public keys.
|
||||
func generateTestKeyPair(t *testing.T) (privPEM, pubPEM string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate rsa key: %v", err)
|
||||
}
|
||||
privDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkcs8: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkix: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
}
|
||||
|
||||
func TestMapWxState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "SUCCESS maps to paid",
|
||||
input: wxpayTradeStateSuccess,
|
||||
want: payment.ProviderStatusPaid,
|
||||
},
|
||||
{
|
||||
name: "REFUND maps to refunded",
|
||||
input: wxpayTradeStateRefund,
|
||||
want: payment.ProviderStatusRefunded,
|
||||
},
|
||||
{
|
||||
name: "CLOSED maps to failed",
|
||||
input: wxpayTradeStateClosed,
|
||||
want: payment.ProviderStatusFailed,
|
||||
},
|
||||
{
|
||||
name: "PAYERROR maps to failed",
|
||||
input: wxpayTradeStatePayError,
|
||||
want: payment.ProviderStatusFailed,
|
||||
},
|
||||
{
|
||||
name: "unknown state maps to pending",
|
||||
input: "NOTPAY",
|
||||
want: payment.ProviderStatusPending,
|
||||
},
|
||||
{
|
||||
name: "empty string maps to pending",
|
||||
input: "",
|
||||
want: payment.ProviderStatusPending,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := mapWxState(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("mapWxState(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWxSV(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input *string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil pointer returns empty string",
|
||||
input: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "non-nil pointer returns value",
|
||||
input: strPtr("hello"),
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "pointer to empty string returns empty string",
|
||||
input: strPtr(""),
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := wxSV(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("wxSV() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWxpayTransactionMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tx := &payments.Transaction{
|
||||
Appid: strPtr("wx-app-id"),
|
||||
Mchid: strPtr("mch-id"),
|
||||
TradeState: strPtr(wxpayTradeStateSuccess),
|
||||
Amount: &payments.TransactionAmount{
|
||||
Currency: strPtr(wxpayCurrency),
|
||||
},
|
||||
}
|
||||
|
||||
metadata := buildWxpayTransactionMetadata(tx)
|
||||
if metadata[wxpayMetadataAppID] != "wx-app-id" {
|
||||
t.Fatalf("appid = %q", metadata[wxpayMetadataAppID])
|
||||
}
|
||||
if metadata[wxpayMetadataMerchantID] != "mch-id" {
|
||||
t.Fatalf("mchid = %q", metadata[wxpayMetadataMerchantID])
|
||||
}
|
||||
if metadata[wxpayMetadataCurrency] != wxpayCurrency {
|
||||
t.Fatalf("currency = %q", metadata[wxpayMetadataCurrency])
|
||||
}
|
||||
if metadata[wxpayMetadataTradeState] != wxpayTradeStateSuccess {
|
||||
t.Fatalf("trade_state = %q", metadata[wxpayMetadataTradeState])
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestFormatPEM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
keyType string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "raw key gets wrapped with headers",
|
||||
key: "MIIBIjANBgkqhki...",
|
||||
keyType: "PUBLIC KEY",
|
||||
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
{
|
||||
name: "already formatted key is returned as-is",
|
||||
key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
|
||||
keyType: "PRIVATE KEY",
|
||||
want: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
{
|
||||
name: "key with leading/trailing whitespace is trimmed before check",
|
||||
key: " \n MIIBIjANBgkqhki... \n ",
|
||||
keyType: "PUBLIC KEY",
|
||||
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
{
|
||||
name: "already formatted key with whitespace is trimmed and returned",
|
||||
key: " -----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY----- ",
|
||||
keyType: "RSA PRIVATE KEY",
|
||||
want: "-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := formatPEM(tt.key, tt.keyType)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatPEM(%q, %q) =\n%s\nwant:\n%s", tt.key, tt.keyType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWxpay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
privPEM, pubPEM := generateTestKeyPair(t)
|
||||
validConfig := map[string]string{
|
||||
"appId": "wx1234567890",
|
||||
"mchId": "1234567890",
|
||||
"privateKey": privPEM,
|
||||
"apiV3Key": "12345678901234567890123456789012", // exactly 32 bytes
|
||||
"publicKey": pubPEM,
|
||||
"publicKeyId": "PUB_KEY_ID_TEST",
|
||||
"certSerial": "SERIAL001",
|
||||
}
|
||||
|
||||
// helper to clone and override config fields
|
||||
withOverride := func(overrides map[string]string) map[string]string {
|
||||
cfg := make(map[string]string, len(validConfig))
|
||||
for k, v := range validConfig {
|
||||
cfg[k] = v
|
||||
}
|
||||
for k, v := range overrides {
|
||||
cfg[k] = v
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]string
|
||||
wantErr bool
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "valid config succeeds",
|
||||
config: validConfig,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing appId",
|
||||
config: withOverride(map[string]string{"appId": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "appId",
|
||||
},
|
||||
{
|
||||
name: "missing mchId",
|
||||
config: withOverride(map[string]string{"mchId": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "mchId",
|
||||
},
|
||||
{
|
||||
name: "missing privateKey",
|
||||
config: withOverride(map[string]string{"privateKey": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "privateKey",
|
||||
},
|
||||
{
|
||||
name: "missing apiV3Key",
|
||||
config: withOverride(map[string]string{"apiV3Key": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "apiV3Key",
|
||||
},
|
||||
{
|
||||
name: "missing certSerial",
|
||||
config: withOverride(map[string]string{"certSerial": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "certSerial",
|
||||
},
|
||||
{
|
||||
name: "missing publicKey",
|
||||
config: withOverride(map[string]string{"publicKey": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "publicKey",
|
||||
},
|
||||
{
|
||||
name: "missing publicKeyId",
|
||||
config: withOverride(map[string]string{"publicKeyId": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "publicKeyId",
|
||||
},
|
||||
{
|
||||
name: "malformed privateKey PEM",
|
||||
config: withOverride(map[string]string{"privateKey": "not-a-valid-pem"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
||||
},
|
||||
{
|
||||
name: "malformed publicKey PEM",
|
||||
config: withOverride(map[string]string{"publicKey": "not-a-valid-pem"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
||||
},
|
||||
{
|
||||
name: "apiV3Key too short",
|
||||
config: withOverride(map[string]string{"apiV3Key": "short"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
||||
},
|
||||
{
|
||||
name: "apiV3Key too long",
|
||||
config: withOverride(map[string]string{"apiV3Key": "123456789012345678901234567890123"}), // 33 bytes
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := NewWxpay("test-instance", tt.config)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil Wxpay instance")
|
||||
}
|
||||
if got.instanceID != "test-instance" {
|
||||
t.Errorf("instanceID = %q, want %q", got.instanceID, "test-instance")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWxpayResultURLPreservesResumeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resultURL, err := buildWxpayResultURL("https://app.example.com/payment/result?order_id=42&resume_token=resume-42&status=success", payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_42",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildWxpayResultURL returned error: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(resultURL)
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse returned error: %v", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if parsed.Path != wxpayResultPath {
|
||||
t.Fatalf("path = %q, want %q", parsed.Path, wxpayResultPath)
|
||||
}
|
||||
if query.Get("resume_token") != "resume-42" {
|
||||
t.Fatalf("resume_token = %q, want %q", query.Get("resume_token"), "resume-42")
|
||||
}
|
||||
if query.Get("order_id") != "42" {
|
||||
t.Fatalf("order_id = %q, want %q", query.Get("order_id"), "42")
|
||||
}
|
||||
if query.Get("out_trade_no") != "sub2_42" {
|
||||
t.Fatalf("out_trade_no = %q, want %q", query.Get("out_trade_no"), "sub2_42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWxpayJSAPIAppID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers dedicated mp app id",
|
||||
config: map[string]string{
|
||||
"mpAppId": "wx-mp-app",
|
||||
"appId": "wx-merchant-app",
|
||||
},
|
||||
want: "wx-mp-app",
|
||||
},
|
||||
{
|
||||
name: "falls back to merchant app id",
|
||||
config: map[string]string{
|
||||
"appId": "wx-merchant-app",
|
||||
},
|
||||
want: "wx-merchant-app",
|
||||
},
|
||||
{
|
||||
name: "missing app ids returns empty",
|
||||
config: map[string]string{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := ResolveWxpayJSAPIAppID(tt.config); got != tt.want {
|
||||
t.Fatalf("ResolveWxpayJSAPIAppID() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWxpayCreateMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req payment.CreatePaymentRequest
|
||||
wantMode string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "desktop uses native",
|
||||
req: payment.CreatePaymentRequest{},
|
||||
wantMode: wxpayModeNative,
|
||||
},
|
||||
{
|
||||
name: "mobile uses h5 when client ip is present",
|
||||
req: payment.CreatePaymentRequest{
|
||||
IsMobile: true,
|
||||
ClientIP: "203.0.113.10",
|
||||
},
|
||||
wantMode: wxpayModeH5,
|
||||
},
|
||||
{
|
||||
name: "mobile without client ip returns clear error",
|
||||
req: payment.CreatePaymentRequest{
|
||||
IsMobile: true,
|
||||
},
|
||||
wantErr: "requires client IP",
|
||||
},
|
||||
{
|
||||
name: "openid uses jsapi mode",
|
||||
req: payment.CreatePaymentRequest{
|
||||
OpenID: "openid-123",
|
||||
},
|
||||
wantMode: wxpayModeJSAPI,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := resolveWxpayCreateMode(tt.req)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.wantMode {
|
||||
t.Fatalf("resolveWxpayCreateMode() = %q, want %q", got, tt.wantMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentWithOpenIDReturnsJSAPIResult(t *testing.T) {
|
||||
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
|
||||
origNativePrepay := wxpayNativePrepay
|
||||
origH5Prepay := wxpayH5Prepay
|
||||
t.Cleanup(func() {
|
||||
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
|
||||
wxpayNativePrepay = origNativePrepay
|
||||
wxpayH5Prepay = origH5Prepay
|
||||
})
|
||||
|
||||
jsapiCalls := 0
|
||||
nativeCalls := 0
|
||||
h5Calls := 0
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
jsapiCalls++
|
||||
if got := wxSV(req.Payer.Openid); got != "openid-123" {
|
||||
t.Fatalf("openid = %q, want %q", got, "openid-123")
|
||||
}
|
||||
if req.SceneInfo == nil || wxSV(req.SceneInfo.PayerClientIp) != "203.0.113.10" {
|
||||
t.Fatalf("scene_info payer_client_ip = %q, want %q", wxSV(req.SceneInfo.PayerClientIp), "203.0.113.10")
|
||||
}
|
||||
return &jsapi.PrepayWithRequestPaymentResponse{
|
||||
Appid: core.String("wx123"),
|
||||
TimeStamp: core.String("1712345678"),
|
||||
NonceStr: core.String("nonce-123"),
|
||||
Package: core.String("prepay_id=wx_prepay_123"),
|
||||
SignType: core.String("RSA"),
|
||||
PaySign: core.String("signed-payload"),
|
||||
}, nil, nil
|
||||
}
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
nativeCalls++
|
||||
return &native.PrepayResponse{}, nil, nil
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
h5Calls++
|
||||
return &h5.PrepayResponse{}, nil, nil
|
||||
}
|
||||
|
||||
provider := &Wxpay{
|
||||
config: map[string]string{
|
||||
"appId": "wx123",
|
||||
"mchId": "mch123",
|
||||
},
|
||||
coreClient: &core.Client{},
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_88",
|
||||
Amount: "66.88",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
NotifyURL: "https://merchant.example/payment/notify",
|
||||
OpenID: "openid-123",
|
||||
ClientIP: "203.0.113.10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if jsapiCalls != 1 {
|
||||
t.Fatalf("jsapi prepay calls = %d, want 1", jsapiCalls)
|
||||
}
|
||||
if nativeCalls != 0 {
|
||||
t.Fatalf("native prepay calls = %d, want 0", nativeCalls)
|
||||
}
|
||||
if h5Calls != 0 {
|
||||
t.Fatalf("h5 prepay calls = %d, want 0", h5Calls)
|
||||
}
|
||||
if resp.ResultType != payment.CreatePaymentResultJSAPIReady {
|
||||
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultJSAPIReady)
|
||||
}
|
||||
if resp.JSAPI == nil {
|
||||
t.Fatal("expected jsapi payload, got nil")
|
||||
}
|
||||
if resp.JSAPI.AppID != "wx123" {
|
||||
t.Fatalf("jsapi appId = %q, want %q", resp.JSAPI.AppID, "wx123")
|
||||
}
|
||||
if resp.JSAPI.TimeStamp != "1712345678" {
|
||||
t.Fatalf("jsapi timeStamp = %q, want %q", resp.JSAPI.TimeStamp, "1712345678")
|
||||
}
|
||||
if resp.JSAPI.NonceStr != "nonce-123" {
|
||||
t.Fatalf("jsapi nonceStr = %q, want %q", resp.JSAPI.NonceStr, "nonce-123")
|
||||
}
|
||||
if resp.JSAPI.Package != "prepay_id=wx_prepay_123" {
|
||||
t.Fatalf("jsapi package = %q, want %q", resp.JSAPI.Package, "prepay_id=wx_prepay_123")
|
||||
}
|
||||
if resp.JSAPI.SignType != "RSA" {
|
||||
t.Fatalf("jsapi signType = %q, want %q", resp.JSAPI.SignType, "RSA")
|
||||
}
|
||||
if resp.JSAPI.PaySign != "signed-payload" {
|
||||
t.Fatalf("jsapi paySign = %q, want %q", resp.JSAPI.PaySign, "signed-payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMobileH5IncludesConfiguredSceneInfo(t *testing.T) {
|
||||
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
|
||||
origNativePrepay := wxpayNativePrepay
|
||||
origH5Prepay := wxpayH5Prepay
|
||||
t.Cleanup(func() {
|
||||
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
|
||||
wxpayNativePrepay = origNativePrepay
|
||||
wxpayH5Prepay = origH5Prepay
|
||||
})
|
||||
|
||||
jsapiCalls := 0
|
||||
nativeCalls := 0
|
||||
h5Calls := 0
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
jsapiCalls++
|
||||
return &jsapi.PrepayWithRequestPaymentResponse{}, nil, nil
|
||||
}
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
nativeCalls++
|
||||
return &native.PrepayResponse{}, nil, nil
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
h5Calls++
|
||||
if req.SceneInfo == nil {
|
||||
t.Fatal("expected scene_info, got nil")
|
||||
}
|
||||
if got := wxSV(req.SceneInfo.PayerClientIp); got != "203.0.113.10" {
|
||||
t.Fatalf("scene_info payer_client_ip = %q, want %q", got, "203.0.113.10")
|
||||
}
|
||||
if req.SceneInfo.H5Info == nil {
|
||||
t.Fatal("expected scene_info.h5_info, got nil")
|
||||
}
|
||||
if got := wxSV(req.SceneInfo.H5Info.Type); got != wxpayH5Type {
|
||||
t.Fatalf("scene_info.h5_info.type = %q, want %q", got, wxpayH5Type)
|
||||
}
|
||||
if got := wxSV(req.SceneInfo.H5Info.AppName); got != "Sub2API" {
|
||||
t.Fatalf("scene_info.h5_info.app_name = %q, want %q", got, "Sub2API")
|
||||
}
|
||||
if got := wxSV(req.SceneInfo.H5Info.AppUrl); got != "https://app.example.com" {
|
||||
t.Fatalf("scene_info.h5_info.app_url = %q, want %q", got, "https://app.example.com")
|
||||
}
|
||||
return &h5.PrepayResponse{
|
||||
H5Url: core.String("https://wx.tenpay.example/h5pay?prepay_id=1"),
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
provider := &Wxpay{
|
||||
config: map[string]string{
|
||||
"appId": "wx123",
|
||||
"mchId": "mch123",
|
||||
"h5AppName": "Sub2API",
|
||||
"h5AppUrl": "https://app.example.com",
|
||||
},
|
||||
coreClient: &core.Client{},
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_99",
|
||||
Amount: "66.88",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
Subject: "Balance Recharge",
|
||||
NotifyURL: "https://merchant.example/payment/notify",
|
||||
ReturnURL: "https://merchant.example/payment/result?resume_token=resume-99",
|
||||
ClientIP: "203.0.113.10",
|
||||
IsMobile: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if jsapiCalls != 0 {
|
||||
t.Fatalf("jsapi prepay calls = %d, want 0", jsapiCalls)
|
||||
}
|
||||
if nativeCalls != 0 {
|
||||
t.Fatalf("native prepay calls = %d, want 0", nativeCalls)
|
||||
}
|
||||
if h5Calls != 1 {
|
||||
t.Fatalf("h5 prepay calls = %d, want 1", h5Calls)
|
||||
}
|
||||
if !strings.Contains(resp.PayURL, "redirect_url=") {
|
||||
t.Fatalf("pay_url = %q, want redirect_url query appended", resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentMobileH5ReturnsNoAuthErrorWithoutNativeFallback(t *testing.T) {
|
||||
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
|
||||
origNativePrepay := wxpayNativePrepay
|
||||
origH5Prepay := wxpayH5Prepay
|
||||
t.Cleanup(func() {
|
||||
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
|
||||
wxpayNativePrepay = origNativePrepay
|
||||
wxpayH5Prepay = origH5Prepay
|
||||
})
|
||||
|
||||
jsapiCalls := 0
|
||||
nativeCalls := 0
|
||||
h5Calls := 0
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
jsapiCalls++
|
||||
return &jsapi.PrepayWithRequestPaymentResponse{}, nil, nil
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
h5Calls++
|
||||
return nil, nil, errors.New("NO_AUTH")
|
||||
}
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
nativeCalls++
|
||||
return &native.PrepayResponse{
|
||||
CodeUrl: core.String("weixin://wxpay/bizpayurl?pr=fallback-native"),
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
provider := &Wxpay{
|
||||
config: map[string]string{
|
||||
"appId": "wx123",
|
||||
"mchId": "mch123",
|
||||
},
|
||||
coreClient: &core.Client{},
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_100",
|
||||
Amount: "66.88",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
Subject: "Balance Recharge",
|
||||
NotifyURL: "https://merchant.example/payment/notify",
|
||||
ClientIP: "203.0.113.10",
|
||||
IsMobile: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-auth error, got nil")
|
||||
}
|
||||
if jsapiCalls != 0 {
|
||||
t.Fatalf("jsapi prepay calls = %d, want 0", jsapiCalls)
|
||||
}
|
||||
if h5Calls != 1 {
|
||||
t.Fatalf("h5 prepay calls = %d, want 1", h5Calls)
|
||||
}
|
||||
if nativeCalls != 0 {
|
||||
t.Fatalf("native prepay calls = %d, want 0", nativeCalls)
|
||||
}
|
||||
if resp != nil {
|
||||
t.Fatalf("expected nil response, got %+v", resp)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "NO_AUTH") {
|
||||
t.Fatalf("error = %v, want NO_AUTH", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// Registry is a thread-safe registry mapping PaymentType to Provider.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
providers map[PaymentType]Provider
|
||||
}
|
||||
|
||||
// ErrProviderNotFound is returned when a requested payment provider is not registered.
|
||||
var ErrProviderNotFound = infraerrors.NotFound("PROVIDER_NOT_FOUND", "payment provider not registered")
|
||||
|
||||
// NewRegistry creates a new empty provider registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
providers: make(map[PaymentType]Provider),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a provider for each of its supported payment types.
|
||||
// If a type was previously registered, it is overwritten.
|
||||
func (r *Registry) Register(p Provider) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, t := range p.SupportedTypes() {
|
||||
r.providers[t] = p
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider returns the provider registered for the given payment type.
|
||||
func (r *Registry) GetProvider(t PaymentType) (Provider, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
p, ok := r.providers[t]
|
||||
if !ok {
|
||||
return nil, ErrProviderNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetProviderByKey returns the first provider whose ProviderKey matches the given key.
|
||||
func (r *Registry) GetProviderByKey(key string) (Provider, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, p := range r.providers {
|
||||
if p.ProviderKey() == key {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrProviderNotFound
|
||||
}
|
||||
|
||||
// GetProviderKey returns the provider key for the given payment type, or empty string if not found.
|
||||
func (r *Registry) GetProviderKey(t PaymentType) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
p, ok := r.providers[t]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return p.ProviderKey()
|
||||
}
|
||||
|
||||
// SupportedTypes returns all currently registered payment types.
|
||||
func (r *Registry) SupportedTypes() []PaymentType {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
types := make([]PaymentType, 0, len(r.providers))
|
||||
for t := range r.providers {
|
||||
types = append(types, t)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// Clear removes all registered providers.
|
||||
func (r *Registry) Clear() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.providers = make(map[PaymentType]Provider)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockProvider implements the Provider interface for testing.
|
||||
type mockProvider struct {
|
||||
name string
|
||||
key string
|
||||
supportedTypes []PaymentType
|
||||
}
|
||||
|
||||
func (m *mockProvider) Name() string { return m.name }
|
||||
func (m *mockProvider) ProviderKey() string { return m.key }
|
||||
func (m *mockProvider) SupportedTypes() []PaymentType { return m.supportedTypes }
|
||||
func (m *mockProvider) CreatePayment(_ context.Context, _ CreatePaymentRequest) (*CreatePaymentResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockProvider) QueryOrder(_ context.Context, _ string) (*QueryOrderResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockProvider) VerifyNotification(_ context.Context, _ string, _ map[string]string) (*PaymentNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockProvider) Refund(_ context.Context, _ RefundRequest) (*RefundResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRegistryRegisterAndGetProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
p := &mockProvider{
|
||||
name: "TestPay",
|
||||
key: "testpay",
|
||||
supportedTypes: []PaymentType{TypeAlipay, TypeWxpay},
|
||||
}
|
||||
r.Register(p)
|
||||
|
||||
got, err := r.GetProvider(TypeAlipay)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProvider(alipay) error: %v", err)
|
||||
}
|
||||
if got.ProviderKey() != "testpay" {
|
||||
t.Fatalf("GetProvider(alipay) key = %q, want %q", got.ProviderKey(), "testpay")
|
||||
}
|
||||
|
||||
got2, err := r.GetProvider(TypeWxpay)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProvider(wxpay) error: %v", err)
|
||||
}
|
||||
if got2.ProviderKey() != "testpay" {
|
||||
t.Fatalf("GetProvider(wxpay) key = %q, want %q", got2.ProviderKey(), "testpay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGetProviderNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
_, err := r.GetProvider("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("GetProvider for unregistered type should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGetProviderByKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
p := &mockProvider{
|
||||
name: "EasyPay",
|
||||
key: "easypay",
|
||||
supportedTypes: []PaymentType{TypeAlipay},
|
||||
}
|
||||
r.Register(p)
|
||||
|
||||
got, err := r.GetProviderByKey("easypay")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderByKey error: %v", err)
|
||||
}
|
||||
if got.Name() != "EasyPay" {
|
||||
t.Fatalf("GetProviderByKey name = %q, want %q", got.Name(), "EasyPay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGetProviderByKeyNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
_, err := r.GetProviderByKey("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("GetProviderByKey for unknown key should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGetProviderKeyUnknownType(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
key := r.GetProviderKey("unknown_type")
|
||||
if key != "" {
|
||||
t.Fatalf("GetProviderKey for unknown type should return empty, got %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGetProviderKeyKnownType(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
p := &mockProvider{
|
||||
name: "Stripe",
|
||||
key: "stripe",
|
||||
supportedTypes: []PaymentType{TypeStripe},
|
||||
}
|
||||
r.Register(p)
|
||||
|
||||
key := r.GetProviderKey(TypeStripe)
|
||||
if key != "stripe" {
|
||||
t.Fatalf("GetProviderKey(stripe) = %q, want %q", key, "stripe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrySupportedTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
p1 := &mockProvider{
|
||||
name: "EasyPay",
|
||||
key: "easypay",
|
||||
supportedTypes: []PaymentType{TypeAlipay, TypeWxpay},
|
||||
}
|
||||
p2 := &mockProvider{
|
||||
name: "Stripe",
|
||||
key: "stripe",
|
||||
supportedTypes: []PaymentType{TypeStripe},
|
||||
}
|
||||
r.Register(p1)
|
||||
r.Register(p2)
|
||||
|
||||
types := r.SupportedTypes()
|
||||
if len(types) != 3 {
|
||||
t.Fatalf("SupportedTypes() len = %d, want 3", len(types))
|
||||
}
|
||||
|
||||
typeSet := make(map[PaymentType]bool)
|
||||
for _, tp := range types {
|
||||
typeSet[tp] = true
|
||||
}
|
||||
for _, expected := range []PaymentType{TypeAlipay, TypeWxpay, TypeStripe} {
|
||||
if !typeSet[expected] {
|
||||
t.Fatalf("SupportedTypes() missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrySupportedTypesEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
types := r.SupportedTypes()
|
||||
if len(types) != 0 {
|
||||
t.Fatalf("SupportedTypes() on empty registry should be empty, got %d", len(types))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryOverwriteExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
p1 := &mockProvider{
|
||||
name: "OldPay",
|
||||
key: "old",
|
||||
supportedTypes: []PaymentType{TypeAlipay},
|
||||
}
|
||||
p2 := &mockProvider{
|
||||
name: "NewPay",
|
||||
key: "new",
|
||||
supportedTypes: []PaymentType{TypeAlipay},
|
||||
}
|
||||
r.Register(p1)
|
||||
r.Register(p2)
|
||||
|
||||
got, err := r.GetProvider(TypeAlipay)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProvider error: %v", err)
|
||||
}
|
||||
if got.Name() != "NewPay" {
|
||||
t.Fatalf("expected overwritten provider, got %q", got.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryConcurrentAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry()
|
||||
|
||||
const goroutines = 50
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines * 2)
|
||||
|
||||
// Concurrent writers
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
p := &mockProvider{
|
||||
name: fmt.Sprintf("Provider-%d", idx),
|
||||
key: fmt.Sprintf("key-%d", idx),
|
||||
supportedTypes: []PaymentType{PaymentType(fmt.Sprintf("type-%d", idx))},
|
||||
}
|
||||
r.Register(p)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent readers
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = r.SupportedTypes()
|
||||
_, _ = r.GetProvider("some-type")
|
||||
_ = r.GetProviderKey("some-type")
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
types := r.SupportedTypes()
|
||||
if len(types) != goroutines {
|
||||
t.Fatalf("after concurrent registration, expected %d types, got %d", goroutines, len(types))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Package payment provides the core payment provider abstraction,
|
||||
// registry, load balancing, and shared utilities for the payment subsystem.
|
||||
package payment
|
||||
|
||||
import "context"
|
||||
|
||||
// PaymentType represents a supported payment method.
|
||||
type PaymentType = string
|
||||
|
||||
// Supported payment type constants.
|
||||
const (
|
||||
TypeAlipay PaymentType = "alipay"
|
||||
TypeWxpay PaymentType = "wxpay"
|
||||
TypeAlipayDirect PaymentType = "alipay_direct"
|
||||
TypeWxpayDirect PaymentType = "wxpay_direct"
|
||||
TypeStripe PaymentType = "stripe"
|
||||
TypeCard PaymentType = "card"
|
||||
TypeLink PaymentType = "link"
|
||||
TypeEasyPay PaymentType = "easypay"
|
||||
TypeAirwallex PaymentType = "airwallex"
|
||||
)
|
||||
|
||||
// Order status constants shared across payment and service layers.
|
||||
const (
|
||||
OrderStatusPending = "PENDING"
|
||||
OrderStatusPaid = "PAID"
|
||||
OrderStatusRecharging = "RECHARGING"
|
||||
OrderStatusCompleted = "COMPLETED"
|
||||
OrderStatusExpired = "EXPIRED"
|
||||
OrderStatusCancelled = "CANCELLED"
|
||||
OrderStatusFailed = "FAILED"
|
||||
OrderStatusRefundRequested = "REFUND_REQUESTED"
|
||||
OrderStatusRefunding = "REFUNDING"
|
||||
OrderStatusRefundPending = "REFUND_PENDING"
|
||||
OrderStatusPartiallyRefunded = "PARTIALLY_REFUNDED"
|
||||
OrderStatusRefunded = "REFUNDED"
|
||||
OrderStatusRefundFailed = "REFUND_FAILED"
|
||||
)
|
||||
|
||||
// Order types distinguish balance recharges from subscription purchases.
|
||||
const (
|
||||
OrderTypeBalance = "balance"
|
||||
OrderTypeSubscription = "subscription"
|
||||
)
|
||||
|
||||
// Entity statuses shared across users, groups, etc.
|
||||
const (
|
||||
EntityStatusActive = "active"
|
||||
)
|
||||
|
||||
// Deduction types for refund flow.
|
||||
const (
|
||||
DeductionTypeBalance = "balance"
|
||||
DeductionTypeSubscription = "subscription"
|
||||
DeductionTypeNone = "none"
|
||||
)
|
||||
|
||||
// Payment notification status values.
|
||||
const (
|
||||
NotificationStatusSuccess = "success"
|
||||
NotificationStatusPaid = "paid"
|
||||
)
|
||||
|
||||
// Provider-level status constants returned by provider implementations
|
||||
// to the service layer (lowercase, distinct from OrderStatus uppercase constants).
|
||||
const (
|
||||
ProviderStatusPending = "pending"
|
||||
ProviderStatusPaid = "paid"
|
||||
ProviderStatusSuccess = "success"
|
||||
ProviderStatusFailed = "failed"
|
||||
ProviderStatusRefunded = "refunded"
|
||||
)
|
||||
|
||||
// DefaultLoadBalanceStrategy is the default load-balancing strategy
|
||||
// used when no strategy is configured.
|
||||
const DefaultLoadBalanceStrategy = "round-robin"
|
||||
|
||||
// ConfigKeyPublishableKey is the config map key for Stripe's publishable key.
|
||||
const ConfigKeyPublishableKey = "publishableKey"
|
||||
|
||||
// GetBasePaymentType extracts the base payment method from a composite key.
|
||||
// For example, "alipay_direct" -> "alipay".
|
||||
func GetBasePaymentType(t string) string {
|
||||
switch {
|
||||
case t == TypeEasyPay:
|
||||
return TypeEasyPay
|
||||
case t == TypeAirwallex:
|
||||
return TypeAirwallex
|
||||
case t == TypeStripe || t == TypeCard || t == TypeLink:
|
||||
return TypeStripe
|
||||
case len(t) >= len(TypeAlipay) && t[:len(TypeAlipay)] == TypeAlipay:
|
||||
return TypeAlipay
|
||||
case len(t) >= len(TypeWxpay) && t[:len(TypeWxpay)] == TypeWxpay:
|
||||
return TypeWxpay
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePaymentRequest holds the parameters for creating a new payment.
|
||||
type CreatePaymentRequest struct {
|
||||
OrderID string // Internal order ID
|
||||
Amount string // 支付金额,按服务商实例配置的币种解释
|
||||
PaymentType string // e.g. "alipay", "wxpay", "stripe"
|
||||
Subject string // Product description
|
||||
NotifyURL string // Webhook callback URL
|
||||
ReturnURL string // Browser redirect URL after payment
|
||||
OpenID string // WeChat JSAPI payer OpenID when available
|
||||
ClientIP string // Payer's IP address
|
||||
IsMobile bool // Whether the request comes from a mobile device
|
||||
// AlipayMobilePrecreate routes a mobile Alipay request through
|
||||
// alipay.trade.precreate instead of alipay.trade.wap.pay.
|
||||
AlipayMobilePrecreate bool
|
||||
InstanceSubMethods string // Comma-separated sub-methods from instance supported_types (for Stripe)
|
||||
}
|
||||
|
||||
// CreatePaymentResultType describes the shape of the create-payment result.
|
||||
type CreatePaymentResultType = string
|
||||
|
||||
const (
|
||||
CreatePaymentResultOrderCreated CreatePaymentResultType = "order_created"
|
||||
CreatePaymentResultOAuthRequired CreatePaymentResultType = "oauth_required"
|
||||
CreatePaymentResultJSAPIReady CreatePaymentResultType = "jsapi_ready"
|
||||
)
|
||||
|
||||
// WechatOAuthInfo describes the next step when WeChat OAuth is required before payment.
|
||||
type WechatOAuthInfo struct {
|
||||
AuthorizeURL string `json:"authorize_url,omitempty"`
|
||||
AppID string `json:"appid,omitempty"`
|
||||
OpenID string `json:"openid,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
}
|
||||
|
||||
// WechatJSAPIPayload contains the fields the frontend needs to invoke WeChat JSAPI payment.
|
||||
type WechatJSAPIPayload struct {
|
||||
AppID string `json:"appId,omitempty"`
|
||||
TimeStamp string `json:"timeStamp,omitempty"`
|
||||
NonceStr string `json:"nonceStr,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
SignType string `json:"signType,omitempty"`
|
||||
PaySign string `json:"paySign,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePaymentResponse is returned after successfully initiating a payment.
|
||||
type CreatePaymentResponse struct {
|
||||
TradeNo string // Third-party transaction ID
|
||||
PayURL string // H5 payment URL (alipay/wxpay)
|
||||
QRCode string // QR code content for scanning
|
||||
ClientSecret string // Stripe PaymentIntent 客户端密钥
|
||||
IntentID string // 前端 SDK 需要的服务商支付意图 ID
|
||||
Currency string // 服务商支付币种
|
||||
CountryCode string // 服务商收银台国家/地区代码
|
||||
PaymentEnv string // 服务商前端环境标识
|
||||
ResultType CreatePaymentResultType // Typed result contract for frontend flows
|
||||
OAuth *WechatOAuthInfo // WeChat OAuth bootstrap payload when required
|
||||
JSAPI *WechatJSAPIPayload // WeChat JSAPI invocation payload when ready
|
||||
}
|
||||
|
||||
// QueryOrderResponse describes the payment status from the upstream provider.
|
||||
type QueryOrderResponse struct {
|
||||
TradeNo string
|
||||
Status string // "pending", "paid", "failed", "refunded"
|
||||
Amount float64 // 按服务商返回币种解释的金额
|
||||
PaidAt string // RFC3339 timestamp or empty
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// PaymentNotification is the parsed result of a webhook/notify callback.
|
||||
type PaymentNotification struct {
|
||||
TradeNo string
|
||||
OrderID string
|
||||
Amount float64
|
||||
Status string // "success" or "failed"
|
||||
RawData string // Raw notification body for audit
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// RefundRequest contains the parameters for requesting a refund.
|
||||
type RefundRequest struct {
|
||||
TradeNo string
|
||||
OrderID string
|
||||
Amount string // Refund amount formatted to 2 decimal places
|
||||
Reason string
|
||||
}
|
||||
|
||||
// RefundQueryRequest contains identifiers needed to query a previously
|
||||
// requested refund.
|
||||
type RefundQueryRequest struct {
|
||||
TradeNo string
|
||||
OrderID string
|
||||
RefundID string
|
||||
Amount string
|
||||
}
|
||||
|
||||
// RefundResponse is returned after a refund request.
|
||||
type RefundResponse struct {
|
||||
RefundID string
|
||||
Status string // "success", "pending", "failed"
|
||||
}
|
||||
|
||||
// InstanceSelection holds the selected provider instance and its decrypted config.
|
||||
type InstanceSelection struct {
|
||||
InstanceID string
|
||||
ProviderKey string // Provider key of the selected instance (e.g. "alipay", "easypay")
|
||||
Config map[string]string
|
||||
SupportedTypes string // Comma-separated list of supported payment types from the instance
|
||||
PaymentMode string // Payment display mode: "qrcode", "redirect", "popup"
|
||||
}
|
||||
|
||||
// Provider defines the interface that all payment providers must implement.
|
||||
type Provider interface {
|
||||
// Name returns a human-readable name for this provider.
|
||||
Name() string
|
||||
// ProviderKey returns the unique key identifying this provider type (e.g. "easypay").
|
||||
ProviderKey() string
|
||||
// SupportedTypes returns the list of payment types this provider handles.
|
||||
SupportedTypes() []PaymentType
|
||||
// CreatePayment initiates a payment and returns the upstream response.
|
||||
CreatePayment(ctx context.Context, req CreatePaymentRequest) (*CreatePaymentResponse, error)
|
||||
// QueryOrder queries the payment status of the given trade number.
|
||||
QueryOrder(ctx context.Context, tradeNo string) (*QueryOrderResponse, error)
|
||||
// VerifyNotification parses and verifies a webhook callback.
|
||||
// Returns nil for unrecognized or irrelevant events (caller should return 200).
|
||||
VerifyNotification(ctx context.Context, rawBody string, headers map[string]string) (*PaymentNotification, error)
|
||||
// Refund requests a refund from the upstream provider.
|
||||
Refund(ctx context.Context, req RefundRequest) (*RefundResponse, error)
|
||||
}
|
||||
|
||||
// RefundQueryProvider extends Provider with refund status querying.
|
||||
type RefundQueryProvider interface {
|
||||
Provider
|
||||
QueryRefund(ctx context.Context, req RefundQueryRequest) (*RefundResponse, error)
|
||||
}
|
||||
|
||||
// CancelableProvider extends Provider with the ability to cancel pending payments.
|
||||
type CancelableProvider interface {
|
||||
Provider
|
||||
// CancelPayment cancels/expires a pending payment on the upstream platform.
|
||||
CancelPayment(ctx context.Context, tradeNo string) error
|
||||
}
|
||||
|
||||
// MerchantIdentityProvider exposes the current non-sensitive merchant identity
|
||||
// derived from provider configuration for snapshot consistency checks.
|
||||
type MerchantIdentityProvider interface {
|
||||
MerchantIdentityMetadata() map[string]string
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// EncryptionKey is a named type for the payment encryption key (AES-256, 32 bytes).
|
||||
// Using a named type avoids Wire ambiguity with other []byte parameters.
|
||||
type EncryptionKey []byte
|
||||
|
||||
// ProvideEncryptionKey derives the payment encryption key from the TOTP encryption key in config.
|
||||
// When the key is empty, nil is returned (payment features that need encryption will be disabled).
|
||||
// When the key is non-empty but invalid (bad hex or wrong length), an error is returned
|
||||
// to prevent startup with a misconfigured encryption key.
|
||||
func ProvideEncryptionKey(cfg *config.Config) (EncryptionKey, error) {
|
||||
if cfg == nil {
|
||||
slog.Warn("payment encryption key not configured — encrypted payment config and resume signing will be unavailable")
|
||||
return nil, nil
|
||||
}
|
||||
keyHex := strings.TrimSpace(cfg.Totp.EncryptionKey)
|
||||
if keyHex == "" {
|
||||
slog.Warn("payment encryption key not configured — encrypted payment config will be unavailable")
|
||||
return nil, nil
|
||||
}
|
||||
// Reject auto-generated TOTP keys for payment signing.
|
||||
// They change across restarts/instances and can silently break resume-token flows.
|
||||
if !cfg.Totp.EncryptionKeyConfigured {
|
||||
slog.Warn("payment encryption/signing key is not explicitly configured; set TOTP_ENCRYPTION_KEY to enable payment resume tokens")
|
||||
return nil, nil
|
||||
}
|
||||
key, err := hex.DecodeString(keyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid payment encryption key (hex decode): %w", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("payment encryption key must be 32 bytes, got %d", len(key))
|
||||
}
|
||||
return EncryptionKey(key), nil
|
||||
}
|
||||
|
||||
// ProvideRegistry creates an empty payment provider registry.
|
||||
// Providers are registered at runtime after application startup.
|
||||
func ProvideRegistry() *Registry {
|
||||
return NewRegistry()
|
||||
}
|
||||
|
||||
// ProvideDefaultLoadBalancer creates a DefaultLoadBalancer backed by the ent client.
|
||||
func ProvideDefaultLoadBalancer(client *dbent.Client, key EncryptionKey) *DefaultLoadBalancer {
|
||||
return NewDefaultLoadBalancer(client, []byte(key))
|
||||
}
|
||||
|
||||
// ProviderSet is the Wire provider set for the payment package.
|
||||
var ProviderSet = wire.NewSet(
|
||||
ProvideEncryptionKey,
|
||||
ProvideRegistry,
|
||||
ProvideDefaultLoadBalancer,
|
||||
wire.Bind(new(LoadBalancer), new(*DefaultLoadBalancer)),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
func TestProvideEncryptionKeySkipsAutoGeneratedTotpKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &config.Config{
|
||||
Totp: config.TotpConfig{
|
||||
EncryptionKey: strings.Repeat("a", 64),
|
||||
EncryptionKeyConfigured: false,
|
||||
},
|
||||
}
|
||||
|
||||
key, err := ProvideEncryptionKey(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvideEncryptionKey returned error: %v", err)
|
||||
}
|
||||
if len(key) != 0 {
|
||||
t.Fatalf("encryption key len = %d, want 0", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideEncryptionKeyUsesConfiguredTotpKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &config.Config{
|
||||
Totp: config.TotpConfig{
|
||||
EncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
EncryptionKeyConfigured: true,
|
||||
},
|
||||
}
|
||||
|
||||
key, err := ProvideEncryptionKey(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("ProvideEncryptionKey returned error: %v", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Fatalf("encryption key len = %d, want 32", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideEncryptionKeyRejectsConfiguredInvalidLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &config.Config{
|
||||
Totp: config.TotpConfig{
|
||||
EncryptionKey: "abcd",
|
||||
EncryptionKeyConfigured: true,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ProvideEncryptionKey(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key length")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user