2026-04-10 21:08:51 +08:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2026-04-17 17:00:29 +08:00
|
|
|
"log/slog"
|
2026-04-10 21:08:51 +08:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
|
|
|
|
"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
|
|
|
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
|
2026-04-10 21:08:51 +08:00
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
|
|
|
)
|
|
|
|
|
|
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
|
|
|
// validateProviderConfig runs the provider's constructor to surface config-level
|
|
|
|
|
// errors at save time (e.g. wxpay missing certSerial), instead of only failing
|
|
|
|
|
// when an order is created. Returns the structured ApplicationError from the
|
|
|
|
|
// constructor so the frontend i18n layer can localize it.
|
|
|
|
|
//
|
|
|
|
|
// Only validates enabled instances — a disabled instance may be a half-filled
|
|
|
|
|
// draft the admin will complete later.
|
|
|
|
|
func (s *PaymentConfigService) validateProviderConfig(providerKey string, config map[string]string) error {
|
|
|
|
|
_, err := provider.CreateProvider(providerKey, "_validate_", config)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
// --- Provider Instance CRUD ---
|
|
|
|
|
|
|
|
|
|
func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
|
|
|
|
|
return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ProviderInstanceResponse is the API response for a provider instance.
|
|
|
|
|
type ProviderInstanceResponse struct {
|
2026-04-14 16:26:46 +08:00
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
ProviderKey string `json:"provider_key"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Config map[string]string `json:"config"`
|
|
|
|
|
SupportedTypes []string `json:"supported_types"`
|
|
|
|
|
Limits string `json:"limits"`
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
RefundEnabled bool `json:"refund_enabled"`
|
|
|
|
|
AllowUserRefund bool `json:"allow_user_refund"`
|
|
|
|
|
SortOrder int `json:"sort_order"`
|
|
|
|
|
PaymentMode string `json:"payment_mode"`
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ListProviderInstancesWithConfig returns provider instances with decrypted config.
|
|
|
|
|
func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Context) ([]ProviderInstanceResponse, error) {
|
|
|
|
|
instances, err := s.entClient.PaymentProviderInstance.Query().
|
|
|
|
|
Order(paymentproviderinstance.BySortOrder()).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
result := make([]ProviderInstanceResponse, 0, len(instances))
|
|
|
|
|
for _, inst := range instances {
|
|
|
|
|
resp := ProviderInstanceResponse{
|
|
|
|
|
ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name,
|
|
|
|
|
SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits,
|
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
|
|
|
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, AllowUserRefund: inst.AllowUserRefund,
|
|
|
|
|
SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode,
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
feat(payment): redact provider secrets in admin config API
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
|
|
|
resp.Config, err = s.decryptAndMaskConfig(inst.ProviderKey, inst.Config)
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("decrypt config for instance %d: %w", inst.ID, err)
|
|
|
|
|
}
|
|
|
|
|
result = append(result, resp)
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
feat(payment): redact provider secrets in admin config API
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
|
|
|
// decryptAndMaskConfig returns the stored config with sensitive fields omitted.
|
|
|
|
|
// Admin UIs display masked placeholders for these; the raw values never leave
|
|
|
|
|
// the server. Callers that need the full config (e.g. payment runtime) must
|
|
|
|
|
// use decryptConfig directly.
|
|
|
|
|
func (s *PaymentConfigService) decryptAndMaskConfig(providerKey, encrypted string) (map[string]string, error) {
|
|
|
|
|
cfg, err := s.decryptConfig(encrypted)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if cfg == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
masked := make(map[string]string, len(cfg))
|
|
|
|
|
for k, v := range cfg {
|
|
|
|
|
if isSensitiveProviderConfigField(providerKey, k) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
masked[k] = v
|
|
|
|
|
}
|
|
|
|
|
return masked, nil
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pendingOrderStatuses are order statuses considered "in progress".
|
|
|
|
|
var pendingOrderStatuses = []string{
|
|
|
|
|
payment.OrderStatusPending,
|
|
|
|
|
payment.OrderStatusPaid,
|
|
|
|
|
payment.OrderStatusRecharging,
|
|
|
|
|
}
|
|
|
|
|
|
feat(payment): redact provider secrets in admin config API
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
|
|
|
// providerSensitiveConfigFields is the authoritative list of config keys that
|
|
|
|
|
// are treated as secrets per provider. Must stay in sync with the frontend
|
|
|
|
|
// definition at frontend/src/components/payment/providerConfig.ts
|
|
|
|
|
// (PROVIDER_CONFIG_FIELDS, fields with sensitive: true).
|
|
|
|
|
//
|
|
|
|
|
// Key matching is case-insensitive. Non-listed keys (e.g. appId, notifyUrl,
|
|
|
|
|
// stripe publishableKey) are returned in plaintext by the admin GET API.
|
|
|
|
|
var providerSensitiveConfigFields = map[string]map[string]struct{}{
|
|
|
|
|
payment.TypeEasyPay: {"pkey": {}},
|
|
|
|
|
payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}},
|
|
|
|
|
payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}},
|
|
|
|
|
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
|
2026-04-22 14:57:16 +08:00
|
|
|
// providerPendingOrderProtectedConfigFields lists config keys that cannot be
|
|
|
|
|
// changed while the instance has in-progress orders. This includes secrets plus
|
|
|
|
|
// all provider identity fields that are snapshotted into orders or used by
|
|
|
|
|
// webhook/refund verification.
|
|
|
|
|
var providerPendingOrderProtectedConfigFields = map[string]map[string]struct{}{
|
|
|
|
|
payment.TypeEasyPay: {"pkey": {}, "pid": {}},
|
|
|
|
|
payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}, "appid": {}},
|
|
|
|
|
payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}},
|
|
|
|
|
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
|
|
|
|
|
}
|
|
|
|
|
|
feat(payment): redact provider secrets in admin config API
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
|
|
|
func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
|
|
|
|
|
fields, ok := providerSensitiveConfigFields[providerKey]
|
|
|
|
|
if !ok {
|
|
|
|
|
return false
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
feat(payment): redact provider secrets in admin config API
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
|
|
|
_, found := fields[strings.ToLower(fieldName)]
|
|
|
|
|
return found
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 14:57:16 +08:00
|
|
|
func hasPendingOrderProtectedConfigChange(providerKey string, currentConfig, nextConfig map[string]string) bool {
|
|
|
|
|
fields, ok := providerPendingOrderProtectedConfigFields[providerKey]
|
|
|
|
|
if !ok {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for fieldName := range fields {
|
|
|
|
|
if providerConfigFieldValue(currentConfig, fieldName) != providerConfigFieldValue(nextConfig, fieldName) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func providerConfigFieldValue(config map[string]string, fieldName string) string {
|
|
|
|
|
for key, value := range config {
|
|
|
|
|
if strings.EqualFold(key, fieldName) {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) {
|
|
|
|
|
return s.entClient.PaymentOrder.Query().
|
|
|
|
|
Where(
|
|
|
|
|
paymentorder.ProviderInstanceIDEQ(strconv.FormatInt(providerInstanceID, 10)),
|
|
|
|
|
paymentorder.StatusIn(pendingOrderStatuses...),
|
|
|
|
|
).Count(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, planID int64) (int, error) {
|
|
|
|
|
return s.entClient.PaymentOrder.Query().
|
|
|
|
|
Where(
|
|
|
|
|
paymentorder.PlanIDEQ(planID),
|
|
|
|
|
paymentorder.StatusIn(pendingOrderStatuses...),
|
|
|
|
|
).Count(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var validProviderKeys = map[string]bool{
|
|
|
|
|
payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
|
|
|
|
typesStr := joinTypes(req.SupportedTypes)
|
|
|
|
|
if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-21 23:17:45 +08:00
|
|
|
if err := s.validateVisibleMethodEnablementConflicts(ctx, 0, req.ProviderKey, typesStr, req.Enabled); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
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 req.Enabled {
|
|
|
|
|
if err := s.validateProviderConfig(req.ProviderKey, req.Config); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
enc, err := s.encryptConfig(req.Config)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-14 16:26:46 +08:00
|
|
|
allowUserRefund := req.AllowUserRefund && req.RefundEnabled
|
2026-04-10 21:08:51 +08:00
|
|
|
return s.entClient.PaymentProviderInstance.Create().
|
|
|
|
|
SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
|
|
|
|
|
SetSupportedTypes(typesStr).SetEnabled(req.Enabled).SetPaymentMode(req.PaymentMode).
|
|
|
|
|
SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled).
|
2026-04-14 16:26:46 +08:00
|
|
|
SetAllowUserRefund(allowUserRefund).
|
2026-04-10 21:08:51 +08:00
|
|
|
Save(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateProviderRequest(providerKey, name, supportedTypes string) error {
|
|
|
|
|
if strings.TrimSpace(name) == "" {
|
|
|
|
|
return infraerrors.BadRequest("VALIDATION_ERROR", "provider name is required")
|
|
|
|
|
}
|
|
|
|
|
if !validProviderKeys[providerKey] {
|
|
|
|
|
return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("invalid provider key: %s", providerKey))
|
|
|
|
|
}
|
|
|
|
|
// supported_types can be empty (provider accepts no payment types until configured)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UpdateProviderInstance updates a provider instance by ID (patch semantics).
|
|
|
|
|
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update
|
|
|
|
|
// boilerplate and pending-order safety checks.
|
|
|
|
|
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
2026-04-21 23:17:45 +08:00
|
|
|
current, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
|
|
|
|
|
if err != nil {
|
2026-04-22 00:35:34 +08:00
|
|
|
return nil, fmt.Errorf("load provider instance: %w", err)
|
2026-04-21 23:17:45 +08:00
|
|
|
}
|
2026-04-22 14:57:16 +08:00
|
|
|
var pendingOrderCount *int
|
|
|
|
|
getPendingOrderCount := func() (int, error) {
|
|
|
|
|
if pendingOrderCount != nil {
|
|
|
|
|
return *pendingOrderCount, nil
|
|
|
|
|
}
|
|
|
|
|
count, err := s.countPendingOrders(ctx, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("check pending orders: %w", err)
|
|
|
|
|
}
|
|
|
|
|
pendingOrderCount = &count
|
|
|
|
|
return count, nil
|
|
|
|
|
}
|
2026-04-21 23:17:45 +08:00
|
|
|
nextEnabled := current.Enabled
|
|
|
|
|
if req.Enabled != nil {
|
|
|
|
|
nextEnabled = *req.Enabled
|
|
|
|
|
}
|
|
|
|
|
nextSupportedTypes := current.SupportedTypes
|
|
|
|
|
if req.SupportedTypes != nil {
|
|
|
|
|
nextSupportedTypes = joinTypes(req.SupportedTypes)
|
|
|
|
|
}
|
|
|
|
|
if err := s.validateVisibleMethodEnablementConflicts(ctx, id, current.ProviderKey, nextSupportedTypes, nextEnabled); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-22 14:57:16 +08:00
|
|
|
var mergedConfig map[string]string
|
2026-04-10 21:08:51 +08:00
|
|
|
if req.Config != nil {
|
2026-04-22 14:57:16 +08:00
|
|
|
currentConfig, err := s.decryptConfig(current.Config)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("decrypt existing config: %w", err)
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-22 14:57:16 +08:00
|
|
|
mergedConfig, err = s.mergeConfig(ctx, id, req.Config)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if hasPendingOrderProtectedConfigChange(current.ProviderKey, currentConfig, mergedConfig) {
|
|
|
|
|
count, err := getPendingOrderCount()
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
2026-04-22 14:57:16 +08:00
|
|
|
return nil, err
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
if count > 0 {
|
|
|
|
|
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
|
|
|
|
|
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if req.Enabled != nil && !*req.Enabled {
|
2026-04-22 14:57:16 +08:00
|
|
|
count, err := getPendingOrderCount()
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
2026-04-22 14:57:16 +08:00
|
|
|
return nil, err
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
if count > 0 {
|
|
|
|
|
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
|
|
|
|
|
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
|
|
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
// Validate merged config when the instance will end up enabled.
|
|
|
|
|
// This surfaces provider-level errors (e.g. wxpay missing certSerial) at save time,
|
|
|
|
|
// so admins see them in the dialog instead of only when an order is created.
|
2026-04-22 00:35:34 +08:00
|
|
|
finalEnabled := current.Enabled
|
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 req.Enabled != nil {
|
|
|
|
|
finalEnabled = *req.Enabled
|
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 finalEnabled {
|
|
|
|
|
configToValidate := mergedConfig
|
|
|
|
|
if configToValidate == nil {
|
2026-04-22 00:35:34 +08:00
|
|
|
configToValidate, err = s.decryptConfig(current.Config)
|
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 err != nil {
|
|
|
|
|
return nil, fmt.Errorf("decrypt existing config: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-22 00:35:34 +08:00
|
|
|
if err := s.validateProviderConfig(current.ProviderKey, configToValidate); 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, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
|
|
|
|
|
if req.Name != nil {
|
|
|
|
|
u.SetName(*req.Name)
|
|
|
|
|
}
|
|
|
|
|
if mergedConfig != nil {
|
|
|
|
|
enc, err := s.encryptConfig(mergedConfig)
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
u.SetConfig(enc)
|
|
|
|
|
}
|
|
|
|
|
if req.SupportedTypes != nil {
|
|
|
|
|
// Check pending orders before removing payment types
|
2026-04-22 14:57:16 +08:00
|
|
|
count, err := getPendingOrderCount()
|
2026-04-10 21:08:51 +08:00
|
|
|
if err != nil {
|
2026-04-22 14:57:16 +08:00
|
|
|
return nil, err
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
if count > 0 {
|
|
|
|
|
// Load current instance to compare types
|
2026-04-21 23:17:45 +08:00
|
|
|
oldTypes := strings.Split(current.SupportedTypes, ",")
|
2026-04-10 21:08:51 +08:00
|
|
|
newTypes := req.SupportedTypes
|
|
|
|
|
for _, ot := range oldTypes {
|
|
|
|
|
ot = strings.TrimSpace(ot)
|
|
|
|
|
if ot == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
found := false
|
|
|
|
|
for _, nt := range newTypes {
|
|
|
|
|
if strings.TrimSpace(nt) == ot {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
return nil, infraerrors.Conflict("PENDING_ORDERS", "cannot remove payment types while instance has pending orders").
|
|
|
|
|
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
u.SetSupportedTypes(joinTypes(req.SupportedTypes))
|
|
|
|
|
}
|
|
|
|
|
if req.Enabled != nil {
|
|
|
|
|
u.SetEnabled(*req.Enabled)
|
|
|
|
|
}
|
|
|
|
|
if req.SortOrder != nil {
|
|
|
|
|
u.SetSortOrder(*req.SortOrder)
|
|
|
|
|
}
|
|
|
|
|
if req.Limits != nil {
|
|
|
|
|
u.SetLimits(*req.Limits)
|
|
|
|
|
}
|
|
|
|
|
if req.RefundEnabled != nil {
|
|
|
|
|
u.SetRefundEnabled(*req.RefundEnabled)
|
2026-04-14 16:26:46 +08:00
|
|
|
// Cascade: turning off refund_enabled also disables allow_user_refund
|
|
|
|
|
if !*req.RefundEnabled {
|
|
|
|
|
u.SetAllowUserRefund(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if req.AllowUserRefund != nil {
|
2026-04-14 18:41:09 +08:00
|
|
|
// Only allow enabling when refund_enabled is (or will be) true
|
2026-04-14 16:26:46 +08:00
|
|
|
if *req.AllowUserRefund {
|
2026-04-14 18:41:09 +08:00
|
|
|
refundEnabled := false
|
|
|
|
|
if req.RefundEnabled != nil {
|
|
|
|
|
refundEnabled = *req.RefundEnabled
|
|
|
|
|
} else {
|
2026-04-21 23:17:45 +08:00
|
|
|
refundEnabled = current.RefundEnabled
|
2026-04-14 18:41:09 +08:00
|
|
|
}
|
|
|
|
|
if refundEnabled {
|
2026-04-14 16:26:46 +08:00
|
|
|
u.SetAllowUserRefund(true)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
u.SetAllowUserRefund(false)
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
if req.PaymentMode != nil {
|
|
|
|
|
u.SetPaymentMode(*req.PaymentMode)
|
|
|
|
|
}
|
|
|
|
|
return u.Save(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 07:43:08 +08:00
|
|
|
// GetUserRefundEligibleInstanceIDs returns provider instance IDs that allow user refund.
|
|
|
|
|
func (s *PaymentConfigService) GetUserRefundEligibleInstanceIDs(ctx context.Context) ([]string, error) {
|
|
|
|
|
instances, err := s.entClient.PaymentProviderInstance.Query().
|
|
|
|
|
Where(
|
|
|
|
|
paymentproviderinstance.RefundEnabledEQ(true),
|
2026-04-14 16:26:46 +08:00
|
|
|
paymentproviderinstance.AllowUserRefundEQ(true),
|
2026-04-14 07:43:08 +08:00
|
|
|
).Select(paymentproviderinstance.FieldID).All(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
ids := make([]string, 0, len(instances))
|
|
|
|
|
for _, inst := range instances {
|
|
|
|
|
ids = append(ids, strconv.FormatInt(int64(inst.ID), 10))
|
|
|
|
|
}
|
|
|
|
|
return ids, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:08:51 +08:00
|
|
|
func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newConfig map[string]string) (map[string]string, error) {
|
|
|
|
|
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("load existing provider: %w", err)
|
|
|
|
|
}
|
|
|
|
|
existing, err := s.decryptConfig(inst.Config)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("decrypt existing config for instance %d: %w", id, err)
|
|
|
|
|
}
|
|
|
|
|
if existing == nil {
|
feat(payment): redact provider secrets in admin config API
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
|
|
|
existing = map[string]string{}
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
for k, v := range newConfig {
|
feat(payment): redact provider secrets in admin config API
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
|
|
|
// Preserve existing secrets when the client submits an empty value
|
|
|
|
|
// (admin UI omits the value to indicate "leave unchanged").
|
|
|
|
|
if v == "" && isSensitiveProviderConfigField(inst.ProviderKey, k) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
existing[k] = v
|
|
|
|
|
}
|
|
|
|
|
return existing, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 17:00:29 +08:00
|
|
|
// decryptConfig parses a stored provider config.
|
|
|
|
|
// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext
|
|
|
|
|
// ("iv:authTag:ciphertext"). Values that cannot be parsed as either — including
|
|
|
|
|
// legacy ciphertext with no/invalid TOTP_ENCRYPTION_KEY — are treated as empty,
|
|
|
|
|
// letting the admin re-enter the config via the UI to complete the migration.
|
2026-04-17 23:05:58 +08:00
|
|
|
//
|
|
|
|
|
// TODO(deprecated-legacy-ciphertext): The AES fallback branch is a transitional
|
|
|
|
|
// shim for pre-plaintext records. Remove it (and the encryptionKey field) after
|
|
|
|
|
// a few releases once all live deployments have re-saved their provider configs.
|
2026-04-17 17:00:29 +08:00
|
|
|
func (s *PaymentConfigService) decryptConfig(stored string) (map[string]string, error) {
|
|
|
|
|
if stored == "" {
|
2026-04-10 21:08:51 +08:00
|
|
|
return nil, nil
|
|
|
|
|
}
|
2026-04-17 17:00:29 +08:00
|
|
|
var cfg map[string]string
|
|
|
|
|
if err := json.Unmarshal([]byte(stored), &cfg); err == nil {
|
|
|
|
|
return cfg, nil
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-17 23:05:58 +08:00
|
|
|
// Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
|
2026-04-17 17:00:29 +08:00
|
|
|
if len(s.encryptionKey) == payment.AES256KeySize {
|
2026-04-17 23:05:58 +08:00
|
|
|
//nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
|
2026-04-17 17:00:29 +08:00
|
|
|
if plaintext, err := payment.Decrypt(stored, s.encryptionKey); err == nil {
|
|
|
|
|
if err := json.Unmarshal([]byte(plaintext), &cfg); err == nil {
|
|
|
|
|
return cfg, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
2026-04-17 17:00:29 +08:00
|
|
|
slog.Warn("payment provider config unreadable, treating as empty for re-entry",
|
|
|
|
|
"stored_len", len(stored))
|
|
|
|
|
return nil, nil
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
|
|
|
|
|
count, err := s.countPendingOrders(ctx, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("check pending orders: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if count > 0 {
|
|
|
|
|
return infraerrors.Conflict("PENDING_ORDERS",
|
|
|
|
|
fmt.Sprintf("this instance has %d in-progress orders and cannot be deleted — wait for orders to complete or disable the instance first", count))
|
|
|
|
|
}
|
|
|
|
|
return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 17:00:29 +08:00
|
|
|
// encryptConfig serialises a provider config for storage.
|
|
|
|
|
// New records are written as plaintext JSON; the historical AES-GCM wrapping
|
|
|
|
|
// has been dropped but decryptConfig still accepts old ciphertext during migration.
|
2026-04-10 21:08:51 +08:00
|
|
|
func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
|
|
|
|
|
data, err := json.Marshal(cfg)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("marshal config: %w", err)
|
|
|
|
|
}
|
2026-04-17 17:00:29 +08:00
|
|
|
return string(data), nil
|
2026-04-10 21:08:51 +08:00
|
|
|
}
|