payment_config_providers.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "regexp"
  8. "strconv"
  9. "strings"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/ent/paymentorder"
  12. "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
  13. "github.com/Wei-Shaw/sub2api/internal/payment"
  14. "github.com/Wei-Shaw/sub2api/internal/payment/provider"
  15. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  16. )
  17. // validateProviderConfig runs the provider's constructor to surface config-level
  18. // errors at save time (e.g. wxpay missing certSerial), instead of only failing
  19. // when an order is created. Returns the structured ApplicationError from the
  20. // constructor so the frontend i18n layer can localize it.
  21. //
  22. // Only validates enabled instances — a disabled instance may be a half-filled
  23. // draft the admin will complete later.
  24. func (s *PaymentConfigService) validateProviderConfig(providerKey string, config map[string]string) error {
  25. _, err := provider.CreateProvider(providerKey, "_validate_", config)
  26. return err
  27. }
  28. // --- Provider Instance CRUD ---
  29. func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
  30. return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx)
  31. }
  32. // ProviderInstanceResponse is the API response for a provider instance.
  33. type ProviderInstanceResponse struct {
  34. ID int64 `json:"id"`
  35. ProviderKey string `json:"provider_key"`
  36. Name string `json:"name"`
  37. Config map[string]string `json:"config"`
  38. SupportedTypes []string `json:"supported_types"`
  39. Limits string `json:"limits"`
  40. Enabled bool `json:"enabled"`
  41. RefundEnabled bool `json:"refund_enabled"`
  42. AllowUserRefund bool `json:"allow_user_refund"`
  43. SortOrder int `json:"sort_order"`
  44. PaymentMode string `json:"payment_mode"`
  45. }
  46. // ListProviderInstancesWithConfig returns provider instances with decrypted config.
  47. func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Context) ([]ProviderInstanceResponse, error) {
  48. instances, err := s.entClient.PaymentProviderInstance.Query().
  49. Order(paymentproviderinstance.BySortOrder()).All(ctx)
  50. if err != nil {
  51. return nil, err
  52. }
  53. result := make([]ProviderInstanceResponse, 0, len(instances))
  54. for _, inst := range instances {
  55. resp := ProviderInstanceResponse{
  56. ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name,
  57. SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits,
  58. Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, AllowUserRefund: inst.AllowUserRefund,
  59. SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode,
  60. }
  61. resp.Config, err = s.decryptAndMaskConfig(inst.ProviderKey, inst.Config)
  62. if err != nil {
  63. return nil, fmt.Errorf("decrypt config for instance %d: %w", inst.ID, err)
  64. }
  65. result = append(result, resp)
  66. }
  67. return result, nil
  68. }
  69. // decryptAndMaskConfig returns the stored config with sensitive fields omitted.
  70. // Admin UIs display masked placeholders for these; the raw values never leave
  71. // the server. Callers that need the full config (e.g. payment runtime) must
  72. // use decryptConfig directly.
  73. func (s *PaymentConfigService) decryptAndMaskConfig(providerKey, encrypted string) (map[string]string, error) {
  74. cfg, err := s.decryptConfig(encrypted)
  75. if err != nil {
  76. return nil, err
  77. }
  78. if cfg == nil {
  79. return nil, nil
  80. }
  81. masked := make(map[string]string, len(cfg))
  82. for k, v := range cfg {
  83. if isSensitiveProviderConfigField(providerKey, k) {
  84. continue
  85. }
  86. masked[k] = v
  87. }
  88. return masked, nil
  89. }
  90. // pendingOrderStatuses are order statuses considered "in progress".
  91. var pendingOrderStatuses = []string{
  92. payment.OrderStatusPending,
  93. payment.OrderStatusPaid,
  94. payment.OrderStatusRecharging,
  95. }
  96. // providerSensitiveConfigFields is the authoritative list of config keys that
  97. // are treated as secrets per provider. Must stay in sync with the frontend
  98. // definition at frontend/src/components/payment/providerConfig.ts
  99. // (PROVIDER_CONFIG_FIELDS, fields with sensitive: true).
  100. //
  101. // Key matching is case-insensitive. Non-listed keys (e.g. appId, notifyUrl,
  102. // stripe publishableKey) are returned in plaintext by the admin GET API.
  103. var providerSensitiveConfigFields = map[string]map[string]struct{}{
  104. payment.TypeEasyPay: {"pkey": {}},
  105. payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}},
  106. payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}},
  107. payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
  108. payment.TypeAirwallex: {"apikey": {}, "webhooksecret": {}},
  109. }
  110. // providerPendingOrderProtectedConfigFields lists config keys that cannot be
  111. // changed while the instance has in-progress orders. This includes secrets plus
  112. // all provider identity fields that are snapshotted into orders or used by
  113. // webhook/refund verification.
  114. var providerPendingOrderProtectedConfigFields = map[string]map[string]struct{}{
  115. payment.TypeEasyPay: {"pkey": {}, "pid": {}},
  116. payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}, "appid": {}},
  117. payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}},
  118. payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}, "currency": {}},
  119. payment.TypeAirwallex: {"clientid": {}, "apikey": {}, "webhooksecret": {}, "apibase": {}, "accountid": {}, "currency": {}},
  120. }
  121. func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
  122. fields, ok := providerSensitiveConfigFields[providerKey]
  123. if !ok {
  124. return false
  125. }
  126. _, found := fields[strings.ToLower(fieldName)]
  127. return found
  128. }
  129. func hasPendingOrderProtectedConfigChange(providerKey string, currentConfig, nextConfig map[string]string) bool {
  130. fields, ok := providerPendingOrderProtectedConfigFields[providerKey]
  131. if !ok {
  132. return false
  133. }
  134. for fieldName := range fields {
  135. if providerConfigFieldValue(currentConfig, fieldName) != providerConfigFieldValue(nextConfig, fieldName) {
  136. return true
  137. }
  138. }
  139. return false
  140. }
  141. func providerConfigFieldValue(config map[string]string, fieldName string) string {
  142. for key, value := range config {
  143. if strings.EqualFold(key, fieldName) {
  144. return value
  145. }
  146. }
  147. return ""
  148. }
  149. func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) {
  150. return s.entClient.PaymentOrder.Query().
  151. Where(
  152. paymentorder.ProviderInstanceIDEQ(strconv.FormatInt(providerInstanceID, 10)),
  153. paymentorder.StatusIn(pendingOrderStatuses...),
  154. ).Count(ctx)
  155. }
  156. func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, planID int64) (int, error) {
  157. return s.entClient.PaymentOrder.Query().
  158. Where(
  159. paymentorder.PlanIDEQ(planID),
  160. paymentorder.StatusIn(pendingOrderStatuses...),
  161. ).Count(ctx)
  162. }
  163. var validProviderKeys = map[string]bool{
  164. payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true,
  165. }
  166. func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
  167. typesStr := joinTypes(req.SupportedTypes)
  168. if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
  169. return nil, err
  170. }
  171. if req.ProviderKey == payment.TypeEasyPay {
  172. if err := validateEasyPayCustomMethods(req.Config, typesStr); err != nil {
  173. return nil, err
  174. }
  175. }
  176. if err := s.validateVisibleMethodEnablementConflicts(ctx, 0, req.ProviderKey, typesStr, req.Enabled); err != nil {
  177. return nil, err
  178. }
  179. if req.Enabled {
  180. if err := s.validateProviderConfig(req.ProviderKey, req.Config); err != nil {
  181. return nil, err
  182. }
  183. }
  184. enc, err := s.encryptConfig(req.Config)
  185. if err != nil {
  186. return nil, err
  187. }
  188. allowUserRefund := req.AllowUserRefund && req.RefundEnabled
  189. return s.entClient.PaymentProviderInstance.Create().
  190. SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
  191. SetSupportedTypes(typesStr).SetEnabled(req.Enabled).SetPaymentMode(req.PaymentMode).
  192. SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled).
  193. SetAllowUserRefund(allowUserRefund).
  194. Save(ctx)
  195. }
  196. func validateProviderRequest(providerKey, name, supportedTypes string) error {
  197. if strings.TrimSpace(name) == "" {
  198. return infraerrors.BadRequest("VALIDATION_ERROR", "provider name is required")
  199. }
  200. if !validProviderKeys[providerKey] {
  201. return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("invalid provider key: %s", providerKey))
  202. }
  203. // supported_types can be empty (provider accepts no payment types until configured)
  204. return nil
  205. }
  206. var easyPayCustomMethodCodePattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
  207. type easyPayCustomMethodConfig struct {
  208. Type string `json:"type"`
  209. UpstreamType string `json:"upstreamType"`
  210. DisplayName string `json:"displayName"`
  211. }
  212. func validateEasyPayCustomMethods(config map[string]string, supportedTypes string) error {
  213. if config == nil {
  214. config = map[string]string{}
  215. }
  216. raw := strings.TrimSpace(config["customMethods"])
  217. methods := make([]easyPayCustomMethodConfig, 0)
  218. if raw != "" {
  219. if err := json.Unmarshal([]byte(raw), &methods); err != nil {
  220. return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods must be a JSON array")
  221. }
  222. }
  223. customTypes := make(map[string]struct{}, len(methods))
  224. for _, method := range methods {
  225. method.Type = strings.TrimSpace(method.Type)
  226. method.UpstreamType = strings.TrimSpace(method.UpstreamType)
  227. if method.Type == "" || method.UpstreamType == "" {
  228. return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods upstreamType is required")
  229. }
  230. if !easyPayCustomMethodCodePattern.MatchString(method.Type) {
  231. return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods type may only contain lowercase letters, digits, underscores, and hyphens")
  232. }
  233. if !easyPayCustomMethodCodePattern.MatchString(method.UpstreamType) {
  234. return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods upstreamType may only contain lowercase letters, digits, underscores, and hyphens")
  235. }
  236. if easyPayCustomMethodTypeConflictsWithBuiltin(method.Type) {
  237. return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods type cannot start with alipay or wxpay")
  238. }
  239. if _, exists := customTypes[method.Type]; exists {
  240. return infraerrors.BadRequest("VALIDATION_ERROR", "duplicate customMethods type")
  241. }
  242. customTypes[method.Type] = struct{}{}
  243. }
  244. for _, supportedType := range splitTypes(supportedTypes) {
  245. supportedType = strings.TrimSpace(supportedType)
  246. if supportedType == "" || supportedType == payment.TypeAlipay || supportedType == payment.TypeWxpay {
  247. continue
  248. }
  249. if !easyPayCustomMethodCodePattern.MatchString(supportedType) {
  250. return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s may only contain lowercase letters, digits, underscores, and hyphens", supportedType))
  251. }
  252. if _, exists := customTypes[supportedType]; !exists {
  253. return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s has no customMethods mapping", supportedType))
  254. }
  255. }
  256. return nil
  257. }
  258. func easyPayCustomMethodTypeConflictsWithBuiltin(methodType string) bool {
  259. return strings.HasPrefix(methodType, payment.TypeAlipay) || strings.HasPrefix(methodType, payment.TypeWxpay)
  260. }
  261. // UpdateProviderInstance updates a provider instance by ID (patch semantics).
  262. // NOTE: This function exceeds 30 lines due to per-field nil-check patch update
  263. // boilerplate and pending-order safety checks.
  264. func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
  265. current, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
  266. if err != nil {
  267. return nil, fmt.Errorf("load provider instance: %w", err)
  268. }
  269. var pendingOrderCount *int
  270. getPendingOrderCount := func() (int, error) {
  271. if pendingOrderCount != nil {
  272. return *pendingOrderCount, nil
  273. }
  274. count, err := s.countPendingOrders(ctx, id)
  275. if err != nil {
  276. return 0, fmt.Errorf("check pending orders: %w", err)
  277. }
  278. pendingOrderCount = &count
  279. return count, nil
  280. }
  281. nextEnabled := current.Enabled
  282. if req.Enabled != nil {
  283. nextEnabled = *req.Enabled
  284. }
  285. nextSupportedTypes := current.SupportedTypes
  286. if req.SupportedTypes != nil {
  287. nextSupportedTypes = joinTypes(req.SupportedTypes)
  288. }
  289. if err := s.validateVisibleMethodEnablementConflicts(ctx, id, current.ProviderKey, nextSupportedTypes, nextEnabled); err != nil {
  290. return nil, err
  291. }
  292. var mergedConfig map[string]string
  293. if req.Config != nil {
  294. currentConfig, err := s.decryptConfig(current.Config)
  295. if err != nil {
  296. return nil, fmt.Errorf("decrypt existing config: %w", err)
  297. }
  298. mergedConfig, err = s.mergeConfig(ctx, id, req.Config)
  299. if err != nil {
  300. return nil, err
  301. }
  302. if hasPendingOrderProtectedConfigChange(current.ProviderKey, currentConfig, mergedConfig) {
  303. count, err := getPendingOrderCount()
  304. if err != nil {
  305. return nil, err
  306. }
  307. if count > 0 {
  308. return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
  309. WithMetadata(map[string]string{"count": strconv.Itoa(count)})
  310. }
  311. }
  312. }
  313. if req.Enabled != nil && !*req.Enabled {
  314. count, err := getPendingOrderCount()
  315. if err != nil {
  316. return nil, err
  317. }
  318. if count > 0 {
  319. return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
  320. WithMetadata(map[string]string{"count": strconv.Itoa(count)})
  321. }
  322. }
  323. configToValidate := mergedConfig
  324. if configToValidate == nil {
  325. configToValidate, err = s.decryptConfig(current.Config)
  326. if err != nil {
  327. return nil, fmt.Errorf("decrypt existing config: %w", err)
  328. }
  329. }
  330. if current.ProviderKey == payment.TypeEasyPay {
  331. if err := validateEasyPayCustomMethods(configToValidate, nextSupportedTypes); err != nil {
  332. return nil, err
  333. }
  334. }
  335. // Validate merged config when the instance will end up enabled.
  336. // This surfaces provider-level errors (e.g. wxpay missing certSerial) at save time,
  337. // so admins see them in the dialog instead of only when an order is created.
  338. finalEnabled := current.Enabled
  339. if req.Enabled != nil {
  340. finalEnabled = *req.Enabled
  341. }
  342. if finalEnabled {
  343. if err := s.validateProviderConfig(current.ProviderKey, configToValidate); err != nil {
  344. return nil, err
  345. }
  346. }
  347. u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
  348. if req.Name != nil {
  349. u.SetName(*req.Name)
  350. }
  351. if mergedConfig != nil {
  352. enc, err := s.encryptConfig(mergedConfig)
  353. if err != nil {
  354. return nil, err
  355. }
  356. u.SetConfig(enc)
  357. }
  358. if req.SupportedTypes != nil {
  359. // Check pending orders before removing payment types
  360. count, err := getPendingOrderCount()
  361. if err != nil {
  362. return nil, err
  363. }
  364. if count > 0 {
  365. // Load current instance to compare types
  366. oldTypes := strings.Split(current.SupportedTypes, ",")
  367. newTypes := req.SupportedTypes
  368. for _, ot := range oldTypes {
  369. ot = strings.TrimSpace(ot)
  370. if ot == "" {
  371. continue
  372. }
  373. found := false
  374. for _, nt := range newTypes {
  375. if strings.TrimSpace(nt) == ot {
  376. found = true
  377. break
  378. }
  379. }
  380. if !found {
  381. return nil, infraerrors.Conflict("PENDING_ORDERS", "cannot remove payment types while instance has pending orders").
  382. WithMetadata(map[string]string{"count": strconv.Itoa(count)})
  383. }
  384. }
  385. }
  386. u.SetSupportedTypes(joinTypes(req.SupportedTypes))
  387. }
  388. if req.Enabled != nil {
  389. u.SetEnabled(*req.Enabled)
  390. }
  391. if req.SortOrder != nil {
  392. u.SetSortOrder(*req.SortOrder)
  393. }
  394. if req.Limits != nil {
  395. u.SetLimits(*req.Limits)
  396. }
  397. if req.RefundEnabled != nil {
  398. u.SetRefundEnabled(*req.RefundEnabled)
  399. // Cascade: turning off refund_enabled also disables allow_user_refund
  400. if !*req.RefundEnabled {
  401. u.SetAllowUserRefund(false)
  402. }
  403. }
  404. if req.AllowUserRefund != nil {
  405. // Only allow enabling when refund_enabled is (or will be) true
  406. if *req.AllowUserRefund {
  407. refundEnabled := false
  408. if req.RefundEnabled != nil {
  409. refundEnabled = *req.RefundEnabled
  410. } else {
  411. refundEnabled = current.RefundEnabled
  412. }
  413. if refundEnabled {
  414. u.SetAllowUserRefund(true)
  415. }
  416. } else {
  417. u.SetAllowUserRefund(false)
  418. }
  419. }
  420. if req.PaymentMode != nil {
  421. u.SetPaymentMode(*req.PaymentMode)
  422. }
  423. return u.Save(ctx)
  424. }
  425. // GetUserRefundEligibleInstanceIDs returns provider instance IDs that allow user refund.
  426. func (s *PaymentConfigService) GetUserRefundEligibleInstanceIDs(ctx context.Context) ([]string, error) {
  427. instances, err := s.entClient.PaymentProviderInstance.Query().
  428. Where(
  429. paymentproviderinstance.RefundEnabledEQ(true),
  430. paymentproviderinstance.AllowUserRefundEQ(true),
  431. ).Select(paymentproviderinstance.FieldID).All(ctx)
  432. if err != nil {
  433. return nil, err
  434. }
  435. ids := make([]string, 0, len(instances))
  436. for _, inst := range instances {
  437. ids = append(ids, strconv.FormatInt(int64(inst.ID), 10))
  438. }
  439. return ids, nil
  440. }
  441. func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newConfig map[string]string) (map[string]string, error) {
  442. inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
  443. if err != nil {
  444. return nil, fmt.Errorf("load existing provider: %w", err)
  445. }
  446. existing, err := s.decryptConfig(inst.Config)
  447. if err != nil {
  448. return nil, fmt.Errorf("decrypt existing config for instance %d: %w", id, err)
  449. }
  450. if existing == nil {
  451. existing = map[string]string{}
  452. }
  453. for k, v := range newConfig {
  454. // Preserve existing secrets when the client submits an empty value
  455. // (admin UI omits the value to indicate "leave unchanged").
  456. if v == "" && isSensitiveProviderConfigField(inst.ProviderKey, k) {
  457. continue
  458. }
  459. existing[k] = v
  460. }
  461. return existing, nil
  462. }
  463. // decryptConfig parses a stored provider config.
  464. // New records are plaintext JSON; legacy records are AES-256-GCM ciphertext
  465. // ("iv:authTag:ciphertext"). Values that cannot be parsed as either — including
  466. // legacy ciphertext with no/invalid TOTP_ENCRYPTION_KEY — are treated as empty,
  467. // letting the admin re-enter the config via the UI to complete the migration.
  468. //
  469. // TODO(deprecated-legacy-ciphertext): The AES fallback branch is a transitional
  470. // shim for pre-plaintext records. Remove it (and the encryptionKey field) after
  471. // a few releases once all live deployments have re-saved their provider configs.
  472. func (s *PaymentConfigService) decryptConfig(stored string) (map[string]string, error) {
  473. if stored == "" {
  474. return nil, nil
  475. }
  476. var cfg map[string]string
  477. if err := json.Unmarshal([]byte(stored), &cfg); err == nil {
  478. return cfg, nil
  479. }
  480. // Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
  481. if len(s.encryptionKey) == payment.AES256KeySize {
  482. //nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
  483. if plaintext, err := payment.Decrypt(stored, s.encryptionKey); err == nil {
  484. if err := json.Unmarshal([]byte(plaintext), &cfg); err == nil {
  485. return cfg, nil
  486. }
  487. }
  488. }
  489. slog.Warn("payment provider config unreadable, treating as empty for re-entry",
  490. "stored_len", len(stored))
  491. return nil, nil
  492. }
  493. func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
  494. count, err := s.countPendingOrders(ctx, id)
  495. if err != nil {
  496. return fmt.Errorf("check pending orders: %w", err)
  497. }
  498. if count > 0 {
  499. return infraerrors.Conflict("PENDING_ORDERS",
  500. fmt.Sprintf("this instance has %d in-progress orders and cannot be deleted — wait for orders to complete or disable the instance first", count))
  501. }
  502. return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
  503. }
  504. // encryptConfig serialises a provider config for storage.
  505. // New records are written as plaintext JSON; the historical AES-GCM wrapping
  506. // has been dropped but decryptConfig still accepts old ciphertext during migration.
  507. func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
  508. data, err := json.Marshal(cfg)
  509. if err != nil {
  510. return "", fmt.Errorf("marshal config: %w", err)
  511. }
  512. return string(data), nil
  513. }