payment_config_plans.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. dbent "github.com/Wei-Shaw/sub2api/ent"
  7. "github.com/Wei-Shaw/sub2api/ent/group"
  8. "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
  9. "github.com/Wei-Shaw/sub2api/internal/payment"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. )
  12. // normalizePlanCurrency validates and normalizes the display-only currency label.
  13. // Empty means "no label" and is kept as-is so existing plans stay unchanged.
  14. func normalizePlanCurrency(raw string) (string, error) {
  15. if strings.TrimSpace(raw) == "" {
  16. return "", nil
  17. }
  18. currency, err := payment.NormalizePaymentCurrency(raw)
  19. if err != nil {
  20. return "", infraerrors.BadRequest("PLAN_CURRENCY_INVALID", "currency must be a 3-letter ISO currency code")
  21. }
  22. return currency, nil
  23. }
  24. // validatePlanRequired checks that all required fields for a plan are provided.
  25. func validatePlanRequired(name string, groupID int64, price float64, validityDays int, validityUnit string, originalPrice *float64) error {
  26. if strings.TrimSpace(name) == "" {
  27. return infraerrors.BadRequest("PLAN_NAME_REQUIRED", "plan name is required")
  28. }
  29. if groupID <= 0 {
  30. return infraerrors.BadRequest("PLAN_GROUP_REQUIRED", "group is required")
  31. }
  32. if price <= 0 {
  33. return infraerrors.BadRequest("PLAN_PRICE_INVALID", "price must be > 0")
  34. }
  35. if validityDays <= 0 {
  36. return infraerrors.BadRequest("PLAN_VALIDITY_REQUIRED", "validity days must be > 0")
  37. }
  38. if strings.TrimSpace(validityUnit) == "" {
  39. return infraerrors.BadRequest("PLAN_VALIDITY_UNIT_REQUIRED", "validity unit is required")
  40. }
  41. if originalPrice != nil && *originalPrice < 0 {
  42. return infraerrors.BadRequest("PLAN_ORIGINAL_PRICE_INVALID", "original price must be >= 0")
  43. }
  44. return nil
  45. }
  46. // validatePlanPatch validates only the non-nil fields in a patch update.
  47. func validatePlanPatch(req UpdatePlanRequest) error {
  48. if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
  49. return infraerrors.BadRequest("PLAN_NAME_REQUIRED", "plan name is required")
  50. }
  51. if req.GroupID != nil && *req.GroupID <= 0 {
  52. return infraerrors.BadRequest("PLAN_GROUP_REQUIRED", "group is required")
  53. }
  54. if req.Price != nil && *req.Price <= 0 {
  55. return infraerrors.BadRequest("PLAN_PRICE_INVALID", "price must be > 0")
  56. }
  57. if req.ValidityDays != nil && *req.ValidityDays <= 0 {
  58. return infraerrors.BadRequest("PLAN_VALIDITY_REQUIRED", "validity days must be > 0")
  59. }
  60. if req.ValidityUnit != nil && strings.TrimSpace(*req.ValidityUnit) == "" {
  61. return infraerrors.BadRequest("PLAN_VALIDITY_UNIT_REQUIRED", "validity unit is required")
  62. }
  63. if req.OriginalPrice != nil && *req.OriginalPrice < 0 {
  64. return infraerrors.BadRequest("PLAN_ORIGINAL_PRICE_INVALID", "original price must be >= 0")
  65. }
  66. return nil
  67. }
  68. // --- Plan CRUD ---
  69. // PlanGroupInfo holds the group details needed for subscription plan display.
  70. type PlanGroupInfo struct {
  71. Platform string `json:"platform"`
  72. Name string `json:"name"`
  73. RateMultiplier float64 `json:"rate_multiplier"`
  74. PeakRateEnabled bool `json:"peak_rate_enabled"`
  75. PeakStart string `json:"peak_start"`
  76. PeakEnd string `json:"peak_end"`
  77. PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
  78. DailyLimitUSD *float64 `json:"daily_limit_usd"`
  79. WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
  80. MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
  81. ModelScopes []string `json:"supported_model_scopes"`
  82. }
  83. // GetGroupInfoMap returns a map of group_id → PlanGroupInfo for the given plans.
  84. func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbent.SubscriptionPlan) map[int64]PlanGroupInfo {
  85. ids := make([]int64, 0, len(plans))
  86. seen := make(map[int64]bool)
  87. for _, p := range plans {
  88. if !seen[p.GroupID] {
  89. seen[p.GroupID] = true
  90. ids = append(ids, p.GroupID)
  91. }
  92. }
  93. if len(ids) == 0 {
  94. return nil
  95. }
  96. groups, err := s.entClient.Group.Query().Where(group.IDIn(ids...)).All(ctx)
  97. if err != nil {
  98. return nil
  99. }
  100. m := make(map[int64]PlanGroupInfo, len(groups))
  101. for _, g := range groups {
  102. m[int64(g.ID)] = PlanGroupInfo{
  103. Platform: g.Platform,
  104. Name: g.Name,
  105. RateMultiplier: g.RateMultiplier,
  106. PeakRateEnabled: g.PeakRateEnabled,
  107. PeakStart: g.PeakStart,
  108. PeakEnd: g.PeakEnd,
  109. PeakRateMultiplier: g.PeakRateMultiplier,
  110. DailyLimitUSD: g.DailyLimitUsd,
  111. WeeklyLimitUSD: g.WeeklyLimitUsd,
  112. MonthlyLimitUSD: g.MonthlyLimitUsd,
  113. ModelScopes: g.SupportedModelScopes,
  114. }
  115. }
  116. return m
  117. }
  118. func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
  119. return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
  120. }
  121. func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
  122. return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
  123. }
  124. func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
  125. if err := validatePlanRequired(req.Name, req.GroupID, req.Price, req.ValidityDays, req.ValidityUnit, req.OriginalPrice); err != nil {
  126. return nil, err
  127. }
  128. currency, err := normalizePlanCurrency(req.Currency)
  129. if err != nil {
  130. return nil, err
  131. }
  132. b := s.entClient.SubscriptionPlan.Create().
  133. SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description).
  134. SetPrice(req.Price).SetCurrency(currency).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit).
  135. SetFeatures(req.Features).SetProductName(req.ProductName).
  136. SetForSale(req.ForSale).SetSortOrder(req.SortOrder)
  137. if req.OriginalPrice != nil {
  138. b.SetOriginalPrice(*req.OriginalPrice)
  139. }
  140. return b.Save(ctx)
  141. }
  142. // UpdatePlan updates a subscription plan by ID (patch semantics).
  143. // NOTE: This function exceeds 30 lines due to per-field nil-check patch update boilerplate
  144. // plus a validation guard for non-nil fields.
  145. func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) (*dbent.SubscriptionPlan, error) {
  146. if err := validatePlanPatch(req); err != nil {
  147. return nil, err
  148. }
  149. u := s.entClient.SubscriptionPlan.UpdateOneID(id)
  150. if req.GroupID != nil {
  151. u.SetGroupID(*req.GroupID)
  152. }
  153. if req.Name != nil {
  154. u.SetName(*req.Name)
  155. }
  156. if req.Description != nil {
  157. u.SetDescription(*req.Description)
  158. }
  159. if req.Price != nil {
  160. u.SetPrice(*req.Price)
  161. }
  162. if req.OriginalPrice != nil {
  163. u.SetOriginalPrice(*req.OriginalPrice)
  164. }
  165. if req.Currency != nil {
  166. currency, err := normalizePlanCurrency(*req.Currency)
  167. if err != nil {
  168. return nil, err
  169. }
  170. u.SetCurrency(currency)
  171. }
  172. if req.ValidityDays != nil {
  173. u.SetValidityDays(*req.ValidityDays)
  174. }
  175. if req.ValidityUnit != nil {
  176. u.SetValidityUnit(*req.ValidityUnit)
  177. }
  178. if req.Features != nil {
  179. u.SetFeatures(*req.Features)
  180. }
  181. if req.ProductName != nil {
  182. u.SetProductName(*req.ProductName)
  183. }
  184. if req.ForSale != nil {
  185. u.SetForSale(*req.ForSale)
  186. }
  187. if req.SortOrder != nil {
  188. u.SetSortOrder(*req.SortOrder)
  189. }
  190. return u.Save(ctx)
  191. }
  192. func (s *PaymentConfigService) DeletePlan(ctx context.Context, id int64) error {
  193. count, err := s.countPendingOrdersByPlan(ctx, id)
  194. if err != nil {
  195. return fmt.Errorf("check pending orders: %w", err)
  196. }
  197. if count > 0 {
  198. return infraerrors.Conflict("PENDING_ORDERS",
  199. fmt.Sprintf("this plan has %d in-progress orders and cannot be deleted — wait for orders to complete first", count))
  200. }
  201. return s.entClient.SubscriptionPlan.DeleteOneID(id).Exec(ctx)
  202. }
  203. // GetPlan returns a subscription plan by ID.
  204. func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.SubscriptionPlan, error) {
  205. plan, err := s.entClient.SubscriptionPlan.Get(ctx, id)
  206. if err != nil {
  207. return nil, infraerrors.NotFound("PLAN_NOT_FOUND", "subscription plan not found")
  208. }
  209. return plan, nil
  210. }