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.fallbackActionHint') }} -
- -- {{ t('admin.settings.betaPolicy.errorMessageHint') }} -
-- {{ 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') }} -
-- {{ t('admin.settings.gatewayForwarding.cchSigningHint') }} -
-- {{ t('admin.settings.site.tablePreferencesDescription') }} -
-- {{ t('admin.settings.site.tableDefaultPageSizeHint') }} -
-- {{ t('admin.settings.site.tablePageSizeOptionsHint') }} -
-