2026-04-10 21:08:51 +08:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
2026-04-20 23:34:57 +08:00
|
|
|
"net/url"
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
"strconv"
|
2026-04-10 21:08:51 +08:00
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
2026-04-10 21:08:51 +08:00
|
|
|
"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"
|
2026-04-20 23:34:57 +08:00
|
|
|
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
2026-04-10 21:08:51 +08:00
|
|
|
"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 (
|
2026-04-20 23:34:57 +08:00
|
|
|
wxpayCurrency = "CNY"
|
|
|
|
|
wxpayH5Type = "Wap"
|
|
|
|
|
wxpayResultPath = "/payment/result"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-21 12:57:35 +08:00
|
|
|
const (
|
|
|
|
|
wxpayMetadataAppID = "appid"
|
|
|
|
|
wxpayMetadataMerchantID = "mchid"
|
|
|
|
|
wxpayMetadataCurrency = "currency"
|
|
|
|
|
wxpayMetadataTradeState = "trade_state"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-20 23:34:57 +08:00
|
|
|
// WeChat Pay create-payment modes.
|
|
|
|
|
const (
|
|
|
|
|
wxpayModeNative = "native"
|
|
|
|
|
wxpayModeH5 = "h5"
|
|
|
|
|
wxpayModeJSAPI = "jsapi"
|
2026-04-10 21:08:51 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// WeChat Pay trade states.
|
|
|
|
|
const (
|
|
|
|
|
wxpayTradeStateSuccess = "SUCCESS"
|
|
|
|
|
wxpayTradeStateRefund = "REFUND"
|
|
|
|
|
wxpayTradeStateClosed = "CLOSED"
|
|
|
|
|
wxpayTradeStatePayError = "PAYERROR"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// WeChat Pay notification event types.
|
|
|
|
|
const (
|
|
|
|
|
wxpayEventTransactionSuccess = "TRANSACTION.SUCCESS"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// WeChat Pay error codes.
|
|
|
|
|
const (
|
|
|
|
|
wxpayErrNoAuth = "NO_AUTH"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-20 23:34:57 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
type Wxpay struct {
|
|
|
|
|
instanceID string
|
|
|
|
|
config map[string]string
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
coreClient *core.Client
|
|
|
|
|
notifyHandler *notify.Handler
|
|
|
|
|
}
|
|
|
|
|
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
const wxpayAPIv3KeyLength = 32
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
// 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"}
|
2026-04-10 21:08:51 +08:00
|
|
|
for _, k := range required {
|
|
|
|
|
if config[k] == "" {
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
return nil, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
|
|
|
|
|
WithMetadata(map[string]string{"key": k})
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
}
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
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"})
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-04-13 14:06:29 +08:00
|
|
|
return []payment.PaymentType{payment.TypeWxpay}
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:34:57 +08:00
|
|
|
// 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"])
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
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
|
|
|
|
|
}
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
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"))
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
|
|
|
|
WithMetadata(map[string]string{"key": "publicKey"})
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
|
|
|
|
|
client, err := core.NewClient(context.Background(),
|
feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00
|
|
|
option.WithMerchantCredential(w.config["mchId"], w.config["certSerial"], privateKey),
|
2026-04-10 21:08:51 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-20 23:34:57 +08:00
|
|
|
|
|
|
|
|
mode, err := resolveWxpayCreateMode(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
switch mode {
|
|
|
|
|
case wxpayModeJSAPI:
|
|
|
|
|
return w.prepayJSAPI(ctx, client, req, notifyURL, totalFen)
|
|
|
|
|
case wxpayModeH5:
|
|
|
|
|
resp, err := w.prepayH5(ctx, client, req, notifyURL, totalFen)
|
2026-04-10 21:08:51 +08:00
|
|
|
if err == nil {
|
|
|
|
|
return resp, nil
|
|
|
|
|
}
|
2026-04-22 14:57:16 +08:00
|
|
|
if wxpayShouldFallbackToNative(err) {
|
|
|
|
|
return w.prepayNativeFallback(ctx, client, req, notifyURL, totalFen)
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-20 23:34:57 +08:00
|
|
|
return nil, err
|
|
|
|
|
case wxpayModeNative:
|
|
|
|
|
return w.prepayNative(ctx, client, req, notifyURL, totalFen)
|
|
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("wxpay create payment: unsupported mode %q", mode)
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:34:57 +08:00
|
|
|
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)
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-20 23:34:57 +08:00
|
|
|
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
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-20 23:34:57 +08:00
|
|
|
resp, _, err := wxpayNativePrepay(ctx, svc, native.PrepayRequest{
|
2026-04-10 21:08:51 +08:00
|
|
|
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
|
2026-04-20 23:34:57 +08:00
|
|
|
resp, _, err := wxpayH5Prepay(ctx, svc, h5.PrepayRequest{
|
2026-04-10 21:08:51 +08:00
|
|
|
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},
|
2026-04-21 08:35:53 +08:00
|
|
|
SceneInfo: &h5.SceneInfo{PayerClientIp: core.String(req.ClientIP), H5Info: buildWxpayH5Info(w.config)},
|
2026-04-10 21:08:51 +08:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("wxpay h5 prepay: %w", err)
|
|
|
|
|
}
|
|
|
|
|
h5URL := ""
|
|
|
|
|
if resp.H5Url != nil {
|
|
|
|
|
h5URL = *resp.H5Url
|
|
|
|
|
}
|
2026-04-20 23:34:57 +08:00
|
|
|
h5URL, err = appendWxpayRedirectURL(h5URL, req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 14:57:16 +08:00
|
|
|
func (w *Wxpay) prepayNativeFallback(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
|
|
|
|
resp, err := w.prepayNative(ctx, c, req, notifyURL, totalFen)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("wxpay native fallback after NO_AUTH: %w", err)
|
|
|
|
|
}
|
|
|
|
|
nativeURL := strings.TrimSpace(resp.PayURL)
|
|
|
|
|
if nativeURL == "" {
|
|
|
|
|
nativeURL = strings.TrimSpace(resp.QRCode)
|
|
|
|
|
}
|
|
|
|
|
if nativeURL == "" {
|
|
|
|
|
return resp, nil
|
|
|
|
|
}
|
|
|
|
|
resp.PayURL = nativeURL
|
|
|
|
|
resp.QRCode = nativeURL
|
|
|
|
|
return resp, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 08:35:53 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 14:57:16 +08:00
|
|
|
func wxpayShouldFallbackToNative(err error) bool {
|
|
|
|
|
return err != nil && strings.Contains(err.Error(), wxpayErrNoAuth)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:34:57 +08:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 00:14:05 +08:00
|
|
|
values := u.Query()
|
2026-04-20 23:34:57 +08:00
|
|
|
values.Set("out_trade_no", strings.TrimSpace(req.OrderID))
|
|
|
|
|
if paymentType := strings.TrimSpace(req.PaymentType); paymentType != "" {
|
|
|
|
|
values.Set("payment_type", paymentType)
|
|
|
|
|
}
|
2026-04-21 00:14:05 +08:00
|
|
|
if strings.TrimSpace(u.Path) == "" {
|
|
|
|
|
u.Path = wxpayResultPath
|
|
|
|
|
}
|
2026-04-20 23:34:57 +08:00
|
|
|
u.RawPath = ""
|
|
|
|
|
u.RawQuery = values.Encode()
|
|
|
|
|
u.Fragment = ""
|
|
|
|
|
return u.String(), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:57:35 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-04-21 12:57:35 +08:00
|
|
|
return &payment.QueryOrderResponse{
|
|
|
|
|
TradeNo: id,
|
|
|
|
|
Status: mapWxState(wxSV(tx.TradeState)),
|
|
|
|
|
Amount: amt,
|
|
|
|
|
PaidAt: pa,
|
|
|
|
|
Metadata: buildWxpayTransactionMetadata(tx),
|
|
|
|
|
}, nil
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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),
|
2026-04-21 12:57:35 +08:00
|
|
|
Amount: amt, Status: st, RawData: rawBody, Metadata: buildWxpayTransactionMetadata(&tx),
|
2026-04-10 21:08:51 +08:00
|
|
|
}, 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
|
|
|
|
|
res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{
|
|
|
|
|
OutTradeNo: core.String(req.OrderID),
|
|
|
|
|
OutRefundNo: core.String(fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano())),
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
rid := wxSV(res.RefundId)
|
|
|
|
|
if rid == "" {
|
|
|
|
|
rid = fmt.Sprintf("%s-refund", req.OrderID)
|
|
|
|
|
}
|
|
|
|
|
st := payment.ProviderStatusPending
|
|
|
|
|
if res.Status != nil && *res.Status == refunddomestic.STATUS_SUCCESS {
|
|
|
|
|
st = payment.ProviderStatusSuccess
|
|
|
|
|
}
|
|
|
|
|
return &payment.RefundResponse{RefundID: rid, Status: st}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
)
|