payment_order.go 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "math"
  8. "net/url"
  9. "strconv"
  10. "strings"
  11. "time"
  12. dbent "github.com/Wei-Shaw/sub2api/ent"
  13. "github.com/Wei-Shaw/sub2api/ent/paymentorder"
  14. "github.com/Wei-Shaw/sub2api/internal/payment"
  15. "github.com/Wei-Shaw/sub2api/internal/payment/provider"
  16. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
  18. "github.com/shopspring/decimal"
  19. )
  20. // --- Order Creation ---
  21. func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) {
  22. if req.OrderType == "" {
  23. req.OrderType = payment.OrderTypeBalance
  24. }
  25. if normalized := NormalizeVisibleMethod(req.PaymentType); normalized != "" {
  26. req.PaymentType = normalized
  27. }
  28. cfg, err := s.configService.GetPaymentConfig(ctx)
  29. if err != nil {
  30. return nil, fmt.Errorf("get payment config: %w", err)
  31. }
  32. if !cfg.Enabled {
  33. return nil, infraerrors.Forbidden("PAYMENT_DISABLED", "payment system is disabled")
  34. }
  35. plan, err := s.validateOrderInput(ctx, req, cfg)
  36. if err != nil {
  37. return nil, err
  38. }
  39. if err := s.checkCancelRateLimit(ctx, req.UserID, cfg); err != nil {
  40. return nil, err
  41. }
  42. user, err := s.userRepo.GetByID(ctx, req.UserID)
  43. if err != nil {
  44. return nil, fmt.Errorf("get user: %w", err)
  45. }
  46. if user.Status != payment.EntityStatusActive {
  47. return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled")
  48. }
  49. if s.notificationEmailService != nil {
  50. s.notificationEmailService.RememberRecipientLocale(ctx, req.UserID, user.Email, req.Locale)
  51. }
  52. orderAmount := req.Amount
  53. limitAmount := req.Amount
  54. if plan != nil {
  55. orderAmount = plan.Price
  56. limitAmount = plan.Price
  57. } else if req.OrderType == payment.OrderTypeBalance {
  58. orderAmount = calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier)
  59. }
  60. feeRate := cfg.RechargeFeeRate
  61. methodCurrency := payment.DefaultPaymentCurrency
  62. if s.configService != nil {
  63. methodCurrency, err = s.configService.ValidateMethodCurrencyConsistency(ctx, req.PaymentType)
  64. if err != nil {
  65. return nil, err
  66. }
  67. }
  68. payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate)
  69. if err != nil {
  70. return nil, err
  71. }
  72. sel, err := s.selectCreateOrderInstance(ctx, req, cfg, payAmount)
  73. if err != nil {
  74. return nil, err
  75. }
  76. if err := s.validateSelectedCreateOrderInstance(ctx, req, sel); err != nil {
  77. return nil, err
  78. }
  79. selectedCurrency := payment.DefaultPaymentCurrency
  80. if sel != nil {
  81. selectedCurrency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config)
  82. }
  83. if selectedCurrency != methodCurrency {
  84. payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate)
  85. if err != nil {
  86. return nil, err
  87. }
  88. }
  89. if err := validateSelectedCreateOrderAmountCurrency(payAmountStr, sel); err != nil {
  90. return nil, err
  91. }
  92. oauthResp, err := s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, limitAmount, payAmount, feeRate, sel)
  93. if err != nil {
  94. return nil, err
  95. }
  96. if oauthResp != nil {
  97. return oauthResp, nil
  98. }
  99. order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount, sel)
  100. if err != nil {
  101. return nil, err
  102. }
  103. resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan, sel)
  104. if err != nil {
  105. _, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
  106. SetStatus(OrderStatusFailed).
  107. Save(ctx)
  108. return nil, err
  109. }
  110. return resp, nil
  111. }
  112. func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig) (*dbent.SubscriptionPlan, error) {
  113. if req.OrderType == payment.OrderTypeBalance && cfg.BalanceDisabled {
  114. return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled")
  115. }
  116. if req.OrderType == payment.OrderTypeSubscription {
  117. return s.validateSubOrder(ctx, req)
  118. }
  119. if math.IsNaN(req.Amount) || math.IsInf(req.Amount, 0) || req.Amount <= 0 {
  120. return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount must be a positive number")
  121. }
  122. if (cfg.MinAmount > 0 && req.Amount < cfg.MinAmount) || (cfg.MaxAmount > 0 && req.Amount > cfg.MaxAmount) {
  123. return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount out of range").
  124. WithMetadata(map[string]string{"min": fmt.Sprintf("%.2f", cfg.MinAmount), "max": fmt.Sprintf("%.2f", cfg.MaxAmount)})
  125. }
  126. return nil, nil
  127. }
  128. func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRequest) (*dbent.SubscriptionPlan, error) {
  129. if req.PlanID == 0 {
  130. return nil, infraerrors.BadRequest("INVALID_INPUT", "subscription order requires a plan")
  131. }
  132. plan, err := s.configService.GetPlan(ctx, req.PlanID)
  133. if err != nil || !plan.ForSale {
  134. return nil, infraerrors.NotFound("PLAN_NOT_AVAILABLE", "plan not found or not for sale")
  135. }
  136. group, err := s.groupRepo.GetByID(ctx, plan.GroupID)
  137. if err != nil || group.Status != payment.EntityStatusActive {
  138. return nil, infraerrors.NotFound("GROUP_NOT_FOUND", "subscription group is no longer available")
  139. }
  140. if !group.IsSubscriptionType() {
  141. return nil, infraerrors.BadRequest("GROUP_TYPE_MISMATCH", "group is not a subscription type")
  142. }
  143. return plan, nil
  144. }
  145. func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount float64, sel *payment.InstanceSelection) (*dbent.PaymentOrder, error) {
  146. tx, err := s.entClient.Tx(ctx)
  147. if err != nil {
  148. return nil, fmt.Errorf("begin transaction: %w", err)
  149. }
  150. defer func() { _ = tx.Rollback() }()
  151. if err := s.checkPendingLimit(ctx, tx, req.UserID, cfg.MaxPendingOrders); err != nil {
  152. return nil, err
  153. }
  154. if err := s.checkDailyLimit(ctx, tx, req.UserID, limitAmount, cfg.DailyLimit); err != nil {
  155. return nil, err
  156. }
  157. tm := cfg.OrderTimeoutMin
  158. if tm <= 0 {
  159. tm = defaultOrderTimeoutMin
  160. }
  161. exp := time.Now().Add(time.Duration(tm) * time.Minute)
  162. outTradeNo, err := s.allocateOutTradeNo(ctx, tx)
  163. if err != nil {
  164. return nil, err
  165. }
  166. providerSnapshot := buildPaymentOrderProviderSnapshot(sel, req)
  167. selectedInstanceID := ""
  168. selectedProviderKey := ""
  169. if sel != nil {
  170. selectedInstanceID = strings.TrimSpace(sel.InstanceID)
  171. selectedProviderKey = strings.TrimSpace(sel.ProviderKey)
  172. }
  173. b := tx.PaymentOrder.Create().
  174. SetUserID(req.UserID).
  175. SetUserEmail(user.Email).
  176. SetUserName(user.Username).
  177. SetNillableUserNotes(psNilIfEmpty(user.Notes)).
  178. SetAmount(orderAmount).
  179. SetPayAmount(payAmount).
  180. SetFeeRate(feeRate).
  181. SetRechargeCode("").
  182. SetOutTradeNo(outTradeNo).
  183. SetPaymentType(req.PaymentType).
  184. SetPaymentTradeNo("").
  185. SetOrderType(req.OrderType).
  186. SetStatus(OrderStatusPending).
  187. SetExpiresAt(exp).
  188. SetClientIP(req.ClientIP).
  189. SetSrcHost(req.SrcHost)
  190. if req.SrcURL != "" {
  191. b.SetSrcURL(req.SrcURL)
  192. }
  193. if selectedInstanceID != "" {
  194. b.SetProviderInstanceID(selectedInstanceID)
  195. }
  196. if selectedProviderKey != "" {
  197. b.SetProviderKey(selectedProviderKey)
  198. }
  199. if providerSnapshot != nil {
  200. b.SetProviderSnapshot(providerSnapshot)
  201. }
  202. if plan != nil {
  203. b.SetPlanID(plan.ID).SetSubscriptionGroupID(plan.GroupID).SetSubscriptionDays(psComputeValidityDays(plan.ValidityDays, plan.ValidityUnit))
  204. }
  205. order, err := b.Save(ctx)
  206. if err != nil {
  207. return nil, fmt.Errorf("create order: %w", err)
  208. }
  209. code := fmt.Sprintf("PAY-%d-%d", order.ID, time.Now().UnixNano()%100000)
  210. order, err = tx.PaymentOrder.UpdateOneID(order.ID).SetRechargeCode(code).Save(ctx)
  211. if err != nil {
  212. return nil, fmt.Errorf("set recharge code: %w", err)
  213. }
  214. if err := tx.Commit(); err != nil {
  215. return nil, fmt.Errorf("commit order transaction: %w", err)
  216. }
  217. return order, nil
  218. }
  219. func (s *PaymentService) allocateOutTradeNo(ctx context.Context, tx *dbent.Tx) (string, error) {
  220. const maxAttempts = 5
  221. for attempt := 0; attempt < maxAttempts; attempt++ {
  222. candidate := generateOutTradeNo()
  223. exists, err := tx.PaymentOrder.Query().Where(paymentorder.OutTradeNo(candidate)).Exist(ctx)
  224. if err != nil {
  225. return "", fmt.Errorf("check out_trade_no uniqueness: %w", err)
  226. }
  227. if !exists {
  228. return candidate, nil
  229. }
  230. }
  231. return "", fmt.Errorf("generate unique out_trade_no: exhausted %d attempts", maxAttempts)
  232. }
  233. func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, userID int64, max int) error {
  234. if max <= 0 {
  235. max = defaultMaxPendingOrders
  236. }
  237. c, err := tx.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID), paymentorder.StatusEQ(OrderStatusPending)).Count(ctx)
  238. if err != nil {
  239. return fmt.Errorf("count pending orders: %w", err)
  240. }
  241. if c >= max {
  242. return infraerrors.TooManyRequests("TOO_MANY_PENDING", "too_many_pending").
  243. WithMetadata(map[string]string{"max": strconv.Itoa(max)})
  244. }
  245. return nil
  246. }
  247. func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req CreateOrderRequest) map[string]any {
  248. if sel == nil {
  249. return nil
  250. }
  251. snapshot := map[string]any{}
  252. snapshot["schema_version"] = 2
  253. instanceID := strings.TrimSpace(sel.InstanceID)
  254. if instanceID != "" {
  255. snapshot["provider_instance_id"] = instanceID
  256. }
  257. providerKey := strings.TrimSpace(sel.ProviderKey)
  258. if providerKey != "" {
  259. snapshot["provider_key"] = providerKey
  260. }
  261. paymentMode := strings.TrimSpace(sel.PaymentMode)
  262. if paymentMode != "" {
  263. snapshot["payment_mode"] = paymentMode
  264. }
  265. if providerKey == payment.TypeWxpay {
  266. if merchantAppID := paymentOrderSnapshotWxpayAppID(sel, req); merchantAppID != "" {
  267. snapshot["merchant_app_id"] = merchantAppID
  268. }
  269. if merchantID := strings.TrimSpace(sel.Config["mchId"]); merchantID != "" {
  270. snapshot["merchant_id"] = merchantID
  271. }
  272. snapshot["currency"] = payment.DefaultPaymentCurrency
  273. }
  274. if providerKey == payment.TypeAlipay {
  275. if merchantAppID := strings.TrimSpace(sel.Config["appId"]); merchantAppID != "" {
  276. snapshot["merchant_app_id"] = merchantAppID
  277. }
  278. }
  279. if providerKey == payment.TypeEasyPay {
  280. if merchantID := strings.TrimSpace(sel.Config["pid"]); merchantID != "" {
  281. snapshot["merchant_id"] = merchantID
  282. }
  283. }
  284. if providerKey == payment.TypeStripe {
  285. snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config)
  286. }
  287. if providerKey == payment.TypeAirwallex {
  288. if accountID := strings.TrimSpace(sel.Config["accountId"]); accountID != "" {
  289. snapshot["merchant_id"] = accountID
  290. }
  291. snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config)
  292. }
  293. if len(snapshot) == 1 {
  294. return nil
  295. }
  296. return snapshot
  297. }
  298. func paymentOrderSnapshotWxpayAppID(sel *payment.InstanceSelection, req CreateOrderRequest) string {
  299. if sel == nil || strings.TrimSpace(sel.ProviderKey) != payment.TypeWxpay {
  300. return ""
  301. }
  302. if strings.TrimSpace(req.OpenID) != "" {
  303. return strings.TrimSpace(provider.ResolveWxpayJSAPIAppID(sel.Config))
  304. }
  305. return strings.TrimSpace(sel.Config["appId"])
  306. }
  307. func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, userID int64, amount, limit float64) error {
  308. if limit <= 0 {
  309. return nil
  310. }
  311. ts := psStartOfDayUTC(time.Now())
  312. orders, err := tx.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID), paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging, OrderStatusCompleted), paymentorder.PaidAtGTE(ts)).All(ctx)
  313. if err != nil {
  314. return fmt.Errorf("query daily usage: %w", err)
  315. }
  316. var used float64
  317. for _, o := range orders {
  318. if o.OrderType == payment.OrderTypeBalance {
  319. used += o.PayAmount
  320. continue
  321. }
  322. used += o.Amount
  323. }
  324. if used+amount > limit {
  325. return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily_limit_exceeded").
  326. WithMetadata(map[string]string{"remaining": fmt.Sprintf("%.2f", math.Max(0, limit-used))})
  327. }
  328. return nil
  329. }
  330. func (s *PaymentService) selectCreateOrderInstance(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig, payAmount float64) (*payment.InstanceSelection, error) {
  331. selectCtx, err := s.prepareCreateOrderSelectionContext(ctx, req)
  332. if err != nil {
  333. return nil, err
  334. }
  335. sel, err := s.loadBalancer.SelectInstance(selectCtx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
  336. if err != nil {
  337. return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "method_not_configured").
  338. WithMetadata(map[string]string{"payment_type": req.PaymentType})
  339. }
  340. if sel == nil {
  341. return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no_available_instance")
  342. }
  343. return sel, nil
  344. }
  345. func (s *PaymentService) prepareCreateOrderSelectionContext(ctx context.Context, req CreateOrderRequest) (context.Context, error) {
  346. if !requestNeedsWeChatJSAPICompatibility(req) {
  347. return ctx, nil
  348. }
  349. if !s.usesOfficialWxpayVisibleMethod(ctx) {
  350. return ctx, nil
  351. }
  352. expectedAppID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
  353. if err != nil {
  354. return nil, err
  355. }
  356. return payment.WithWxpayJSAPIAppID(ctx, expectedAppID), nil
  357. }
  358. func requestNeedsWeChatJSAPICompatibility(req CreateOrderRequest) bool {
  359. if payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
  360. return false
  361. }
  362. return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
  363. }
  364. func (s *PaymentService) usesOfficialWxpayVisibleMethod(ctx context.Context) bool {
  365. if s == nil || s.configService == nil {
  366. return false
  367. }
  368. inst, err := s.configService.resolveEnabledVisibleMethodInstance(ctx, payment.TypeWxpay)
  369. if err != nil {
  370. return false
  371. }
  372. if inst == nil {
  373. return false
  374. }
  375. return inst.ProviderKey == payment.TypeWxpay
  376. }
  377. func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
  378. prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
  379. if err != nil {
  380. slog.Error("[PaymentService] CreateProvider failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
  381. // If the provider returned a structured ApplicationError (e.g. WXPAY_CONFIG_MISSING_KEY),
  382. // pass it through with provider context added to metadata. Otherwise wrap as PAYMENT_PROVIDER_MISCONFIGURED.
  383. if appErr := new(infraerrors.ApplicationError); errors.As(err, &appErr) {
  384. md := map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID}
  385. for k, v := range appErr.Metadata {
  386. md[k] = v
  387. }
  388. return nil, appErr.WithMetadata(md)
  389. }
  390. return nil, infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_MISCONFIGURED", "provider_misconfigured").
  391. WithMetadata(map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID})
  392. }
  393. subject := s.buildPaymentSubject(plan, limitAmount, cfg, sel)
  394. outTradeNo := order.OutTradeNo
  395. canonicalReturnURL, err := CanonicalizeReturnURL(req.ReturnURL, req.SrcHost, req.SrcURL)
  396. if err != nil {
  397. return nil, err
  398. }
  399. resumeToken := ""
  400. if resume := s.paymentResume(); resume != nil {
  401. if canonicalReturnURL != "" && resume.isSigningConfigured() {
  402. resumeToken, err = resume.CreateToken(ResumeTokenClaims{
  403. OrderID: order.ID,
  404. UserID: order.UserID,
  405. ProviderInstanceID: sel.InstanceID,
  406. ProviderKey: sel.ProviderKey,
  407. PaymentType: req.PaymentType,
  408. CanonicalReturnURL: canonicalReturnURL,
  409. })
  410. if err != nil {
  411. return nil, fmt.Errorf("create payment resume token: %w", err)
  412. }
  413. }
  414. }
  415. providerReturnURL, err := buildPaymentReturnURL(canonicalReturnURL, order.ID, outTradeNo, resumeToken)
  416. if err != nil {
  417. return nil, err
  418. }
  419. providerReq := buildProviderCreatePaymentRequest(CreateOrderRequest{
  420. PaymentType: req.PaymentType,
  421. OpenID: req.OpenID,
  422. ClientIP: req.ClientIP,
  423. IsMobile: req.IsMobile,
  424. ReturnURL: providerReturnURL,
  425. }, sel, outTradeNo, payAmountStr, subject)
  426. providerReq.AlipayMobilePrecreate = shouldUseAlipayMobilePrecreate(req, cfg, sel)
  427. finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
  428. pr, err := prov.CreatePayment(ctx, providerReq)
  429. finishProviderCall()
  430. if err != nil {
  431. slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
  432. if appErr := new(infraerrors.ApplicationError); errors.As(err, &appErr) {
  433. return nil, appErr
  434. }
  435. return nil, classifyCreatePaymentError(req, sel.ProviderKey, err)
  436. }
  437. sanitizeCreatePaymentResponseDetails(pr)
  438. _, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).
  439. SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).
  440. SetNillablePayURL(psNilIfEmpty(pr.PayURL)).
  441. SetNillableQrCode(psNilIfEmpty(pr.QRCode)).
  442. SetNillableProviderInstanceID(psNilIfEmpty(sel.InstanceID)).
  443. SetNillableProviderKey(psNilIfEmpty(sel.ProviderKey)).
  444. Save(ctx)
  445. if err != nil {
  446. return nil, fmt.Errorf("update order with payment details: %w", err)
  447. }
  448. s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{
  449. "paymentAmount": req.Amount,
  450. "creditedAmount": order.Amount,
  451. "payAmount": order.PayAmount,
  452. "paymentType": req.PaymentType,
  453. "orderType": req.OrderType,
  454. "paymentSource": NormalizePaymentSource(req.PaymentSource),
  455. })
  456. resultType := pr.ResultType
  457. if resultType == "" {
  458. resultType = payment.CreatePaymentResultOrderCreated
  459. }
  460. resp := buildCreateOrderResponse(order, req, payAmount, sel, pr, resultType)
  461. resp.ResumeToken = resumeToken
  462. resp.AlipayMobilePrecreateDeepLink = providerReq.AlipayMobilePrecreate && strings.TrimSpace(pr.QRCode) != ""
  463. return resp, nil
  464. }
  465. func shouldUseAlipayMobilePrecreate(req CreateOrderRequest, cfg *PaymentConfig, sel *payment.InstanceSelection) bool {
  466. return cfg != nil &&
  467. cfg.AlipayMobilePrecreateDeepLink &&
  468. req.IsMobile &&
  469. sel != nil &&
  470. strings.EqualFold(strings.TrimSpace(sel.ProviderKey), payment.TypeAlipay)
  471. }
  472. func sanitizeCreatePaymentResponseDetails(pr *payment.CreatePaymentResponse) {
  473. if pr == nil {
  474. return
  475. }
  476. pr.TradeNo = removePostgresTextNUL(pr.TradeNo)
  477. pr.PayURL = removePostgresTextNUL(pr.PayURL)
  478. pr.QRCode = removePostgresTextNUL(pr.QRCode)
  479. }
  480. func removePostgresTextNUL(value string) string {
  481. if !strings.ContainsRune(value, 0) {
  482. return value
  483. }
  484. return strings.ReplaceAll(value, "\x00", "")
  485. }
  486. func buildProviderCreatePaymentRequest(req CreateOrderRequest, sel *payment.InstanceSelection, orderID, amount, subject string) payment.CreatePaymentRequest {
  487. return payment.CreatePaymentRequest{
  488. OrderID: orderID,
  489. Amount: amount,
  490. PaymentType: req.PaymentType,
  491. Subject: subject,
  492. ReturnURL: req.ReturnURL,
  493. OpenID: strings.TrimSpace(req.OpenID),
  494. ClientIP: req.ClientIP,
  495. IsMobile: req.IsMobile,
  496. InstanceSubMethods: selectedInstanceSupportedTypes(sel),
  497. }
  498. }
  499. func selectedInstanceSupportedTypes(sel *payment.InstanceSelection) string {
  500. if sel == nil {
  501. return ""
  502. }
  503. return sel.SupportedTypes
  504. }
  505. func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig, sel *payment.InstanceSelection) string {
  506. if plan != nil {
  507. productName := plan.ProductName
  508. if productName == "" {
  509. productName = "Sub2API Subscription " + plan.Name
  510. }
  511. return applyPaymentProductNameAffix(productName, cfg)
  512. }
  513. currency := payment.DefaultPaymentCurrency
  514. if sel != nil {
  515. currency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config)
  516. }
  517. amountStr := payment.FormatAmountForCurrency(limitAmount, currency)
  518. if hasPaymentProductNameAffix(cfg) {
  519. return applyPaymentProductNameAffix(amountStr, cfg)
  520. }
  521. return "Sub2API " + amountStr + " " + currency
  522. }
  523. func hasPaymentProductNameAffix(cfg *PaymentConfig) bool {
  524. if cfg == nil {
  525. return false
  526. }
  527. pf := strings.TrimSpace(cfg.ProductNamePrefix)
  528. sf := strings.TrimSpace(cfg.ProductNameSuffix)
  529. return pf != "" || sf != ""
  530. }
  531. func applyPaymentProductNameAffix(productName string, cfg *PaymentConfig) string {
  532. if !hasPaymentProductNameAffix(cfg) {
  533. return productName
  534. }
  535. pf := strings.TrimSpace(cfg.ProductNamePrefix)
  536. sf := strings.TrimSpace(cfg.ProductNameSuffix)
  537. return strings.TrimSpace(pf + " " + productName + " " + sf)
  538. }
  539. func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
  540. return s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, amount, payAmount, feeRate, nil)
  541. }
  542. func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponseForSelection(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
  543. if sel != nil && sel.ProviderKey != "" && sel.ProviderKey != payment.TypeWxpay {
  544. return nil, nil
  545. }
  546. if strings.TrimSpace(req.OpenID) != "" || !req.IsWeChatBrowser || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
  547. return nil, nil
  548. }
  549. return s.buildWeChatOAuthRequiredResponse(ctx, req, amount, payAmount, feeRate)
  550. }
  551. func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
  552. appID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
  553. if err != nil {
  554. return nil, err
  555. }
  556. if err := s.paymentResume().ensureSigningKey(); err != nil {
  557. return nil, err
  558. }
  559. authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base")
  560. if err != nil {
  561. return nil, err
  562. }
  563. return &CreateOrderResponse{
  564. Amount: amount,
  565. PayAmount: payAmount,
  566. FeeRate: feeRate,
  567. ResultType: payment.CreatePaymentResultOAuthRequired,
  568. PaymentType: req.PaymentType,
  569. OAuth: &payment.WechatOAuthInfo{
  570. AuthorizeURL: authorizeURL,
  571. AppID: appID,
  572. Scope: "snsapi_base",
  573. RedirectURL: "/auth/wechat/payment/callback",
  574. },
  575. }, nil
  576. }
  577. func (s *PaymentService) validateSelectedCreateOrderInstance(ctx context.Context, req CreateOrderRequest, sel *payment.InstanceSelection) error {
  578. if !requiresWeChatJSAPICompatibleSelection(req, sel) {
  579. return nil
  580. }
  581. expectedAppID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
  582. if err != nil {
  583. return err
  584. }
  585. selectedAppID := provider.ResolveWxpayJSAPIAppID(sel.Config)
  586. if selectedAppID == "" || selectedAppID != expectedAppID {
  587. return infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "selected payment instance is not compatible with the current WeChat OAuth app")
  588. }
  589. return nil
  590. }
  591. func calculateCreateOrderPayAmount(limitAmount, feeRate float64, currency string) (string, float64, error) {
  592. if err := validateCreateOrderAmountCurrency(limitAmount, currency); err != nil {
  593. return "", 0, err
  594. }
  595. payAmountStr := payment.CalculatePayAmountForCurrency(limitAmount, feeRate, currency)
  596. if _, err := payment.AmountToMinorUnit(payAmountStr, currency); err != nil {
  597. return "", 0, infraerrors.BadRequest("INVALID_AMOUNT", err.Error()).
  598. WithMetadata(map[string]string{"currency": currency})
  599. }
  600. payAmount, err := strconv.ParseFloat(payAmountStr, 64)
  601. if err != nil {
  602. return "", 0, infraerrors.BadRequest("INVALID_AMOUNT", "invalid payment amount").
  603. WithMetadata(map[string]string{"currency": currency})
  604. }
  605. return payAmountStr, payAmount, nil
  606. }
  607. func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, usdToCnyRate float64) (string, float64, error) {
  608. paymentAmount := limitAmount
  609. if orderType == payment.OrderTypeSubscription {
  610. paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, usdToCnyRate, currency)
  611. }
  612. return calculateCreateOrderPayAmount(paymentAmount, feeRate, currency)
  613. }
  614. // calculateSubscriptionGatewayBaseAmount 计算订阅订单的网关扣款基数。
  615. // 换算是显式 opt-in:仅当管理员配置了订阅汇率(rate > 0,1 USD = rate CNY)
  616. // 且网关币种为 CNY 时,按 price × rate 换算;未配置时保持 price 直付的存量行为。
  617. func calculateSubscriptionGatewayBaseAmount(amount, usdToCnyRate float64, currency string) float64 {
  618. rate := normalizeSubscriptionUSDToCNYRate(usdToCnyRate)
  619. if rate <= 0 || currency != payment.DefaultPaymentCurrency {
  620. return amount
  621. }
  622. return decimal.NewFromFloat(amount).
  623. Mul(decimal.NewFromFloat(rate)).
  624. Round(int32(payment.CurrencyMaxFractionDigits(currency))).
  625. InexactFloat64()
  626. }
  627. func validateCreateOrderAmountCurrency(amount float64, currency string) error {
  628. amountStr := strconv.FormatFloat(amount, 'f', -1, 64)
  629. if _, err := payment.AmountToMinorUnit(amountStr, currency); err != nil {
  630. return infraerrors.BadRequest("INVALID_AMOUNT", err.Error()).
  631. WithMetadata(map[string]string{"currency": currency})
  632. }
  633. return nil
  634. }
  635. func validateSelectedCreateOrderAmountCurrency(payAmount string, sel *payment.InstanceSelection) error {
  636. if sel == nil {
  637. return nil
  638. }
  639. currency := paymentProviderConfigCurrency(sel.ProviderKey, sel.Config)
  640. if _, err := payment.AmountToMinorUnit(payAmount, currency); err != nil {
  641. return infraerrors.BadRequest("INVALID_AMOUNT", err.Error()).
  642. WithMetadata(map[string]string{"currency": currency})
  643. }
  644. return nil
  645. }
  646. func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment.InstanceSelection) bool {
  647. if sel == nil || sel.ProviderKey != payment.TypeWxpay || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
  648. return false
  649. }
  650. return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
  651. }
  652. func (s *PaymentService) getWeChatPaymentOAuthCredential(ctx context.Context) (string, string, error) {
  653. if s == nil || s.configService == nil || s.configService.settingRepo == nil {
  654. return "", "", infraerrors.ServiceUnavailable(
  655. "WECHAT_PAYMENT_MP_NOT_CONFIGURED",
  656. "wechat in-app payment requires a complete WeChat MP OAuth credential",
  657. )
  658. }
  659. cfg, err := (&SettingService{settingRepo: s.configService.settingRepo}).GetWeChatConnectOAuthConfig(ctx)
  660. appID := strings.TrimSpace(cfg.AppIDForMode("mp"))
  661. appSecret := strings.TrimSpace(cfg.AppSecretForMode("mp"))
  662. if err != nil || !cfg.SupportsMode("mp") || appID == "" || appSecret == "" {
  663. return "", "", infraerrors.ServiceUnavailable(
  664. "WECHAT_PAYMENT_MP_NOT_CONFIGURED",
  665. "wechat in-app payment requires a complete WeChat MP OAuth credential",
  666. )
  667. }
  668. return appID, appSecret, nil
  669. }
  670. func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err error) error {
  671. if err == nil {
  672. return nil
  673. }
  674. if providerKey == payment.TypeWxpay &&
  675. payment.GetBasePaymentType(req.PaymentType) == payment.TypeWxpay &&
  676. strings.Contains(err.Error(), "wxpay h5 payments are not authorized for this merchant") {
  677. return infraerrors.ServiceUnavailable(
  678. "WECHAT_H5_NOT_AUTHORIZED",
  679. "wechat h5 payment is not available for this merchant",
  680. ).WithMetadata(map[string]string{
  681. "action": "open_in_wechat_or_scan_qr",
  682. })
  683. }
  684. return infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
  685. }
  686. func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, payAmount float64, sel *payment.InstanceSelection, pr *payment.CreatePaymentResponse, resultType payment.CreatePaymentResultType) *CreateOrderResponse {
  687. return &CreateOrderResponse{
  688. OrderID: order.ID,
  689. Amount: order.Amount,
  690. PayAmount: payAmount,
  691. FeeRate: order.FeeRate,
  692. Status: OrderStatusPending,
  693. ResultType: resultType,
  694. PaymentType: req.PaymentType,
  695. OutTradeNo: order.OutTradeNo,
  696. PayURL: pr.PayURL,
  697. QRCode: pr.QRCode,
  698. ClientSecret: pr.ClientSecret,
  699. IntentID: pr.IntentID,
  700. Currency: pr.Currency,
  701. CountryCode: pr.CountryCode,
  702. PaymentEnv: pr.PaymentEnv,
  703. OAuth: pr.OAuth,
  704. JSAPI: pr.JSAPI,
  705. JSAPIPayload: pr.JSAPI,
  706. ExpiresAt: order.ExpiresAt,
  707. PaymentMode: sel.PaymentMode,
  708. }
  709. }
  710. func buildWeChatPaymentOAuthStartURL(req CreateOrderRequest, scope string) (string, error) {
  711. u, err := url.Parse("/api/v1/auth/oauth/wechat/payment/start")
  712. if err != nil {
  713. return "", fmt.Errorf("build wechat payment oauth start url: %w", err)
  714. }
  715. q := u.Query()
  716. q.Set("payment_type", strings.TrimSpace(req.PaymentType))
  717. if req.Amount > 0 {
  718. q.Set("amount", strconv.FormatFloat(req.Amount, 'f', -1, 64))
  719. }
  720. if orderType := strings.TrimSpace(req.OrderType); orderType != "" {
  721. q.Set("order_type", orderType)
  722. }
  723. if req.PlanID > 0 {
  724. q.Set("plan_id", strconv.FormatInt(req.PlanID, 10))
  725. }
  726. if scope = strings.TrimSpace(scope); scope != "" {
  727. q.Set("scope", scope)
  728. }
  729. if redirectTo := paymentRedirectPathFromURL(req.SrcURL); redirectTo != "" {
  730. q.Set("redirect", redirectTo)
  731. }
  732. u.RawQuery = q.Encode()
  733. return u.String(), nil
  734. }
  735. func paymentRedirectPathFromURL(rawURL string) string {
  736. rawURL = strings.TrimSpace(rawURL)
  737. if rawURL == "" {
  738. return "/purchase"
  739. }
  740. if strings.HasPrefix(rawURL, "/") && !strings.HasPrefix(rawURL, "//") {
  741. return normalizePaymentRedirectPath(rawURL)
  742. }
  743. u, err := url.Parse(rawURL)
  744. if err != nil {
  745. return "/purchase"
  746. }
  747. path := strings.TrimSpace(u.EscapedPath())
  748. if path == "" {
  749. path = strings.TrimSpace(u.Path)
  750. }
  751. if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
  752. return "/purchase"
  753. }
  754. if strings.TrimSpace(u.RawQuery) != "" {
  755. path += "?" + u.RawQuery
  756. }
  757. return normalizePaymentRedirectPath(path)
  758. }
  759. func normalizePaymentRedirectPath(path string) string {
  760. path = strings.TrimSpace(path)
  761. if path == "" {
  762. return "/purchase"
  763. }
  764. if path == "/payment" {
  765. return "/purchase"
  766. }
  767. if strings.HasPrefix(path, "/payment?") {
  768. return "/purchase" + strings.TrimPrefix(path, "/payment")
  769. }
  770. return path
  771. }
  772. // --- Order Queries ---
  773. func (s *PaymentService) GetOrder(ctx context.Context, orderID, userID int64) (*dbent.PaymentOrder, error) {
  774. o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
  775. if err != nil {
  776. return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
  777. }
  778. if o.UserID != userID {
  779. return nil, infraerrors.Forbidden("FORBIDDEN", "no permission for this order")
  780. }
  781. return o, nil
  782. }
  783. func (s *PaymentService) GetOrderByID(ctx context.Context, orderID int64) (*dbent.PaymentOrder, error) {
  784. o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
  785. if err != nil {
  786. return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
  787. }
  788. return o, nil
  789. }
  790. func (s *PaymentService) GetUserOrders(ctx context.Context, userID int64, p OrderListParams) ([]*dbent.PaymentOrder, int, error) {
  791. q := s.entClient.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID))
  792. if p.Status != "" {
  793. q = q.Where(paymentorder.StatusEQ(p.Status))
  794. }
  795. if p.OrderType != "" {
  796. q = q.Where(paymentorder.OrderTypeEQ(p.OrderType))
  797. }
  798. if p.PaymentType != "" {
  799. q = q.Where(paymentorder.PaymentTypeEQ(p.PaymentType))
  800. }
  801. total, err := q.Clone().Count(ctx)
  802. if err != nil {
  803. return nil, 0, fmt.Errorf("count user orders: %w", err)
  804. }
  805. ps, pg := applyPagination(p.PageSize, p.Page)
  806. orders, err := q.Order(dbent.Desc(paymentorder.FieldCreatedAt)).Limit(ps).Offset((pg - 1) * ps).All(ctx)
  807. if err != nil {
  808. return nil, 0, fmt.Errorf("query user orders: %w", err)
  809. }
  810. return orders, total, nil
  811. }
  812. // AdminListOrders returns a paginated list of orders. If userID > 0, filters by user.
  813. func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p OrderListParams) ([]*dbent.PaymentOrder, int, error) {
  814. q := s.entClient.PaymentOrder.Query()
  815. if userID > 0 {
  816. q = q.Where(paymentorder.UserIDEQ(userID))
  817. }
  818. if p.Status != "" {
  819. q = q.Where(paymentorder.StatusEQ(p.Status))
  820. }
  821. if p.OrderType != "" {
  822. q = q.Where(paymentorder.OrderTypeEQ(p.OrderType))
  823. }
  824. if p.PaymentType != "" {
  825. q = q.Where(paymentorder.PaymentTypeEQ(p.PaymentType))
  826. }
  827. if p.Keyword != "" {
  828. q = q.Where(paymentorder.Or(
  829. paymentorder.OutTradeNoContainsFold(p.Keyword),
  830. paymentorder.UserEmailContainsFold(p.Keyword),
  831. paymentorder.UserNameContainsFold(p.Keyword),
  832. ))
  833. }
  834. total, err := q.Clone().Count(ctx)
  835. if err != nil {
  836. return nil, 0, fmt.Errorf("count admin orders: %w", err)
  837. }
  838. ps, pg := applyPagination(p.PageSize, p.Page)
  839. orders, err := q.Order(dbent.Desc(paymentorder.FieldCreatedAt)).Limit(ps).Offset((pg - 1) * ps).All(ctx)
  840. if err != nil {
  841. return nil, 0, fmt.Errorf("query admin orders: %w", err)
  842. }
  843. return orders, total, nil
  844. }