payment_config_limits.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "strings"
  7. dbent "github.com/Wei-Shaw/sub2api/ent"
  8. "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
  9. "github.com/Wei-Shaw/sub2api/internal/payment"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. )
  12. // GetAvailableMethodLimits collects all payment types from enabled provider
  13. // instances and returns limits for each, plus the global widest range.
  14. // Stripe sub-types (card, link) are aggregated under "stripe".
  15. func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*MethodLimitsResponse, error) {
  16. instances, err := s.entClient.PaymentProviderInstance.Query().
  17. Where(paymentproviderinstance.EnabledEQ(true)).All(ctx)
  18. if err != nil {
  19. return nil, fmt.Errorf("query provider instances: %w", err)
  20. }
  21. typeInstances := pcGroupByPaymentType(instances)
  22. typeInstances = s.pcApplyEnabledVisibleMethodInstances(ctx, typeInstances, instances)
  23. resp := &MethodLimitsResponse{
  24. Methods: make(map[string]MethodLimits, len(typeInstances)),
  25. }
  26. for pt, insts := range typeInstances {
  27. currency, ok := s.pcAggregateMethodCurrency(insts)
  28. if !ok {
  29. continue
  30. }
  31. ml := pcAggregateMethodLimits(pt, insts)
  32. ml.DisplayName = s.pcAggregateMethodDisplayName(pt, insts)
  33. ml.Currency = currency
  34. resp.Methods[ml.PaymentType] = ml
  35. }
  36. resp.GlobalMin, resp.GlobalMax = pcComputeGlobalRange(resp.Methods)
  37. return resp, nil
  38. }
  39. func (s *PaymentConfigService) pcApplyEnabledVisibleMethodInstances(ctx context.Context, typeInstances map[string][]*dbent.PaymentProviderInstance, instances []*dbent.PaymentProviderInstance) map[string][]*dbent.PaymentProviderInstance {
  40. if len(typeInstances) == 0 {
  41. return typeInstances
  42. }
  43. filtered := make(map[string][]*dbent.PaymentProviderInstance, len(typeInstances))
  44. for paymentType, groupedInstances := range typeInstances {
  45. filtered[paymentType] = groupedInstances
  46. }
  47. for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} {
  48. matching := filterEnabledVisibleMethodInstances(instances, method)
  49. providerKey, err := s.resolveVisibleMethodProviderKey(ctx, method, matching)
  50. if err != nil {
  51. delete(filtered, method)
  52. continue
  53. }
  54. if providerKey == "" {
  55. if len(matching) == 0 {
  56. delete(filtered, method)
  57. continue
  58. }
  59. filtered[method] = matching
  60. continue
  61. }
  62. selectedInstances := filterVisibleMethodInstancesByProviderKey(instances, method, providerKey)
  63. if len(selectedInstances) == 0 {
  64. delete(filtered, method)
  65. continue
  66. }
  67. filtered[method] = selectedInstances
  68. }
  69. return filtered
  70. }
  71. // GetMethodLimits returns per-payment-type limits from enabled provider instances.
  72. func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) {
  73. instances, err := s.entClient.PaymentProviderInstance.Query().
  74. Where(paymentproviderinstance.EnabledEQ(true)).All(ctx)
  75. if err != nil {
  76. return nil, fmt.Errorf("query provider instances: %w", err)
  77. }
  78. result := make([]MethodLimits, 0, len(types))
  79. for _, pt := range types {
  80. var matching []*dbent.PaymentProviderInstance
  81. for _, inst := range instances {
  82. if payment.InstanceSupportsType(inst.SupportedTypes, pt) {
  83. matching = append(matching, inst)
  84. }
  85. }
  86. currency, ok := s.pcAggregateMethodCurrency(matching)
  87. if !ok {
  88. continue
  89. }
  90. ml := pcAggregateMethodLimits(pt, matching)
  91. ml.DisplayName = s.pcAggregateMethodDisplayName(pt, matching)
  92. ml.Currency = currency
  93. result = append(result, ml)
  94. }
  95. return result, nil
  96. }
  97. func (s *PaymentConfigService) ValidateMethodCurrencyConsistency(ctx context.Context, paymentType string) (string, error) {
  98. method := NormalizeVisibleMethod(paymentType)
  99. if method == "" || s == nil || s.entClient == nil {
  100. return payment.DefaultPaymentCurrency, nil
  101. }
  102. instances, err := s.entClient.PaymentProviderInstance.Query().
  103. Where(paymentproviderinstance.EnabledEQ(true)).All(ctx)
  104. if err != nil {
  105. return "", fmt.Errorf("query provider instances: %w", err)
  106. }
  107. typeInstances := pcGroupByPaymentType(instances)
  108. typeInstances = s.pcApplyEnabledVisibleMethodInstances(ctx, typeInstances, instances)
  109. matching := typeInstances[method]
  110. if len(matching) == 0 {
  111. return payment.DefaultPaymentCurrency, nil
  112. }
  113. currency, ok := s.pcAggregateMethodCurrency(matching)
  114. if !ok {
  115. return "", infraerrors.ServiceUnavailable(
  116. "PAYMENT_METHOD_CURRENCY_CONFLICT",
  117. "payment method has enabled provider instances with mixed currencies",
  118. ).WithMetadata(map[string]string{"payment_type": method})
  119. }
  120. return currency, nil
  121. }
  122. func (s *PaymentConfigService) pcAggregateMethodCurrency(instances []*dbent.PaymentProviderInstance) (string, bool) {
  123. currency := ""
  124. for _, inst := range instances {
  125. next := s.pcInstancePaymentCurrency(inst)
  126. if next == "" {
  127. continue
  128. }
  129. if currency == "" {
  130. currency = next
  131. continue
  132. }
  133. if currency != next {
  134. return "", false
  135. }
  136. }
  137. if currency == "" {
  138. return payment.DefaultPaymentCurrency, true
  139. }
  140. return currency, true
  141. }
  142. func (s *PaymentConfigService) pcInstancePaymentCurrency(inst *dbent.PaymentProviderInstance) string {
  143. if inst == nil {
  144. return payment.DefaultPaymentCurrency
  145. }
  146. cfg := map[string]string{}
  147. if s != nil {
  148. decrypted, err := s.decryptConfig(inst.Config)
  149. if err == nil && decrypted != nil {
  150. cfg = decrypted
  151. }
  152. }
  153. return paymentProviderConfigCurrency(inst.ProviderKey, cfg)
  154. }
  155. type easyPayCustomMethodDisplayConfig struct {
  156. Type string `json:"type"`
  157. DisplayName string `json:"displayName"`
  158. }
  159. func (s *PaymentConfigService) pcAggregateMethodDisplayName(pt string, instances []*dbent.PaymentProviderInstance) string {
  160. pt = strings.TrimSpace(pt)
  161. if pt == "" {
  162. return ""
  163. }
  164. for _, inst := range instances {
  165. displayName := s.pcInstanceEasyPayCustomMethodDisplayName(inst, pt)
  166. if displayName != "" {
  167. return displayName
  168. }
  169. }
  170. return ""
  171. }
  172. func (s *PaymentConfigService) pcInstanceEasyPayCustomMethodDisplayName(inst *dbent.PaymentProviderInstance, pt string) string {
  173. if inst == nil || inst.ProviderKey != payment.TypeEasyPay {
  174. return ""
  175. }
  176. cfg := map[string]string{}
  177. if s != nil {
  178. decrypted, err := s.decryptConfig(inst.Config)
  179. if err == nil && decrypted != nil {
  180. cfg = decrypted
  181. }
  182. }
  183. raw := strings.TrimSpace(cfg["customMethods"])
  184. if raw == "" {
  185. return ""
  186. }
  187. var methods []easyPayCustomMethodDisplayConfig
  188. if err := json.Unmarshal([]byte(raw), &methods); err != nil {
  189. return ""
  190. }
  191. for _, method := range methods {
  192. if strings.TrimSpace(method.Type) == pt {
  193. return strings.TrimSpace(method.DisplayName)
  194. }
  195. }
  196. return ""
  197. }
  198. // pcGroupByPaymentType groups instances by user-facing payment type.
  199. // For Stripe providers, ALL sub-types (card, link, alipay, wxpay) map to "stripe"
  200. // because the user sees a single "Stripe" button, not individual sub-methods.
  201. // Uses a seen set to avoid counting one instance twice.
  202. func pcGroupByPaymentType(instances []*dbent.PaymentProviderInstance) map[string][]*dbent.PaymentProviderInstance {
  203. typeInstances := make(map[string][]*dbent.PaymentProviderInstance)
  204. seen := make(map[string]map[int64]bool)
  205. add := func(key string, inst *dbent.PaymentProviderInstance) {
  206. if seen[key] == nil {
  207. seen[key] = make(map[int64]bool)
  208. }
  209. if !seen[key][int64(inst.ID)] {
  210. seen[key][int64(inst.ID)] = true
  211. typeInstances[key] = append(typeInstances[key], inst)
  212. }
  213. }
  214. for _, inst := range instances {
  215. // Stripe provider: all sub-types → single "stripe" group
  216. if inst.ProviderKey == payment.TypeStripe {
  217. add(payment.TypeStripe, inst)
  218. continue
  219. }
  220. for _, t := range splitTypes(inst.SupportedTypes) {
  221. add(t, inst)
  222. }
  223. }
  224. return typeInstances
  225. }
  226. // pcInstanceTypeLimits extracts per-type limits from a provider instance.
  227. // Returns (limits, true) if configured; (zero, false) if unlimited.
  228. // For Stripe instances, limits are stored under "stripe" key regardless of sub-types.
  229. func pcInstanceTypeLimits(inst *dbent.PaymentProviderInstance, pt string) (payment.ChannelLimits, bool) {
  230. if inst.Limits == "" {
  231. return payment.ChannelLimits{}, false
  232. }
  233. var limits payment.InstanceLimits
  234. if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
  235. return payment.ChannelLimits{}, false
  236. }
  237. cl, ok := limits[pt]
  238. return cl, ok
  239. }
  240. // unionFloat merges a single limit value into the aggregate using UNION semantics.
  241. // - For "min" fields (wantMin=true): keeps the lowest non-zero value
  242. // - For "max"/"cap" fields (wantMin=false): keeps the highest non-zero value
  243. // - If any value is 0 (unlimited), the result is unlimited.
  244. //
  245. // Returns (aggregated value, still limited).
  246. func unionFloat(agg float64, limited bool, val float64, wantMin bool) (float64, bool) {
  247. if val == 0 {
  248. return agg, false
  249. }
  250. if !limited {
  251. return agg, false
  252. }
  253. if agg == 0 {
  254. return val, true
  255. }
  256. if wantMin && val < agg {
  257. return val, true
  258. }
  259. if !wantMin && val > agg {
  260. return val, true
  261. }
  262. return agg, true
  263. }
  264. // pcAggregateMethodLimits computes the UNION (least restrictive) of limits
  265. // across all provider instances for a given payment type.
  266. //
  267. // Since the load balancer can route an order to any available instance,
  268. // the user should see the widest possible range:
  269. // - SingleMin: lowest floor across instances; 0 if any is unlimited
  270. // - SingleMax: highest ceiling across instances; 0 if any is unlimited
  271. // - DailyLimit: highest cap across instances; 0 if any is unlimited
  272. func pcAggregateMethodLimits(pt string, instances []*dbent.PaymentProviderInstance) MethodLimits {
  273. ml := MethodLimits{PaymentType: pt}
  274. minLimited, maxLimited, dailyLimited := true, true, true
  275. for _, inst := range instances {
  276. cl, hasLimits := pcInstanceTypeLimits(inst, pt)
  277. if !hasLimits {
  278. return MethodLimits{PaymentType: pt} // any unlimited instance → all zeros
  279. }
  280. ml.SingleMin, minLimited = unionFloat(ml.SingleMin, minLimited, cl.SingleMin, true)
  281. ml.SingleMax, maxLimited = unionFloat(ml.SingleMax, maxLimited, cl.SingleMax, false)
  282. ml.DailyLimit, dailyLimited = unionFloat(ml.DailyLimit, dailyLimited, cl.DailyLimit, false)
  283. }
  284. if !minLimited {
  285. ml.SingleMin = 0
  286. }
  287. if !maxLimited {
  288. ml.SingleMax = 0
  289. }
  290. if !dailyLimited {
  291. ml.DailyLimit = 0
  292. }
  293. return ml
  294. }
  295. // pcComputeGlobalRange computes the widest [min, max] across all methods.
  296. // Uses the same union logic: lowest min, highest max, 0 if any is unlimited.
  297. func pcComputeGlobalRange(methods map[string]MethodLimits) (globalMin, globalMax float64) {
  298. minLimited, maxLimited := true, true
  299. for _, ml := range methods {
  300. globalMin, minLimited = unionFloat(globalMin, minLimited, ml.SingleMin, true)
  301. globalMax, maxLimited = unionFloat(globalMax, maxLimited, ml.SingleMax, false)
  302. }
  303. if !minLimited {
  304. globalMin = 0
  305. }
  306. if !maxLimited {
  307. globalMax = 0
  308. }
  309. return globalMin, globalMax
  310. }