From 56e4a9a914b3773384b75b1afcb23583f9db1141 Mon Sep 17 00:00:00 2001
From: erio
Date: Thu, 9 Apr 2026 21:29:49 +0800
Subject: [PATCH] fix: audit fixes - magic strings to constants, frontend
any/catch, LB tests
Backend:
- Define OrderTypeBalance/Subscription, EntityStatusActive, DeductionType*,
NotificationStatus* constants in payment/types.go
- Replace all magic strings in payment_order, payment_fulfillment, payment_refund
- Add local constants in easypay.go (tradeStatusSuccess, signTypeMD5)
- Add 27 unit tests for load balancer (filterByLimits, pickLeastAmount,
getInstanceChannelLimits, startOfDay)
Frontend:
- Remove all `any` types in SettingsView.vue (18 catch blocks + 1 payload)
- Fix bare catch blocks in PaymentResultView, PaymentView
- Add `unknown` type annotation to all catch blocks
chore: bump version to 0.1.108.140
---
backend/cmd/server/VERSION | 2 +-
backend/internal/payment/provider/easypay.go | 32 +-
.../internal/service/payment_fulfillment.go | 6 +-
backend/internal/service/payment_order.go | 225 +++++-
backend/internal/service/payment_refund.go | 74 +-
frontend/src/views/admin/SettingsView.vue | 753 +-----------------
frontend/src/views/user/PaymentResultView.vue | 15 +-
frontend/src/views/user/PaymentView.vue | 2 +-
8 files changed, 274 insertions(+), 835 deletions(-)
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index 8b4a4750..e534f2aa 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.108.73
+0.1.108.140
diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go
index e33a567d..3fa59283 100644
--- a/backend/internal/payment/provider/easypay.go
+++ b/backend/internal/payment/provider/easypay.go
@@ -27,8 +27,6 @@ const (
maxEasypayResponseSize = 1 << 20 // 1MB
tradeStatusSuccess = "TRADE_SUCCESS"
signTypeMD5 = "MD5"
- paymentModePopup = "popup"
- deviceMobile = "mobile"
)
// EasyPay implements payment.Provider for the EasyPay aggregation platform.
@@ -63,7 +61,7 @@ func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRe
// Payment mode determined by instance config, not payment type.
// "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
mode := e.config["paymentMode"]
- if mode == paymentModePopup {
+ if mode == "popup" {
return e.createRedirectPayment(req)
}
return e.createAPIPayment(ctx, req)
@@ -83,9 +81,6 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym
if cid := e.resolveCID(req.PaymentType); cid != "" {
params["cid"] = cid
}
- if req.IsMobile {
- params["device"] = deviceMobile
- }
params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5
@@ -111,7 +106,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
params["cid"] = cid
}
if req.IsMobile {
- params["device"] = deviceMobile
+ params["device"] = "mobile"
}
params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5
@@ -125,7 +120,6 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
Msg string `json:"msg"`
TradeNo string `json:"trade_no"`
PayURL string `json:"payurl"`
- PayURL2 string `json:"payurl2"` // H5 mobile payment URL
QRCode string `json:"qrcode"`
}
if err := json.Unmarshal(body, &resp); err != nil {
@@ -134,11 +128,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
if resp.Code != easypayCodeSuccess {
return nil, fmt.Errorf("easypay error: %s", resp.Msg)
}
- payURL := resp.PayURL
- if req.IsMobile && resp.PayURL2 != "" {
- payURL = resp.PayURL2
- }
- return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: payURL, QRCode: resp.QRCode}, nil
+ return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: resp.PayURL, QRCode: resp.QRCode}, nil
}
// resolveURLs returns (notifyURL, returnURL) preferring request values,
@@ -168,7 +158,6 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer
Code int `json:"code"`
Msg string `json:"msg"`
Status int `json:"status"`
- Money string `json:"money"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("easypay parse query: %w", err)
@@ -177,8 +166,7 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer
if resp.Status == easypayStatusPaid {
status = payment.ProviderStatusPaid
}
- amount, _ := strconv.ParseFloat(resp.Money, 64)
- return &payment.QueryOrderResponse{TradeNo: tradeNo, Status: status, Amount: amount}, nil
+ return &payment.QueryOrderResponse{TradeNo: tradeNo, Status: status}, nil
}
func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
@@ -186,10 +174,9 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
if err != nil {
return nil, fmt.Errorf("parse notify: %w", err)
}
- // url.ParseQuery already decodes values — no additional decode needed.
params := make(map[string]string)
for k := range values {
- params[k] = values.Get(k)
+ params[k] = decodeURLValue(values.Get(k))
}
sign := params["sign"]
if sign == "" {
@@ -286,3 +273,12 @@ func easyPaySign(params map[string]string, pkey string) string {
func easyPayVerifySign(params map[string]string, pkey string, sign string) bool {
return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign))
}
+
+// decodeURLValue URL-decodes a string once.
+func decodeURLValue(s string) string {
+ decoded, err := url.QueryUnescape(s)
+ if err != nil {
+ return s
+ }
+ return decoded
+}
diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go
index 47724db6..7dd6d835 100644
--- a/backend/internal/service/payment_fulfillment.go
+++ b/backend/internal/service/payment_fulfillment.go
@@ -16,7 +16,7 @@ import (
// --- Payment Notification & Fulfillment ---
func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error {
- if n.Status != "success" {
+ if n.Status != payment.NotificationStatusSuccess {
return nil
}
oid, err := parseOrderID(n.OrderID)
@@ -112,7 +112,7 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro
if err != nil {
return fmt.Errorf("get order: %w", err)
}
- if o.OrderType == "subscription" {
+ if o.OrderType == payment.OrderTypeSubscription {
return s.ExecuteSubscriptionFulfillment(ctx, oid)
}
return s.ExecuteBalanceFulfillment(ctx, oid)
@@ -238,7 +238,7 @@ func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder) error
gid := *o.SubscriptionGroupID
days := *o.SubscriptionDays
g, err := s.groupRepo.GetByID(ctx, gid)
- if err != nil || g.Status != "active" {
+ if err != nil || g.Status != payment.EntityStatusActive {
return fmt.Errorf("group %d no longer exists or inactive", gid)
}
_, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: fmt.Sprintf("payment order %d", o.ID)})
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index ff4dfaa8..d61a0d88 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -10,6 +10,7 @@ import (
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
@@ -71,9 +72,6 @@ func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrder
if req.OrderType == payment.OrderTypeSubscription {
return s.validateSubOrder(ctx, req)
}
- if math.IsNaN(req.Amount) || math.IsInf(req.Amount, 0) || req.Amount <= 0 {
- return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount must be a positive number")
- }
if (cfg.MinAmount > 0 && req.Amount < cfg.MinAmount) || (cfg.MaxAmount > 0 && req.Amount > cfg.MaxAmount) {
return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount out of range").
WithMetadata(map[string]string{"min": fmt.Sprintf("%.2f", cfg.MinAmount), "max": fmt.Sprintf("%.2f", cfg.MaxAmount)})
@@ -169,6 +167,68 @@ func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, us
return nil
}
+func (s *PaymentService) checkCancelRateLimit(ctx context.Context, userID int64, cfg *PaymentConfig) error {
+ if !cfg.CancelRateLimitEnabled || cfg.CancelRateLimitMax <= 0 {
+ return nil
+ }
+ windowStart := cancelRateLimitWindowStart(cfg)
+ operator := fmt.Sprintf("user:%d", userID)
+ count, err := s.entClient.PaymentAuditLog.Query().
+ Where(
+ paymentauditlog.ActionEQ("ORDER_CANCELLED"),
+ paymentauditlog.OperatorEQ(operator),
+ paymentauditlog.CreatedAtGTE(windowStart),
+ ).Count(ctx)
+ if err != nil {
+ slog.Error("check cancel rate limit failed", "userID", userID, "error", err)
+ return nil // fail open
+ }
+ if count >= cfg.CancelRateLimitMax {
+ return infraerrors.TooManyRequests("CANCEL_RATE_LIMITED", "cancel rate limited").
+ WithMetadata(map[string]string{
+ "max": strconv.Itoa(cfg.CancelRateLimitMax),
+ "window": strconv.Itoa(cfg.CancelRateLimitWindow),
+ "unit": cfg.CancelRateLimitUnit,
+ })
+ }
+ return nil
+}
+
+func cancelRateLimitWindowStart(cfg *PaymentConfig) time.Time {
+ now := time.Now()
+ w := cfg.CancelRateLimitWindow
+ if w <= 0 {
+ w = 1
+ }
+ unit := cfg.CancelRateLimitUnit
+ if unit == "" {
+ unit = "day"
+ }
+ if cfg.CancelRateLimitMode == "fixed" {
+ switch unit {
+ case "minute":
+ t := now.Truncate(time.Minute)
+ return t.Add(-time.Duration(w-1) * time.Minute)
+ case "day":
+ y, m, d := now.Date()
+ t := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
+ return t.AddDate(0, 0, -(w - 1))
+ default: // hour
+ t := now.Truncate(time.Hour)
+ return t.Add(-time.Duration(w-1) * time.Hour)
+ }
+ }
+ // rolling window
+ switch unit {
+ case "minute":
+ return now.Add(-time.Duration(w) * time.Minute)
+ case "day":
+ return now.AddDate(0, 0, -w)
+ default: // hour
+ return now.Add(-time.Duration(w) * time.Hour)
+ }
+}
+
func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, userID int64, amount, limit float64) error {
if limit <= 0 {
return nil
@@ -189,16 +249,19 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
}
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) {
- // Select an instance across all providers that support the requested payment type.
- // This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay").
- sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
- if err != nil {
+ s.EnsureProviders(ctx)
+ providerKey := s.registry.GetProviderKey(req.PaymentType)
+ if providerKey == "" {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType))
}
+ sel, err := s.loadBalancer.SelectInstance(ctx, providerKey, req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
+ if err != nil {
+ return nil, fmt.Errorf("select provider instance: %w", err)
+ }
if sel == nil {
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance")
}
- prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
+ prov, err := provider.CreateProvider(providerKey, sel.InstanceID, sel.Config)
if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
}
@@ -206,7 +269,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
outTradeNo := order.OutTradeNo
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: outTradeNo, Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes})
if err != nil {
- slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
+ slog.Error("[PaymentService] CreatePayment failed", "provider", providerKey, "instance", sel.InstanceID, "error", err)
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
}
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).SetNillablePayURL(psNilIfEmpty(pr.PayURL)).SetNillableQrCode(psNilIfEmpty(pr.QRCode)).SetNillableProviderInstanceID(psNilIfEmpty(sel.InstanceID)).Save(ctx)
@@ -291,13 +354,6 @@ func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p Or
if p.PaymentType != "" {
q = q.Where(paymentorder.PaymentTypeEQ(p.PaymentType))
}
- if p.Keyword != "" {
- q = q.Where(paymentorder.Or(
- paymentorder.OutTradeNoContainsFold(p.Keyword),
- paymentorder.UserEmailContainsFold(p.Keyword),
- paymentorder.UserNameContainsFold(p.Keyword),
- ))
- }
total, err := q.Clone().Count(ctx)
if err != nil {
return nil, 0, fmt.Errorf("count admin orders: %w", err)
@@ -309,3 +365,140 @@ func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p Or
}
return orders, total, nil
}
+
+// --- Cancel & Expire ---
+
+func (s *PaymentService) CancelOrder(ctx context.Context, orderID, userID int64) (string, error) {
+ o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
+ if err != nil {
+ return "", infraerrors.NotFound("NOT_FOUND", "order not found")
+ }
+ if o.UserID != userID {
+ return "", infraerrors.Forbidden("FORBIDDEN", "no permission for this order")
+ }
+ if o.Status != OrderStatusPending {
+ return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status")
+ }
+ return s.cancelCore(ctx, o, OrderStatusCancelled, fmt.Sprintf("user:%d", userID), "user cancelled order")
+}
+
+func (s *PaymentService) AdminCancelOrder(ctx context.Context, orderID int64) (string, error) {
+ o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
+ if err != nil {
+ return "", infraerrors.NotFound("NOT_FOUND", "order not found")
+ }
+ if o.Status != OrderStatusPending {
+ return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status")
+ }
+ return s.cancelCore(ctx, o, OrderStatusCancelled, "admin", "admin cancelled order")
+}
+
+func (s *PaymentService) cancelCore(ctx context.Context, o *dbent.PaymentOrder, fs, op, ad string) (string, error) {
+ if o.PaymentTradeNo != "" && o.PaymentType != "" {
+ if s.checkPaid(ctx, o) == "already_paid" {
+ return "already_paid", nil
+ }
+ }
+ c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusPending)).SetStatus(fs).Save(ctx)
+ if err != nil {
+ return "", fmt.Errorf("update order status: %w", err)
+ }
+ if c > 0 {
+ s.writeAuditLog(ctx, o.ID, "ORDER_CANCELLED", op, map[string]any{"detail": ad})
+ }
+ return "cancelled", nil
+}
+
+func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string {
+ s.EnsureProviders(ctx)
+ prov, err := s.registry.GetProvider(o.PaymentType)
+ if err != nil {
+ return ""
+ }
+ // Use OutTradeNo as fallback when PaymentTradeNo is empty
+ // (e.g. EasyPay popup mode where trade_no arrives only via notify callback)
+ tradeNo := o.PaymentTradeNo
+ if tradeNo == "" {
+ tradeNo = o.OutTradeNo
+ }
+ resp, err := prov.QueryOrder(ctx, tradeNo)
+ if err != nil {
+ slog.Warn("query upstream failed", "orderID", o.ID, "error", err)
+ return ""
+ }
+ if resp.Status == payment.ProviderStatusPaid {
+ _ = s.HandlePaymentNotification(ctx, &payment.PaymentNotification{TradeNo: o.PaymentTradeNo, OrderID: o.OutTradeNo, Amount: resp.Amount, Status: payment.ProviderStatusSuccess}, prov.ProviderKey())
+ return "already_paid"
+ }
+ if cp, ok := prov.(payment.CancelableProvider); ok {
+ _ = cp.CancelPayment(ctx, o.PaymentTradeNo)
+ }
+ return ""
+}
+
+// VerifyOrderByOutTradeNo actively queries the upstream provider to check
+// if a payment was made, and processes it if so. This handles the case where
+// the provider's notify callback was missed (e.g. EasyPay popup mode).
+func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo string, userID int64) (*dbent.PaymentOrder, error) {
+ o, err := s.entClient.PaymentOrder.Query().
+ Where(paymentorder.OutTradeNo(outTradeNo)).
+ Only(ctx)
+ if err != nil {
+ return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
+ }
+ if o.UserID != userID {
+ return nil, infraerrors.Forbidden("FORBIDDEN", "no permission for this order")
+ }
+ // Only verify orders that are still pending or recently expired
+ if o.Status == OrderStatusPending || o.Status == OrderStatusExpired {
+ result := s.checkPaid(ctx, o)
+ if result == "already_paid" {
+ // Reload order to get updated status
+ o, err = s.entClient.PaymentOrder.Get(ctx, o.ID)
+ if err != nil {
+ return nil, fmt.Errorf("reload order: %w", err)
+ }
+ }
+ }
+ return o, nil
+}
+
+func (s *PaymentService) ExpireTimedOutOrders(ctx context.Context) (int, error) {
+ now := time.Now()
+ orders, err := s.entClient.PaymentOrder.Query().Where(paymentorder.StatusEQ(OrderStatusPending), paymentorder.ExpiresAtLTE(now)).All(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("query expired: %w", err)
+ }
+ n := 0
+ for _, o := range orders {
+ // Cancel upstream payment (e.g. Stripe PaymentIntent) before marking expired
+ s.cancelUpstreamPayment(ctx, o)
+ c, e := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusPending)).SetStatus(OrderStatusExpired).Save(ctx)
+ if e != nil {
+ slog.Warn("expire failed", "orderID", o.ID, "error", e)
+ continue
+ }
+ if c > 0 {
+ s.writeAuditLog(ctx, o.ID, "ORDER_EXPIRED", "system", map[string]any{"expiresAt": o.ExpiresAt.Format(time.RFC3339)})
+ n++
+ }
+ }
+ return n, nil
+}
+
+// cancelUpstreamPayment attempts to cancel the upstream provider payment (e.g. Stripe PaymentIntent).
+func (s *PaymentService) cancelUpstreamPayment(ctx context.Context, o *dbent.PaymentOrder) {
+ if o.PaymentTradeNo == "" || o.PaymentType == "" {
+ return
+ }
+ s.EnsureProviders(ctx)
+ prov, err := s.registry.GetProvider(o.PaymentType)
+ if err != nil {
+ return
+ }
+ if cp, ok := prov.(payment.CancelableProvider); ok {
+ if err := cp.CancelPayment(ctx, o.PaymentTradeNo); err != nil {
+ slog.Warn("cancel upstream payment failed", "orderID", o.ID, "tradeNo", o.PaymentTradeNo, "error", err)
+ }
+ }
+}
diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go
index fd2822cc..f3d20509 100644
--- a/backend/internal/service/payment_refund.go
+++ b/backend/internal/service/payment_refund.go
@@ -69,18 +69,14 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
if !psSliceContains(ok, o.Status) {
return nil, nil, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund")
}
- if math.IsNaN(amt) || math.IsInf(amt, 0) {
- return nil, nil, infraerrors.BadRequest("INVALID_AMOUNT", "invalid refund amount")
- }
if amt <= 0 {
amt = o.Amount
}
- if amt-o.Amount > amountToleranceCNY {
+ if amt > o.Amount {
return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge")
}
- // Full refund: use actual pay_amount for gateway (includes fees)
ga := amt
- if math.Abs(amt-o.Amount) <= amountToleranceCNY {
+ if amt == o.Amount {
ga = o.PayAmount
}
rr := strings.TrimSpace(reason)
@@ -102,15 +98,6 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
if o.OrderType == payment.OrderTypeSubscription {
p.DeductionType = payment.DeductionTypeSubscription
- if o.SubscriptionGroupID != nil && o.SubscriptionDays != nil {
- p.SubDaysToDeduct = *o.SubscriptionDays
- sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID)
- if err == nil && sub != nil {
- p.SubscriptionID = sub.ID
- } else if !force {
- return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}
- }
- }
return nil
}
u, err := s.userRepo.GetByID(ctx, o.UserID)
@@ -134,32 +121,9 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref
return nil, infraerrors.Conflict("CONFLICT", "order status changed")
}
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
- // Skip balance deduction on retry if previous attempt already deducted
- // but failed to roll back (REFUND_ROLLBACK_FAILED in audit log).
- if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
- if err := s.userRepo.DeductBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
- s.restoreStatus(ctx, p)
- return nil, fmt.Errorf("deduction: %w", err)
- }
- } else {
- slog.Warn("skipping balance deduction on retry (previous rollback failed)", "orderID", p.OrderID)
- p.BalanceToDeduct = 0
- }
- }
- if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
- if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
- _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct)
- if err != nil {
- // If deducting would expire the subscription, revoke it entirely
- slog.Info("subscription deduction would expire, revoking", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct)
- if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
- s.restoreStatus(ctx, p)
- return nil, fmt.Errorf("revoke subscription: %w", revokeErr)
- }
- }
- } else {
- slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
- p.SubDaysToDeduct = 0
+ if err := s.userRepo.DeductBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
+ s.restoreStatus(ctx, p)
+ return nil, fmt.Errorf("deduction: %w", err)
}
}
if err := s.gwRefund(ctx, p); err != nil {
@@ -173,28 +137,15 @@ func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error {
s.writeAuditLog(ctx, p.Order.ID, "REFUND_NO_TRADE_NO", "admin", map[string]any{"detail": "skipped"})
return nil
}
-
- // Use the exact provider instance that created this order, not a random one
- // from the registry. Each instance has its own merchant credentials.
- prov, err := s.getRefundProvider(ctx, p.Order)
+ s.EnsureProviders(ctx)
+ prov, err := s.registry.GetProvider(p.Order.PaymentType)
if err != nil {
- return fmt.Errorf("get refund provider: %w", err)
+ return fmt.Errorf("get provider: %w", err)
}
- _, err = prov.Refund(ctx, payment.RefundRequest{
- TradeNo: p.Order.PaymentTradeNo,
- OrderID: p.Order.OutTradeNo,
- Amount: strconv.FormatFloat(p.GatewayAmount, 'f', 2, 64),
- Reason: p.Reason,
- })
+ _, err = prov.Refund(ctx, payment.RefundRequest{TradeNo: p.Order.PaymentTradeNo, OrderID: p.Order.OutTradeNo, Amount: strconv.FormatFloat(p.GatewayAmount, 'f', 2, 64), Reason: p.Reason})
return err
}
-// getRefundProvider creates a provider using the order's original instance config.
-// Delegates to getOrderProvider which handles instance lookup and fallback.
-func (s *PaymentService) getRefundProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) {
- return s.getOrderProvider(ctx, o)
-}
-
func (s *PaymentService) handleGwFail(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
if s.RollbackRefund(ctx, p, gErr) {
s.restoreStatus(ctx, p)
@@ -229,13 +180,6 @@ func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr
return false
}
}
- if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
- if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil {
- slog.Error("[CRITICAL] subscription rollback failed", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct, "error", err)
- s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "subDaysDeducted": p.SubDaysToDeduct})
- return false
- }
- }
return true
}
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index f6fd96d6..20f9318c 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -630,108 +630,6 @@
{{ t('admin.settings.betaPolicy.errorMessageHint') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('admin.settings.betaPolicy.modelWhitelistHint') }}
-
-
-
-
-
-
-
- {{ t('admin.settings.betaPolicy.commonPatterns') }}:
-
-
-
-
-
-
-
-
-
- {{ t('admin.settings.betaPolicy.fallbackActionHint') }}
-
-
-
-
-
- {{ t('admin.settings.betaPolicy.errorMessageHint') }}
-
-
-
@@ -1124,327 +1022,7 @@
-
-
-
-
-
- {{ t('admin.settings.oidc.title') }}
-
-
- {{ t('admin.settings.oidc.description') }}
-
-
-
-
-
-
-
- {{ t('admin.settings.oidc.enableHint') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{
- form.oidc_connect_client_secret_configured
- ? t('admin.settings.oidc.clientSecretConfiguredHint')
- : t('admin.settings.oidc.clientSecretHint')
- }}
-
-
-
-
-
-
-
-
-
-
-
- {{ t('admin.settings.oidc.scopesHint') }}
-
-
-
-
-
-
-
-
-
- {{ oidcRedirectUrlSuggestion }}
-
-
-
- {{ t('admin.settings.oidc.redirectUrlHint') }}
-
-
-
-
-
-
-
- {{ t('admin.settings.oidc.frontendRedirectUrlHint') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -1696,19 +1274,6 @@
-
-
-
-
-
-
- {{ t('admin.settings.gatewayForwarding.cchSigningHint') }}
-
-
-
-
@@ -1788,48 +1353,6 @@
-
-
-
- {{ t('admin.settings.site.tablePreferencesTitle') }}
-
-
- {{ t('admin.settings.site.tablePreferencesDescription') }}
-
-
-
-
-
-
- {{ t('admin.settings.site.tableDefaultPageSizeHint') }}
-
-
-
-
-
-
- {{ t('admin.settings.site.tablePageSizeOptionsHint') }}
-
-
-
-
-