| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544 |
- package service
- import (
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "regexp"
- "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"
- "github.com/Wei-Shaw/sub2api/internal/payment/provider"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- )
- // 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
- }
- // --- 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 {
- 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"`
- }
- // 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,
- Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, AllowUserRefund: inst.AllowUserRefund,
- SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode,
- }
- resp.Config, err = s.decryptAndMaskConfig(inst.ProviderKey, inst.Config)
- if err != nil {
- return nil, fmt.Errorf("decrypt config for instance %d: %w", inst.ID, err)
- }
- result = append(result, resp)
- }
- return result, nil
- }
- // 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
- }
- // pendingOrderStatuses are order statuses considered "in progress".
- var pendingOrderStatuses = []string{
- payment.OrderStatusPending,
- payment.OrderStatusPaid,
- payment.OrderStatusRecharging,
- }
- // 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": {}},
- payment.TypeAirwallex: {"apikey": {}, "webhooksecret": {}},
- }
- // 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": {}, "currency": {}},
- payment.TypeAirwallex: {"clientid": {}, "apikey": {}, "webhooksecret": {}, "apibase": {}, "accountid": {}, "currency": {}},
- }
- func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
- fields, ok := providerSensitiveConfigFields[providerKey]
- if !ok {
- return false
- }
- _, found := fields[strings.ToLower(fieldName)]
- return found
- }
- 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 ""
- }
- 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, payment.TypeAirwallex: 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
- }
- if req.ProviderKey == payment.TypeEasyPay {
- if err := validateEasyPayCustomMethods(req.Config, typesStr); err != nil {
- return nil, err
- }
- }
- if err := s.validateVisibleMethodEnablementConflicts(ctx, 0, req.ProviderKey, typesStr, req.Enabled); err != nil {
- return nil, err
- }
- if req.Enabled {
- if err := s.validateProviderConfig(req.ProviderKey, req.Config); err != nil {
- return nil, err
- }
- }
- enc, err := s.encryptConfig(req.Config)
- if err != nil {
- return nil, err
- }
- allowUserRefund := req.AllowUserRefund && req.RefundEnabled
- 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).
- SetAllowUserRefund(allowUserRefund).
- 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
- }
- var easyPayCustomMethodCodePattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
- type easyPayCustomMethodConfig struct {
- Type string `json:"type"`
- UpstreamType string `json:"upstreamType"`
- DisplayName string `json:"displayName"`
- }
- func validateEasyPayCustomMethods(config map[string]string, supportedTypes string) error {
- if config == nil {
- config = map[string]string{}
- }
- raw := strings.TrimSpace(config["customMethods"])
- methods := make([]easyPayCustomMethodConfig, 0)
- if raw != "" {
- if err := json.Unmarshal([]byte(raw), &methods); err != nil {
- return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods must be a JSON array")
- }
- }
- customTypes := make(map[string]struct{}, len(methods))
- for _, method := range methods {
- method.Type = strings.TrimSpace(method.Type)
- method.UpstreamType = strings.TrimSpace(method.UpstreamType)
- if method.Type == "" || method.UpstreamType == "" {
- return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods upstreamType is required")
- }
- if !easyPayCustomMethodCodePattern.MatchString(method.Type) {
- return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods type may only contain lowercase letters, digits, underscores, and hyphens")
- }
- if !easyPayCustomMethodCodePattern.MatchString(method.UpstreamType) {
- return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods upstreamType may only contain lowercase letters, digits, underscores, and hyphens")
- }
- if easyPayCustomMethodTypeConflictsWithBuiltin(method.Type) {
- return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods type cannot start with alipay or wxpay")
- }
- if _, exists := customTypes[method.Type]; exists {
- return infraerrors.BadRequest("VALIDATION_ERROR", "duplicate customMethods type")
- }
- customTypes[method.Type] = struct{}{}
- }
- for _, supportedType := range splitTypes(supportedTypes) {
- supportedType = strings.TrimSpace(supportedType)
- if supportedType == "" || supportedType == payment.TypeAlipay || supportedType == payment.TypeWxpay {
- continue
- }
- if !easyPayCustomMethodCodePattern.MatchString(supportedType) {
- return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s may only contain lowercase letters, digits, underscores, and hyphens", supportedType))
- }
- if _, exists := customTypes[supportedType]; !exists {
- return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s has no customMethods mapping", supportedType))
- }
- }
- return nil
- }
- func easyPayCustomMethodTypeConflictsWithBuiltin(methodType string) bool {
- return strings.HasPrefix(methodType, payment.TypeAlipay) || strings.HasPrefix(methodType, payment.TypeWxpay)
- }
- // 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) {
- current, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
- if err != nil {
- return nil, fmt.Errorf("load provider instance: %w", err)
- }
- 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
- }
- 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
- }
- var mergedConfig map[string]string
- if req.Config != nil {
- currentConfig, err := s.decryptConfig(current.Config)
- if err != nil {
- return nil, fmt.Errorf("decrypt existing config: %w", err)
- }
- mergedConfig, err = s.mergeConfig(ctx, id, req.Config)
- if err != nil {
- return nil, err
- }
- if hasPendingOrderProtectedConfigChange(current.ProviderKey, currentConfig, mergedConfig) {
- count, err := getPendingOrderCount()
- if err != nil {
- return nil, err
- }
- 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 {
- count, err := getPendingOrderCount()
- if err != nil {
- return nil, err
- }
- if count > 0 {
- return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
- WithMetadata(map[string]string{"count": strconv.Itoa(count)})
- }
- }
- configToValidate := mergedConfig
- if configToValidate == nil {
- configToValidate, err = s.decryptConfig(current.Config)
- if err != nil {
- return nil, fmt.Errorf("decrypt existing config: %w", err)
- }
- }
- if current.ProviderKey == payment.TypeEasyPay {
- if err := validateEasyPayCustomMethods(configToValidate, nextSupportedTypes); err != nil {
- return nil, err
- }
- }
- // 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.
- finalEnabled := current.Enabled
- if req.Enabled != nil {
- finalEnabled = *req.Enabled
- }
- if finalEnabled {
- if err := s.validateProviderConfig(current.ProviderKey, configToValidate); err != nil {
- 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)
- if err != nil {
- return nil, err
- }
- u.SetConfig(enc)
- }
- if req.SupportedTypes != nil {
- // Check pending orders before removing payment types
- count, err := getPendingOrderCount()
- if err != nil {
- return nil, err
- }
- if count > 0 {
- // Load current instance to compare types
- oldTypes := strings.Split(current.SupportedTypes, ",")
- 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)
- // Cascade: turning off refund_enabled also disables allow_user_refund
- if !*req.RefundEnabled {
- u.SetAllowUserRefund(false)
- }
- }
- if req.AllowUserRefund != nil {
- // Only allow enabling when refund_enabled is (or will be) true
- if *req.AllowUserRefund {
- refundEnabled := false
- if req.RefundEnabled != nil {
- refundEnabled = *req.RefundEnabled
- } else {
- refundEnabled = current.RefundEnabled
- }
- if refundEnabled {
- u.SetAllowUserRefund(true)
- }
- } else {
- u.SetAllowUserRefund(false)
- }
- }
- if req.PaymentMode != nil {
- u.SetPaymentMode(*req.PaymentMode)
- }
- return u.Save(ctx)
- }
- // 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),
- paymentproviderinstance.AllowUserRefundEQ(true),
- ).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
- }
- 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 {
- existing = map[string]string{}
- }
- for k, v := range newConfig {
- // 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
- }
- existing[k] = v
- }
- return existing, nil
- }
- // 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.
- //
- // 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.
- func (s *PaymentConfigService) decryptConfig(stored string) (map[string]string, error) {
- if stored == "" {
- return nil, nil
- }
- var cfg map[string]string
- if err := json.Unmarshal([]byte(stored), &cfg); err == nil {
- return cfg, nil
- }
- // Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
- if len(s.encryptionKey) == payment.AES256KeySize {
- //nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
- if plaintext, err := payment.Decrypt(stored, s.encryptionKey); err == nil {
- if err := json.Unmarshal([]byte(plaintext), &cfg); err == nil {
- return cfg, nil
- }
- }
- }
- slog.Warn("payment provider config unreadable, treating as empty for re-entry",
- "stored_len", len(stored))
- return nil, nil
- }
- 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)
- }
- // 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.
- 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)
- }
- return string(data), nil
- }
|