From b5b5e35757d7872ead634c28677765c15519a671 Mon Sep 17 00:00:00 2001 From: erio Date: Sat, 11 Apr 2026 13:24:17 +0800 Subject: [PATCH] refactor(payment): code standards fixes and file organization Cherry-picked from PR branch (feat/payment-system-v2). - Use payment.Type* constants instead of magic strings in factory - Add wxpay success response constants - Add validity unit constants (week/month) - Add constants for easypay popup mode - Split payment_order.go into payment_order_lifecycle.go - Extract shared order utilities to orderUtils.ts - Improve admin order components to use shared utilities - Removed duplicate migration (097 already exists) --- .../handler/payment_webhook_handler.go | 8 +- backend/internal/payment/provider/easypay.go | 6 +- backend/internal/payment/provider/factory.go | 8 +- backend/internal/service/payment_order.go | 232 ---------------- .../service/payment_order_lifecycle.go | 257 ++++++++++++++++++ backend/internal/service/payment_service.go | 10 +- .../admin/payment/AdminOrderDetail.vue | 16 +- .../admin/payment/AdminOrderTable.vue | 40 +-- .../admin/payment/AdminRefundDialog.vue | 4 +- frontend/src/components/payment/orderUtils.ts | 34 +++ frontend/src/i18n/locales/en.ts | 4 + frontend/src/i18n/locales/zh.ts | 4 + .../views/admin/orders/AdminOrdersView.vue | 3 +- frontend/src/views/user/SubscriptionsView.vue | 4 +- 14 files changed, 346 insertions(+), 284 deletions(-) create mode 100644 backend/internal/service/payment_order_lifecycle.go create mode 100644 frontend/src/components/payment/orderUtils.ts diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index 6d8540d80b..8a83bfebb7 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -137,13 +137,19 @@ type wxpaySuccessResponse struct { Message string `json:"message"` } +// WeChat Pay webhook success response constants. +const ( + wxpaySuccessCode = "SUCCESS" + wxpaySuccessMessage = "成功" +) + // writeSuccessResponse sends the provider-specific success response. // WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"}; // Stripe expects an empty 200; others accept plain text "success". func writeSuccessResponse(c *gin.Context, providerKey string) { switch providerKey { case payment.TypeWxpay: - c.JSON(http.StatusOK, wxpaySuccessResponse{Code: "SUCCESS", Message: "成功"}) + c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage}) case payment.TypeStripe: c.String(http.StatusOK, "") default: diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 08f73e290c..350a0f27cc 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -27,6 +27,8 @@ const ( maxEasypayResponseSize = 1 << 20 // 1MB tradeStatusSuccess = "TRADE_SUCCESS" signTypeMD5 = "MD5" + paymentModePopup = "popup" + deviceMobile = "mobile" ) // EasyPay implements payment.Provider for the EasyPay aggregation platform. @@ -61,7 +63,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 == "popup" { + if mode == paymentModePopup { return e.createRedirectPayment(req) } return e.createAPIPayment(ctx, req) @@ -109,7 +111,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen params["cid"] = cid } if req.IsMobile { - params["device"] = "mobile" + params["device"] = deviceMobile } params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign_type"] = signTypeMD5 diff --git a/backend/internal/payment/provider/factory.go b/backend/internal/payment/provider/factory.go index 397ca064ae..0adbd2676e 100644 --- a/backend/internal/payment/provider/factory.go +++ b/backend/internal/payment/provider/factory.go @@ -9,13 +9,13 @@ import ( // CreateProvider creates a Provider from a provider key, instance ID and decrypted config. func CreateProvider(providerKey string, instanceID string, config map[string]string) (payment.Provider, error) { switch providerKey { - case "easypay": + case payment.TypeEasyPay: return NewEasyPay(instanceID, config) - case "alipay": + case payment.TypeAlipay: return NewAlipay(instanceID, config) - case "wxpay": + case payment.TypeWxpay: return NewWxpay(instanceID, config) - case "stripe": + case payment.TypeStripe: return NewStripe(instanceID, config) default: return nil, fmt.Errorf("unknown provider key: %s", providerKey) diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 9de029e69d..2a952ece10 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -10,7 +10,6 @@ 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" @@ -170,68 +169,6 @@ 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 @@ -375,172 +312,3 @@ 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 { - auditAction := "ORDER_CANCELLED" - if fs == OrderStatusExpired { - auditAction = "ORDER_EXPIRED" - } - s.writeAuditLog(ctx, o.ID, auditAction, op, map[string]any{"detail": ad}) - } - return "cancelled", nil -} - -func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string { - prov, err := s.getOrderProvider(ctx, o) - 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 { - if err := s.HandlePaymentNotification(ctx, &payment.PaymentNotification{TradeNo: o.PaymentTradeNo, OrderID: o.OutTradeNo, Amount: resp.Amount, Status: payment.ProviderStatusSuccess}, prov.ProviderKey()); err != nil { - slog.Error("fulfillment failed during checkPaid", "orderID", o.ID, "error", err) - // Still return already_paid — order was paid, fulfillment can be retried - } - return "already_paid" - } - if cp, ok := prov.(payment.CancelableProvider); ok { - _ = cp.CancelPayment(ctx, tradeNo) - } - 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 -} - -// VerifyOrderPublic verifies payment status without user authentication. -// Used by the payment result page when the user's session has expired. -func (s *PaymentService) VerifyOrderPublic(ctx context.Context, outTradeNo string) (*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.Status == OrderStatusPending || o.Status == OrderStatusExpired { - result := s.checkPaid(ctx, o) - if result == "already_paid" { - 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 { - // Check upstream payment status before expiring — the user may have - // paid just before timeout and the webhook hasn't arrived yet. - outcome, _ := s.cancelCore(ctx, o, OrderStatusExpired, "system", "order expired") - if outcome == "already_paid" { - slog.Info("order was paid during expiry", "orderID", o.ID) - continue - } - if outcome != "" { - n++ - } - } - return n, nil -} - -// getOrderProvider creates a provider using the order's original instance config. -// Falls back to registry lookup if instance ID is missing (legacy orders). -func (s *PaymentService) getOrderProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) { - if o.ProviderInstanceID != nil && *o.ProviderInstanceID != "" { - instID, err := strconv.ParseInt(*o.ProviderInstanceID, 10, 64) - if err == nil { - cfg, err := s.loadBalancer.GetInstanceConfig(ctx, instID) - if err == nil { - providerKey := s.registry.GetProviderKey(o.PaymentType) - if providerKey == "" { - providerKey = o.PaymentType - } - p, err := provider.CreateProvider(providerKey, *o.ProviderInstanceID, cfg) - if err == nil { - return p, nil - } - } - } - } - s.EnsureProviders(ctx) - return s.registry.GetProvider(o.PaymentType) -} diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go new file mode 100644 index 0000000000..801471804c --- /dev/null +++ b/backend/internal/service/payment_order_lifecycle.go @@ -0,0 +1,257 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "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" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// --- Cancel & Expire --- + +// Cancel rate limit configuration constants. +const ( + rateLimitUnitDay = "day" + rateLimitUnitMinute = "minute" + rateLimitUnitHour = "hour" + rateLimitModeFixed = "fixed" + checkPaidResultAlreadyPaid = "already_paid" + checkPaidResultCancelled = "cancelled" +) + +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 = rateLimitUnitDay + } + if cfg.CancelRateLimitMode == rateLimitModeFixed { + switch unit { + case rateLimitUnitMinute: + t := now.Truncate(time.Minute) + return t.Add(-time.Duration(w-1) * time.Minute) + case rateLimitUnitDay: + 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 rateLimitUnitMinute: + return now.Add(-time.Duration(w) * time.Minute) + case rateLimitUnitDay: + return now.AddDate(0, 0, -w) + default: // hour + return now.Add(-time.Duration(w) * time.Hour) + } +} + +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) == checkPaidResultAlreadyPaid { + return checkPaidResultAlreadyPaid, 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 { + auditAction := "ORDER_CANCELLED" + if fs == OrderStatusExpired { + auditAction = "ORDER_EXPIRED" + } + s.writeAuditLog(ctx, o.ID, auditAction, op, map[string]any{"detail": ad}) + } + return checkPaidResultCancelled, nil +} + +func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string { + prov, err := s.getOrderProvider(ctx, o) + 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 { + if err := s.HandlePaymentNotification(ctx, &payment.PaymentNotification{TradeNo: o.PaymentTradeNo, OrderID: o.OutTradeNo, Amount: resp.Amount, Status: payment.ProviderStatusSuccess}, prov.ProviderKey()); err != nil { + slog.Error("fulfillment failed during checkPaid", "orderID", o.ID, "error", err) + // Still return already_paid — order was paid, fulfillment can be retried + } + return checkPaidResultAlreadyPaid + } + if cp, ok := prov.(payment.CancelableProvider); ok { + _ = cp.CancelPayment(ctx, tradeNo) + } + 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 == checkPaidResultAlreadyPaid { + // 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 +} + +// VerifyOrderPublic verifies payment status without user authentication. +// Used by the payment result page when the user's session has expired. +func (s *PaymentService) VerifyOrderPublic(ctx context.Context, outTradeNo string) (*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.Status == OrderStatusPending || o.Status == OrderStatusExpired { + result := s.checkPaid(ctx, o) + if result == checkPaidResultAlreadyPaid { + 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 { + // Check upstream payment status before expiring — the user may have + // paid just before timeout and the webhook hasn't arrived yet. + outcome, _ := s.cancelCore(ctx, o, OrderStatusExpired, "system", "order expired") + if outcome == checkPaidResultAlreadyPaid { + slog.Info("order was paid during expiry", "orderID", o.ID) + continue + } + if outcome != "" { + n++ + } + } + return n, nil +} + +// getOrderProvider creates a provider using the order's original instance config. +// Falls back to registry lookup if instance ID is missing (legacy orders). +func (s *PaymentService) getOrderProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) { + if o.ProviderInstanceID != nil && *o.ProviderInstanceID != "" { + instID, err := strconv.ParseInt(*o.ProviderInstanceID, 10, 64) + if err == nil { + cfg, err := s.loadBalancer.GetInstanceConfig(ctx, instID) + if err == nil { + providerKey := s.registry.GetProviderKey(o.PaymentType) + if providerKey == "" { + providerKey = o.PaymentType + } + p, err := provider.CreateProvider(providerKey, *o.ProviderInstanceID, cfg) + if err == nil { + return p, nil + } + } + } + } + s.EnsureProviders(ctx) + return s.registry.GetProvider(o.PaymentType) +} diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 25ec15f748..6d8b185e97 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -271,11 +271,17 @@ func psSliceContains(sl []string, s string) bool { return false } +// Subscription validity period unit constants. +const ( + validityUnitWeek = "week" + validityUnitMonth = "month" +) + func psComputeValidityDays(days int, unit string) int { switch unit { - case "week": + case validityUnitWeek: return days * 7 - case "month": + case validityUnitMonth: return days * 30 default: return days diff --git a/frontend/src/components/admin/payment/AdminOrderDetail.vue b/frontend/src/components/admin/payment/AdminOrderDetail.vue index b10bc76b55..de4b00b565 100644 --- a/frontend/src/components/admin/payment/AdminOrderDetail.vue +++ b/frontend/src/components/admin/payment/AdminOrderDetail.vue @@ -113,6 +113,7 @@ import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import type { PaymentOrder } from '@/types/payment' +import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } from '@/components/payment/orderUtils' const { t } = useI18n() @@ -128,22 +129,11 @@ const emit = defineEmits<{ (e: 'refund', order: PaymentOrder): void }>() -function statusBadgeClass(status: string): string { - const m: Record = { - PENDING: 'badge-warning', PAID: 'badge-info', RECHARGING: 'badge-info', - COMPLETED: 'badge-success', EXPIRED: 'badge-secondary', CANCELLED: 'badge-secondary', - FAILED: 'badge-danger', REFUND_REQUESTED: 'badge-warning', REFUNDING: 'badge-warning', - PARTIALLY_REFUNDED: 'badge-warning', REFUNDED: 'badge-info', REFUND_FAILED: 'badge-danger', - } - return m[status] || 'badge-secondary' -} - function canRefund(order: PaymentOrder): boolean { - return ['COMPLETED', 'PARTIALLY_REFUNDED', 'REFUND_REQUESTED', 'REFUND_FAILED'].includes(order.status) + return canRefundStatus(order.status) } function formatDateTime(dateStr: string): string { - if (!dateStr) return '-' - return new Date(dateStr).toLocaleString() + return formatOrderDateTime(dateStr) } diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index 61ffbfa818..0a22930e8d 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -108,7 +108,7 @@ {{ t('payment.admin.retry') }}