payment_refund.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "math"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "entgo.io/ent/dialect/sql"
  13. dbent "github.com/Wei-Shaw/sub2api/ent"
  14. "github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
  15. "github.com/Wei-Shaw/sub2api/ent/paymentorder"
  16. "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
  17. "github.com/Wei-Shaw/sub2api/internal/payment"
  18. "github.com/Wei-Shaw/sub2api/internal/payment/provider"
  19. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  20. "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
  21. )
  22. // --- Refund Flow ---
  23. var createPaymentProviderFromInstance = provider.CreateProvider
  24. // getOrderProviderInstance looks up the provider instance that processed this order.
  25. // For legacy orders without provider_instance_id, it resolves only when the
  26. // historical instance is uniquely identifiable from the stored order fields.
  27. func (s *PaymentService) getOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
  28. if s == nil || s.entClient == nil || o == nil {
  29. return nil, nil
  30. }
  31. if snapshot := psOrderProviderSnapshot(o); snapshot != nil {
  32. return s.resolveSnapshotOrderProviderInstance(ctx, o, snapshot)
  33. }
  34. instIDStr := strings.TrimSpace(psStringValue(o.ProviderInstanceID))
  35. if instIDStr == "" {
  36. return s.resolveUniqueLegacyOrderProviderInstance(ctx, o)
  37. }
  38. instID, err := strconv.ParseInt(instIDStr, 10, 64)
  39. if err != nil {
  40. return nil, nil
  41. }
  42. return s.entClient.PaymentProviderInstance.Get(ctx, instID)
  43. }
  44. // getRefundOrderProviderInstance resolves the provider instance for refund paths.
  45. // Refunds must be pinned to an explicit historical binding, so legacy
  46. // "best-effort" provider guessing is intentionally not allowed here.
  47. func (s *PaymentService) getRefundOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
  48. if s == nil || s.entClient == nil || o == nil {
  49. return nil, nil
  50. }
  51. if snapshot := psOrderProviderSnapshot(o); snapshot != nil {
  52. return s.resolveSnapshotOrderProviderInstance(ctx, o, snapshot)
  53. }
  54. instIDStr := strings.TrimSpace(psStringValue(o.ProviderInstanceID))
  55. if instIDStr == "" {
  56. return nil, nil
  57. }
  58. instID, err := strconv.ParseInt(instIDStr, 10, 64)
  59. if err != nil {
  60. return nil, fmt.Errorf("order %d refund provider instance id is invalid: %s", o.ID, instIDStr)
  61. }
  62. inst, err := s.entClient.PaymentProviderInstance.Get(ctx, instID)
  63. if err != nil {
  64. if dbent.IsNotFound(err) {
  65. return nil, fmt.Errorf("order %d refund provider instance %s is missing", o.ID, instIDStr)
  66. }
  67. return nil, err
  68. }
  69. return inst, nil
  70. }
  71. func (s *PaymentService) resolveUniqueLegacyOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
  72. paymentType := payment.GetBasePaymentType(strings.TrimSpace(o.PaymentType))
  73. providerKey := strings.TrimSpace(psStringValue(o.ProviderKey))
  74. if providerKey != "" {
  75. instances, err := s.entClient.PaymentProviderInstance.Query().
  76. Where(paymentproviderinstance.ProviderKeyEQ(providerKey)).
  77. All(ctx)
  78. if err != nil {
  79. return nil, err
  80. }
  81. matched := psFilterLegacyOrderProviderInstances(paymentType, instances)
  82. if len(matched) == 1 {
  83. return matched[0], nil
  84. }
  85. return nil, nil
  86. }
  87. if paymentType == "" {
  88. return nil, nil
  89. }
  90. instances, err := s.entClient.PaymentProviderInstance.Query().
  91. All(ctx)
  92. if err != nil {
  93. return nil, err
  94. }
  95. matched := psFilterLegacyOrderProviderInstances(paymentType, instances)
  96. if len(matched) == 1 {
  97. return matched[0], nil
  98. }
  99. return nil, nil
  100. }
  101. func psFilterLegacyOrderProviderInstances(orderPaymentType string, instances []*dbent.PaymentProviderInstance) []*dbent.PaymentProviderInstance {
  102. if len(instances) == 0 {
  103. return nil
  104. }
  105. if strings.TrimSpace(orderPaymentType) == "" {
  106. return instances
  107. }
  108. var matched []*dbent.PaymentProviderInstance
  109. for _, inst := range instances {
  110. if psLegacyOrderMatchesInstance(orderPaymentType, inst) {
  111. matched = append(matched, inst)
  112. }
  113. }
  114. return matched
  115. }
  116. func psLegacyOrderMatchesInstance(orderPaymentType string, inst *dbent.PaymentProviderInstance) bool {
  117. if inst == nil {
  118. return false
  119. }
  120. baseType := payment.GetBasePaymentType(strings.TrimSpace(orderPaymentType))
  121. instanceProviderKey := strings.TrimSpace(inst.ProviderKey)
  122. if baseType == "" {
  123. return false
  124. }
  125. if baseType == payment.TypeStripe {
  126. return instanceProviderKey == payment.TypeStripe
  127. }
  128. if instanceProviderKey == payment.TypeStripe {
  129. return false
  130. }
  131. if instanceProviderKey == baseType {
  132. return true
  133. }
  134. return payment.InstanceSupportsType(inst.SupportedTypes, baseType)
  135. }
  136. func (s *PaymentService) RequestRefund(ctx context.Context, oid, uid int64, reason string) error {
  137. o, err := s.validateRefundRequest(ctx, oid, uid)
  138. if err != nil {
  139. return err
  140. }
  141. u, err := s.userRepo.GetByID(ctx, o.UserID)
  142. if err != nil {
  143. return fmt.Errorf("get user: %w", err)
  144. }
  145. if u.Balance < o.Amount {
  146. return infraerrors.BadRequest("BALANCE_NOT_ENOUGH", "refund amount exceeds balance")
  147. }
  148. nr := strings.TrimSpace(reason)
  149. now := time.Now()
  150. by := fmt.Sprintf("%d", uid)
  151. c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.UserIDEQ(uid), paymentorder.StatusEQ(OrderStatusCompleted), paymentorder.OrderTypeEQ(payment.OrderTypeBalance)).SetStatus(OrderStatusRefundRequested).SetRefundRequestedAt(now).SetRefundRequestReason(nr).SetRefundRequestedBy(by).SetRefundAmount(o.Amount).Save(ctx)
  152. if err != nil {
  153. return fmt.Errorf("update: %w", err)
  154. }
  155. if c == 0 {
  156. return infraerrors.Conflict("CONFLICT", "order status changed")
  157. }
  158. s.writeAuditLog(ctx, oid, "REFUND_REQUESTED", fmt.Sprintf("user:%d", uid), map[string]any{"amount": o.Amount, "reason": nr})
  159. return nil
  160. }
  161. func (s *PaymentService) validateRefundRequest(ctx context.Context, oid, uid int64) (*dbent.PaymentOrder, error) {
  162. o, err := s.entClient.PaymentOrder.Get(ctx, oid)
  163. if err != nil {
  164. return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
  165. }
  166. if o.UserID != uid {
  167. return nil, infraerrors.Forbidden("FORBIDDEN", "no permission")
  168. }
  169. if o.OrderType != payment.OrderTypeBalance {
  170. return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "only balance orders can request refund")
  171. }
  172. if o.Status != OrderStatusCompleted {
  173. return nil, infraerrors.BadRequest("INVALID_STATUS", "only completed orders can request refund")
  174. }
  175. // Check provider instance allows user refund
  176. inst, err := s.getRefundOrderProviderInstance(ctx, o)
  177. if err != nil || inst == nil {
  178. return nil, infraerrors.Forbidden("USER_REFUND_DISABLED", "refund is not available for this order")
  179. }
  180. if !inst.AllowUserRefund {
  181. return nil, infraerrors.Forbidden("USER_REFUND_DISABLED", "user refund is not enabled for this provider")
  182. }
  183. return o, nil
  184. }
  185. func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float64, reason string, force, deduct bool) (*RefundPlan, *RefundResult, error) {
  186. o, err := s.entClient.PaymentOrder.Get(ctx, oid)
  187. if err != nil {
  188. return nil, nil, infraerrors.NotFound("NOT_FOUND", "order not found")
  189. }
  190. ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, OrderStatusRefundFailed}
  191. if !psSliceContains(ok, o.Status) {
  192. return nil, nil, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund")
  193. }
  194. // Check provider instance allows admin refund
  195. inst, instErr := s.getRefundOrderProviderInstance(ctx, o)
  196. if instErr != nil {
  197. slog.Warn("refund: provider instance lookup failed", "orderID", oid, "error", instErr)
  198. return nil, nil, infraerrors.InternalServer("PROVIDER_LOOKUP_FAILED", "failed to look up payment provider for this order")
  199. }
  200. if inst == nil {
  201. // Legacy order without provider_instance_id — block refund
  202. return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not available for this order")
  203. }
  204. if !inst.RefundEnabled {
  205. return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not enabled for this provider")
  206. }
  207. if math.IsNaN(amt) || math.IsInf(amt, 0) {
  208. return nil, nil, infraerrors.BadRequest("INVALID_AMOUNT", "invalid refund amount")
  209. }
  210. if amt <= 0 {
  211. amt = o.Amount
  212. }
  213. orderCurrency := PaymentOrderCurrency(o)
  214. if amt-o.Amount > paymentAmountToleranceForCurrency(orderCurrency) {
  215. return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge")
  216. }
  217. ga := calculateGatewayRefundAmount(o.Amount, o.PayAmount, amt, orderCurrency)
  218. rr := strings.TrimSpace(reason)
  219. if rr == "" && o.RefundRequestReason != nil {
  220. rr = *o.RefundRequestReason
  221. }
  222. if rr == "" {
  223. rr = fmt.Sprintf("refund order:%d", o.ID)
  224. }
  225. p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct, DeductionType: payment.DeductionTypeNone}
  226. if deduct {
  227. if er := s.prepDeduct(ctx, o, p, force); er != nil {
  228. return nil, er, nil
  229. }
  230. }
  231. return p, nil, nil
  232. }
  233. func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
  234. if o.OrderType == payment.OrderTypeSubscription {
  235. p.DeductionType = payment.DeductionTypeSubscription
  236. if o.SubscriptionGroupID != nil && o.SubscriptionDays != nil {
  237. p.SubDaysToDeduct = *o.SubscriptionDays
  238. sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID)
  239. if err == nil && sub != nil {
  240. p.SubscriptionID = sub.ID
  241. } else if !force {
  242. return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}
  243. }
  244. }
  245. return nil
  246. }
  247. u, err := s.userRepo.GetByID(ctx, o.UserID)
  248. if err != nil {
  249. if !force {
  250. return &RefundResult{Success: false, Warning: "cannot fetch user balance, use force", RequireForce: true}
  251. }
  252. return nil
  253. }
  254. p.DeductionType = payment.DeductionTypeBalance
  255. if u.Balance < p.RefundAmount && !force {
  256. return &RefundResult{Success: false, Warning: "user balance is insufficient for deduction, use force", RequireForce: true}
  257. }
  258. p.BalanceToDeduct = math.Max(0, math.Min(p.RefundAmount, u.Balance))
  259. return nil
  260. }
  261. type availableBalanceDeductor interface {
  262. DeductAvailableBalance(ctx context.Context, id int64, amount float64) (float64, error)
  263. }
  264. func (s *PaymentService) deductAvailableBalance(ctx context.Context, userID int64, amount float64) (float64, error) {
  265. repo, ok := s.userRepo.(availableBalanceDeductor)
  266. if !ok {
  267. return 0, errors.New("user repository does not support available balance deduction")
  268. }
  269. return repo.DeductAvailableBalance(ctx, userID, amount)
  270. }
  271. func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*RefundResult, error) {
  272. c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx)
  273. if err != nil {
  274. return nil, fmt.Errorf("lock: %w", err)
  275. }
  276. if c == 0 {
  277. return nil, infraerrors.Conflict("CONFLICT", "order status changed")
  278. }
  279. if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
  280. // Skip balance deduction on retry if previous attempt already deducted
  281. // but failed to roll back (REFUND_ROLLBACK_FAILED in audit log).
  282. if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
  283. deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct)
  284. if err != nil {
  285. s.restoreStatus(ctx, p)
  286. return nil, fmt.Errorf("deduction: %w", err)
  287. }
  288. p.BalanceToDeduct = deducted
  289. } else {
  290. slog.Warn("skipping balance deduction on retry (previous rollback failed)", "orderID", p.OrderID)
  291. p.BalanceToDeduct = 0
  292. }
  293. }
  294. if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
  295. if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
  296. _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct)
  297. if err != nil {
  298. if errors.Is(err, ErrAdjustWouldExpire) {
  299. // Deduction would expire the subscription — revoke it entirely
  300. slog.Info("subscription deduction would expire, revoking", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct)
  301. if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
  302. s.restoreStatus(ctx, p)
  303. return nil, fmt.Errorf("revoke subscription: %w", revokeErr)
  304. }
  305. } else {
  306. // Other errors (DB failure, not found) — abort refund
  307. s.restoreStatus(ctx, p)
  308. return nil, fmt.Errorf("deduct subscription days: %w", err)
  309. }
  310. }
  311. } else {
  312. slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
  313. p.SubDaysToDeduct = 0
  314. }
  315. }
  316. resp, err := s.gwRefund(ctx, p)
  317. if err != nil {
  318. return s.handleGwFail(ctx, p, err)
  319. }
  320. return s.finishRefund(ctx, p, resp)
  321. }
  322. func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) (*payment.RefundResponse, error) {
  323. if p.Order.PaymentTradeNo == "" {
  324. s.writeAuditLog(ctx, p.Order.ID, "REFUND_NO_TRADE_NO", "admin", map[string]any{"detail": "skipped"})
  325. return &payment.RefundResponse{Status: payment.ProviderStatusSuccess}, nil
  326. }
  327. // Use the exact provider instance that created this order, not a random one
  328. // from the registry. Each instance has its own merchant credentials.
  329. prov, err := s.getRefundProvider(ctx, p.Order)
  330. if err != nil {
  331. return nil, fmt.Errorf("get refund provider: %w", err)
  332. }
  333. if err := validateProviderSnapshotMetadata(p.Order, prov.ProviderKey(), providerMerchantIdentityMetadata(prov)); err != nil {
  334. s.writeAuditLog(ctx, p.Order.ID, "REFUND_PROVIDER_METADATA_MISMATCH", "admin", map[string]any{
  335. "detail": err.Error(),
  336. })
  337. return nil, err
  338. }
  339. finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
  340. resp, err := prov.Refund(ctx, payment.RefundRequest{
  341. TradeNo: p.Order.PaymentTradeNo,
  342. OrderID: p.Order.OutTradeNo,
  343. Amount: formatGatewayRefundAmount(p.GatewayAmount, p.Order),
  344. Reason: p.Reason,
  345. })
  346. finishProviderCall()
  347. if err != nil {
  348. if resp != nil && strings.TrimSpace(resp.Status) == payment.ProviderStatusPending {
  349. return resp, nil
  350. }
  351. return nil, err
  352. }
  353. if err := validateRefundProviderResponse(resp); err != nil {
  354. return nil, err
  355. }
  356. return resp, nil
  357. }
  358. func formatGatewayRefundAmount(amount float64, order *dbent.PaymentOrder) string {
  359. return payment.FormatAmountForCurrency(amount, PaymentOrderCurrency(order))
  360. }
  361. func validateRefundProviderResponse(resp *payment.RefundResponse) error {
  362. if resp == nil {
  363. return fmt.Errorf("payment refund response missing")
  364. }
  365. status := strings.TrimSpace(resp.Status)
  366. switch status {
  367. case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded, payment.ProviderStatusPending:
  368. return nil
  369. case payment.ProviderStatusFailed:
  370. return fmt.Errorf("payment refund failed: status %s", status)
  371. default:
  372. return fmt.Errorf("payment refund returned unknown status: %s", status)
  373. }
  374. }
  375. func (s *PaymentService) finishRefund(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) {
  376. if err := validateRefundProviderResponse(resp); err != nil {
  377. return s.handleGwFail(ctx, p, err)
  378. }
  379. switch strings.TrimSpace(resp.Status) {
  380. case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded:
  381. return s.markRefundOk(ctx, p)
  382. case payment.ProviderStatusPending:
  383. return s.markRefundPending(ctx, p, resp)
  384. default:
  385. return s.handleGwFail(ctx, p, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status)))
  386. }
  387. }
  388. func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64) (*RefundResult, error) {
  389. o, err := s.entClient.PaymentOrder.Get(ctx, oid)
  390. if err != nil {
  391. return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
  392. }
  393. if o.Status != OrderStatusRefundPending {
  394. return nil, infraerrors.BadRequest("INVALID_STATUS", "only refund pending orders can be finalized")
  395. }
  396. prov, err := s.getRefundProvider(ctx, o)
  397. if err != nil {
  398. return nil, fmt.Errorf("get refund provider: %w", err)
  399. }
  400. queryProvider, ok := prov.(payment.RefundQueryProvider)
  401. if !ok {
  402. return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this payment provider does not support refund status query; please verify manually")
  403. }
  404. pendingDetail := s.latestRefundPendingDetail(ctx, oid)
  405. finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
  406. resp, err := queryProvider.QueryRefund(ctx, payment.RefundQueryRequest{
  407. TradeNo: o.PaymentTradeNo,
  408. OrderID: o.OutTradeNo,
  409. RefundID: pendingDetail.RefundID,
  410. Amount: formatGatewayRefundAmount(o.RefundAmount, o),
  411. })
  412. finishProviderCall()
  413. if err != nil {
  414. return nil, fmt.Errorf("query refund: %w", err)
  415. }
  416. if err := validateRefundProviderResponse(resp); err != nil {
  417. return s.finalizeRefundFailed(ctx, o, err)
  418. }
  419. plan := s.refundFinalizePlan(o)
  420. if !pendingDetail.DeductionRollbackOK {
  421. plan.BalanceToDeduct = 0
  422. plan.SubDaysToDeduct = 0
  423. } else if o.OrderType == payment.OrderTypeSubscription {
  424. if early := s.prepDeduct(ctx, o, plan, true); early != nil {
  425. return early, nil
  426. }
  427. }
  428. switch strings.TrimSpace(resp.Status) {
  429. case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded:
  430. return s.finalizePendingRefundSuccess(ctx, plan)
  431. case payment.ProviderStatusPending:
  432. s.writeAuditLog(ctx, oid, "REFUND_QUERY_PENDING", "admin", map[string]any{"refundID": resp.RefundID})
  433. return &RefundResult{Success: false, Warning: "gateway refund is still pending confirmation"}, nil
  434. default:
  435. return s.finalizeRefundFailed(ctx, o, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status)))
  436. }
  437. }
  438. func (s *PaymentService) finalizePendingRefundSuccess(ctx context.Context, p *RefundPlan) (_ *RefundResult, err error) {
  439. tx, err := s.entClient.Tx(ctx)
  440. if err != nil {
  441. return nil, fmt.Errorf("begin refund finalization: %w", err)
  442. }
  443. defer func() {
  444. if err != nil {
  445. _ = tx.Rollback()
  446. }
  447. }()
  448. txCtx := dbent.NewTxContext(ctx, tx)
  449. claimed, err := tx.PaymentOrder.Update().
  450. Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)).
  451. SetStatus(OrderStatusRefunding).
  452. Save(txCtx)
  453. if err != nil {
  454. return nil, fmt.Errorf("claim pending refund: %w", err)
  455. }
  456. if claimed == 0 {
  457. return nil, infraerrors.Conflict("CONFLICT", "order status changed")
  458. }
  459. if err := s.applyRefundFinalDeduction(txCtx, p); err != nil {
  460. return nil, err
  461. }
  462. result, err := s.markRefundOkTx(txCtx, tx.Client(), p)
  463. if err != nil {
  464. return nil, err
  465. }
  466. if err = tx.Commit(); err != nil {
  467. return nil, fmt.Errorf("commit refund finalization: %w", err)
  468. }
  469. return result, nil
  470. }
  471. func (s *PaymentService) refundFinalizePlan(o *dbent.PaymentOrder) *RefundPlan {
  472. refundAmount := o.RefundAmount
  473. reason := strings.TrimSpace(psStringValue(o.RefundReason))
  474. if reason == "" {
  475. reason = fmt.Sprintf("refund order:%d", o.ID)
  476. }
  477. return &RefundPlan{
  478. OrderID: o.ID,
  479. Order: o,
  480. RefundAmount: refundAmount,
  481. GatewayAmount: calculateGatewayRefundAmount(o.Amount, o.PayAmount, refundAmount, PaymentOrderCurrency(o)),
  482. Reason: reason,
  483. Force: o.ForceRefund,
  484. DeductBalance: true,
  485. DeductionType: payment.DeductionTypeBalance,
  486. BalanceToDeduct: func() float64 {
  487. if o.OrderType == payment.OrderTypeBalance {
  488. return refundAmount
  489. }
  490. return 0
  491. }(),
  492. }
  493. }
  494. func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *RefundPlan) error {
  495. if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
  496. deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct)
  497. if err != nil {
  498. return fmt.Errorf("deduction: %w", err)
  499. }
  500. p.BalanceToDeduct = deducted
  501. }
  502. if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
  503. if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct); err != nil {
  504. if errors.Is(err, ErrAdjustWouldExpire) {
  505. if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
  506. return fmt.Errorf("revoke subscription: %w", revokeErr)
  507. }
  508. } else {
  509. return fmt.Errorf("deduct subscription days: %w", err)
  510. }
  511. }
  512. }
  513. return nil
  514. }
  515. func (s *PaymentService) finalizeRefundFailed(ctx context.Context, o *dbent.PaymentOrder, gErr error) (*RefundResult, error) {
  516. now := time.Now()
  517. _, _ = s.entClient.PaymentOrder.UpdateOneID(o.ID).SetStatus(OrderStatusRefundFailed).SetFailedAt(now).SetFailedReason(psErrMsg(gErr)).Save(ctx)
  518. s.writeAuditLog(ctx, o.ID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
  519. return &RefundResult{Success: false, Warning: "gateway refund failed: " + psErrMsg(gErr)}, nil
  520. }
  521. type refundPendingAuditDetail struct {
  522. RefundID string `json:"refundID"`
  523. DeductionRollbackOK bool `json:"deductionRollbackOK"`
  524. }
  525. func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) refundPendingAuditDetail {
  526. logEntry, err := s.entClient.PaymentAuditLog.Query().
  527. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
  528. Order(paymentauditlog.ByCreatedAt(sql.OrderDesc())).
  529. First(ctx)
  530. if err != nil || logEntry == nil {
  531. return refundPendingAuditDetail{DeductionRollbackOK: true}
  532. }
  533. detail := refundPendingAuditDetail{DeductionRollbackOK: true}
  534. _ = json.Unmarshal([]byte(logEntry.Detail), &detail)
  535. detail.RefundID = strings.TrimSpace(detail.RefundID)
  536. return detail
  537. }
  538. // getRefundProvider creates a provider using the order's original instance config.
  539. // Delegates to getOrderProvider which handles instance lookup and fallback.
  540. func (s *PaymentService) getRefundProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) {
  541. inst, err := s.getRefundOrderProviderInstance(ctx, o)
  542. if err != nil {
  543. return nil, err
  544. }
  545. if inst == nil {
  546. return nil, fmt.Errorf("refund provider instance is unavailable for order %d", o.ID)
  547. }
  548. return s.createProviderFromInstance(ctx, inst)
  549. }
  550. func (s *PaymentService) handleGwFail(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
  551. if s.RollbackRefund(ctx, p, gErr) {
  552. s.restoreStatus(ctx, p)
  553. s.writeAuditLog(ctx, p.OrderID, "REFUND_GATEWAY_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
  554. return &RefundResult{Success: false, Warning: "gateway failed: " + psErrMsg(gErr) + ", rolled back"}, nil
  555. }
  556. now := time.Now()
  557. _, _ = s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(OrderStatusRefundFailed).SetFailedAt(now).SetFailedReason(psErrMsg(gErr)).Save(ctx)
  558. s.writeAuditLog(ctx, p.OrderID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
  559. return nil, infraerrors.InternalServer("REFUND_FAILED", psErrMsg(gErr))
  560. }
  561. func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*RefundResult, error) {
  562. fs := OrderStatusRefunded
  563. if p.RefundAmount < p.Order.Amount {
  564. fs = OrderStatusPartiallyRefunded
  565. }
  566. now := time.Now()
  567. _, err := s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(fs).SetRefundAmount(p.RefundAmount).SetRefundReason(p.Reason).SetRefundAt(now).SetForceRefund(p.Force).Save(ctx)
  568. if err != nil {
  569. return nil, fmt.Errorf("mark refund: %w", err)
  570. }
  571. s.writeAuditLog(ctx, p.OrderID, "REFUND_SUCCESS", "admin", map[string]any{"refundAmount": p.RefundAmount, "reason": p.Reason, "balanceDeducted": p.BalanceToDeduct, "force": p.Force})
  572. return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil
  573. }
  574. func (s *PaymentService) markRefundOkTx(ctx context.Context, client *dbent.Client, p *RefundPlan) (*RefundResult, error) {
  575. fs := OrderStatusRefunded
  576. if p.RefundAmount < p.Order.Amount {
  577. fs = OrderStatusPartiallyRefunded
  578. }
  579. now := time.Now()
  580. _, err := client.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(fs).SetRefundAmount(p.RefundAmount).SetRefundReason(p.Reason).SetRefundAt(now).SetForceRefund(p.Force).Save(ctx)
  581. if err != nil {
  582. return nil, fmt.Errorf("mark refund: %w", err)
  583. }
  584. detail, err := json.Marshal(map[string]any{"refundAmount": p.RefundAmount, "reason": p.Reason, "balanceDeducted": p.BalanceToDeduct, "force": p.Force})
  585. if err != nil {
  586. return nil, fmt.Errorf("marshal refund audit: %w", err)
  587. }
  588. if _, err := client.PaymentAuditLog.Create().
  589. SetOrderID(strconv.FormatInt(p.OrderID, 10)).
  590. SetAction("REFUND_SUCCESS").
  591. SetDetail(string(detail)).
  592. SetOperator("admin").
  593. Save(ctx); err != nil {
  594. return nil, fmt.Errorf("write refund audit: %w", err)
  595. }
  596. return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil
  597. }
  598. func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) {
  599. balanceDeducted := p.BalanceToDeduct
  600. subDaysDeducted := p.SubDaysToDeduct
  601. rollbackOK := s.RollbackRefund(ctx, p, nil)
  602. if rollbackOK {
  603. p.BalanceToDeduct = 0
  604. p.SubDaysToDeduct = 0
  605. }
  606. _, err := s.entClient.PaymentOrder.UpdateOneID(p.OrderID).
  607. SetStatus(OrderStatusRefundPending).
  608. SetRefundAmount(p.RefundAmount).
  609. SetRefundReason(p.Reason).
  610. ClearRefundAt().
  611. SetForceRefund(p.Force).
  612. ClearFailedAt().
  613. ClearFailedReason().
  614. Save(ctx)
  615. if err != nil {
  616. return nil, fmt.Errorf("mark refund pending: %w", err)
  617. }
  618. detail := map[string]any{
  619. "refundID": refundResponseID(resp),
  620. "refundAmount": p.RefundAmount,
  621. "reason": p.Reason,
  622. "force": p.Force,
  623. "balanceDeducted": p.BalanceToDeduct,
  624. "subDaysDeducted": p.SubDaysToDeduct,
  625. "balanceRolledBack": balanceDeducted,
  626. "subDaysRolledBack": subDaysDeducted,
  627. "deductionRollbackOK": rollbackOK,
  628. }
  629. s.writeAuditLog(ctx, p.OrderID, "REFUND_PENDING", "admin", detail)
  630. warning := "gateway refund is pending confirmation"
  631. if !rollbackOK {
  632. warning += "; refund deduction rollback failed"
  633. }
  634. return &RefundResult{Success: false, Warning: warning}, nil
  635. }
  636. func refundResponseID(resp *payment.RefundResponse) string {
  637. if resp == nil {
  638. return ""
  639. }
  640. return strings.TrimSpace(resp.RefundID)
  641. }
  642. func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr error) bool {
  643. if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
  644. if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
  645. slog.Error("[CRITICAL] rollback failed", "orderID", p.OrderID, "amount", p.BalanceToDeduct, "error", err)
  646. s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "balanceDeducted": p.BalanceToDeduct})
  647. return false
  648. }
  649. }
  650. if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
  651. if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil {
  652. slog.Error("[CRITICAL] subscription rollback failed", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct, "error", err)
  653. s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "subDaysDeducted": p.SubDaysToDeduct})
  654. return false
  655. }
  656. }
  657. return true
  658. }
  659. func (s *PaymentService) restoreStatus(ctx context.Context, p *RefundPlan) {
  660. rs := OrderStatusCompleted
  661. if p.Order.Status == OrderStatusRefundRequested {
  662. rs = OrderStatusRefundRequested
  663. }
  664. _, _ = s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(rs).Save(ctx)
  665. }