"backend/internal/vscode:/vscode.git/clone" did not exist on "538ae31ade38e1c7c1e7877b70e4822897f4d80b"
Commit 56e4a9a9 authored by erio's avatar erio
Browse files

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
parent 3c884f8e
...@@ -27,8 +27,6 @@ const ( ...@@ -27,8 +27,6 @@ const (
maxEasypayResponseSize = 1 << 20 // 1MB maxEasypayResponseSize = 1 << 20 // 1MB
tradeStatusSuccess = "TRADE_SUCCESS" tradeStatusSuccess = "TRADE_SUCCESS"
signTypeMD5 = "MD5" signTypeMD5 = "MD5"
paymentModePopup = "popup"
deviceMobile = "mobile"
) )
// EasyPay implements payment.Provider for the EasyPay aggregation platform. // EasyPay implements payment.Provider for the EasyPay aggregation platform.
...@@ -63,7 +61,7 @@ func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRe ...@@ -63,7 +61,7 @@ func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRe
// Payment mode determined by instance config, not payment type. // Payment mode determined by instance config, not payment type.
// "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php). // "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
mode := e.config["paymentMode"] mode := e.config["paymentMode"]
if mode == paymentModePopup { if mode == "popup" {
return e.createRedirectPayment(req) return e.createRedirectPayment(req)
} }
return e.createAPIPayment(ctx, req) return e.createAPIPayment(ctx, req)
...@@ -83,9 +81,6 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym ...@@ -83,9 +81,6 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym
if cid := e.resolveCID(req.PaymentType); cid != "" { if cid := e.resolveCID(req.PaymentType); cid != "" {
params["cid"] = cid params["cid"] = cid
} }
if req.IsMobile {
params["device"] = deviceMobile
}
params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5 params["sign_type"] = signTypeMD5
...@@ -111,7 +106,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen ...@@ -111,7 +106,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
params["cid"] = cid params["cid"] = cid
} }
if req.IsMobile { if req.IsMobile {
params["device"] = deviceMobile params["device"] = "mobile"
} }
params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5 params["sign_type"] = signTypeMD5
...@@ -125,7 +120,6 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen ...@@ -125,7 +120,6 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
Msg string `json:"msg"` Msg string `json:"msg"`
TradeNo string `json:"trade_no"` TradeNo string `json:"trade_no"`
PayURL string `json:"payurl"` PayURL string `json:"payurl"`
PayURL2 string `json:"payurl2"` // H5 mobile payment URL
QRCode string `json:"qrcode"` QRCode string `json:"qrcode"`
} }
if err := json.Unmarshal(body, &resp); err != nil { if err := json.Unmarshal(body, &resp); err != nil {
...@@ -134,11 +128,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen ...@@ -134,11 +128,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
if resp.Code != easypayCodeSuccess { if resp.Code != easypayCodeSuccess {
return nil, fmt.Errorf("easypay error: %s", resp.Msg) return nil, fmt.Errorf("easypay error: %s", resp.Msg)
} }
payURL := resp.PayURL return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: resp.PayURL, QRCode: resp.QRCode}, nil
if req.IsMobile && resp.PayURL2 != "" {
payURL = resp.PayURL2
}
return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: payURL, QRCode: resp.QRCode}, nil
} }
// resolveURLs returns (notifyURL, returnURL) preferring request values, // resolveURLs returns (notifyURL, returnURL) preferring request values,
...@@ -168,7 +158,6 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer ...@@ -168,7 +158,6 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer
Code int `json:"code"` Code int `json:"code"`
Msg string `json:"msg"` Msg string `json:"msg"`
Status int `json:"status"` Status int `json:"status"`
Money string `json:"money"`
} }
if err := json.Unmarshal(body, &resp); err != nil { if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("easypay parse query: %w", err) return nil, fmt.Errorf("easypay parse query: %w", err)
...@@ -177,8 +166,7 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer ...@@ -177,8 +166,7 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer
if resp.Status == easypayStatusPaid { if resp.Status == easypayStatusPaid {
status = payment.ProviderStatusPaid status = payment.ProviderStatusPaid
} }
amount, _ := strconv.ParseFloat(resp.Money, 64) return &payment.QueryOrderResponse{TradeNo: tradeNo, Status: status}, nil
return &payment.QueryOrderResponse{TradeNo: tradeNo, Status: status, Amount: amount}, nil
} }
func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) { 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 ...@@ -186,10 +174,9 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
if err != nil { if err != nil {
return nil, fmt.Errorf("parse notify: %w", err) return nil, fmt.Errorf("parse notify: %w", err)
} }
// url.ParseQuery already decodes values — no additional decode needed.
params := make(map[string]string) params := make(map[string]string)
for k := range values { for k := range values {
params[k] = values.Get(k) params[k] = decodeURLValue(values.Get(k))
} }
sign := params["sign"] sign := params["sign"]
if sign == "" { if sign == "" {
...@@ -286,3 +273,12 @@ func easyPaySign(params map[string]string, pkey string) string { ...@@ -286,3 +273,12 @@ func easyPaySign(params map[string]string, pkey string) string {
func easyPayVerifySign(params map[string]string, pkey string, sign string) bool { func easyPayVerifySign(params map[string]string, pkey string, sign string) bool {
return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign)) 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
}
...@@ -16,7 +16,7 @@ import ( ...@@ -16,7 +16,7 @@ import (
// --- Payment Notification & Fulfillment --- // --- Payment Notification & Fulfillment ---
func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error { func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error {
if n.Status != "success" { if n.Status != payment.NotificationStatusSuccess {
return nil return nil
} }
oid, err := parseOrderID(n.OrderID) oid, err := parseOrderID(n.OrderID)
...@@ -112,7 +112,7 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro ...@@ -112,7 +112,7 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro
if err != nil { if err != nil {
return fmt.Errorf("get order: %w", err) return fmt.Errorf("get order: %w", err)
} }
if o.OrderType == "subscription" { if o.OrderType == payment.OrderTypeSubscription {
return s.ExecuteSubscriptionFulfillment(ctx, oid) return s.ExecuteSubscriptionFulfillment(ctx, oid)
} }
return s.ExecuteBalanceFulfillment(ctx, oid) return s.ExecuteBalanceFulfillment(ctx, oid)
...@@ -238,7 +238,7 @@ func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder) error ...@@ -238,7 +238,7 @@ func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder) error
gid := *o.SubscriptionGroupID gid := *o.SubscriptionGroupID
days := *o.SubscriptionDays days := *o.SubscriptionDays
g, err := s.groupRepo.GetByID(ctx, gid) 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) 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)}) _, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: fmt.Sprintf("payment order %d", o.ID)})
......
...@@ -10,6 +10,7 @@ import ( ...@@ -10,6 +10,7 @@ import (
"time" "time"
dbent "github.com/Wei-Shaw/sub2api/ent" 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/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/payment/provider" "github.com/Wei-Shaw/sub2api/internal/payment/provider"
...@@ -71,9 +72,6 @@ func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrder ...@@ -71,9 +72,6 @@ func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrder
if req.OrderType == payment.OrderTypeSubscription { if req.OrderType == payment.OrderTypeSubscription {
return s.validateSubOrder(ctx, req) 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) { 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"). 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)}) 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 ...@@ -169,6 +167,68 @@ func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, us
return nil 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 { func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, userID int64, amount, limit float64) error {
if limit <= 0 { if limit <= 0 {
return nil return nil
...@@ -189,16 +249,19 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user ...@@ -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) { 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. s.EnsureProviders(ctx)
// This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay"). providerKey := s.registry.GetProviderKey(req.PaymentType)
sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount) if providerKey == "" {
if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType)) 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 { if sel == nil {
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance") 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 { if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable") 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 ...@@ -206,7 +269,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
outTradeNo := order.OutTradeNo 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}) 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 { 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())) 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) _, 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 ...@@ -291,13 +354,6 @@ func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p Or
if p.PaymentType != "" { if p.PaymentType != "" {
q = q.Where(paymentorder.PaymentTypeEQ(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) total, err := q.Clone().Count(ctx)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("count admin orders: %w", err) 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 ...@@ -309,3 +365,140 @@ func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p Or
} }
return orders, total, nil 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)
}
}
}
...@@ -69,18 +69,14 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float ...@@ -69,18 +69,14 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
if !psSliceContains(ok, o.Status) { if !psSliceContains(ok, o.Status) {
return nil, nil, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund") 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 { if amt <= 0 {
amt = o.Amount amt = o.Amount
} }
if amt-o.Amount > amountToleranceCNY { if amt > o.Amount {
return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge") return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge")
} }
// Full refund: use actual pay_amount for gateway (includes fees)
ga := amt ga := amt
if math.Abs(amt-o.Amount) <= amountToleranceCNY { if amt == o.Amount {
ga = o.PayAmount ga = o.PayAmount
} }
rr := strings.TrimSpace(reason) rr := strings.TrimSpace(reason)
...@@ -102,15 +98,6 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float ...@@ -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 { func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
if o.OrderType == payment.OrderTypeSubscription { if o.OrderType == payment.OrderTypeSubscription {
p.DeductionType = payment.DeductionTypeSubscription 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 return nil
} }
u, err := s.userRepo.GetByID(ctx, o.UserID) u, err := s.userRepo.GetByID(ctx, o.UserID)
...@@ -134,32 +121,9 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref ...@@ -134,32 +121,9 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref
return nil, infraerrors.Conflict("CONFLICT", "order status changed") return nil, infraerrors.Conflict("CONFLICT", "order status changed")
} }
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
// Skip balance deduction on retry if previous attempt already deducted if err := s.userRepo.DeductBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
// but failed to roll back (REFUND_ROLLBACK_FAILED in audit log). s.restoreStatus(ctx, p)
if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") { return nil, fmt.Errorf("deduction: %w", err)
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.gwRefund(ctx, p); err != nil { if err := s.gwRefund(ctx, p); err != nil {
...@@ -173,28 +137,15 @@ func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error { ...@@ -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"}) s.writeAuditLog(ctx, p.Order.ID, "REFUND_NO_TRADE_NO", "admin", map[string]any{"detail": "skipped"})
return nil return nil
} }
s.EnsureProviders(ctx)
// Use the exact provider instance that created this order, not a random one prov, err := s.registry.GetProvider(p.Order.PaymentType)
// from the registry. Each instance has its own merchant credentials.
prov, err := s.getRefundProvider(ctx, p.Order)
if err != nil { if err != nil {
return fmt.Errorf("get refund provider: %w", err) return fmt.Errorf("get provider: %w", err)
} }
_, err = prov.Refund(ctx, payment.RefundRequest{ _, 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})
TradeNo: p.Order.PaymentTradeNo,
OrderID: p.Order.OutTradeNo,
Amount: strconv.FormatFloat(p.GatewayAmount, 'f', 2, 64),
Reason: p.Reason,
})
return err 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) { func (s *PaymentService) handleGwFail(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
if s.RollbackRefund(ctx, p, gErr) { if s.RollbackRefund(ctx, p, gErr) {
s.restoreStatus(ctx, p) s.restoreStatus(ctx, p)
...@@ -229,13 +180,6 @@ func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr ...@@ -229,13 +180,6 @@ func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr
return false 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 return true
} }
......
This diff is collapsed.
...@@ -102,12 +102,10 @@ interface ReturnInfo { ...@@ -102,12 +102,10 @@ interface ReturnInfo {
} }
const returnInfo = ref<ReturnInfo | null>(null) const returnInfo = ref<ReturnInfo | null>(null)
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
const isSuccess = computed(() => { const isSuccess = computed(() => {
// Always prioritize actual order status from backend // Always prioritize actual order status from backend
if (order.value) { if (order.value) {
return SUCCESS_STATUSES.has(order.value.status) return order.value.status === 'COMPLETED' || order.value.status === 'PAID'
} }
// Fallback only when order not loaded // Fallback only when order not loaded
if (route.query.status === 'success') return true if (route.query.status === 'success') return true
...@@ -138,17 +136,14 @@ onMounted(async () => { ...@@ -138,17 +136,14 @@ onMounted(async () => {
} }
} }
// Verify payment via public endpoint (works without login) // If we have an out_trade_no from a provider return URL, actively verify
// the payment with the upstream provider (handles missed notify callbacks)
if (outTradeNo) { if (outTradeNo) {
try { try {
const result = await paymentAPI.verifyOrderPublic(outTradeNo) const result = await paymentAPI.verifyOrder(outTradeNo)
order.value = result.data order.value = result.data
} catch (_err: unknown) { } catch (_err: unknown) {
// Public verify failed, try authenticated endpoint if logged in // Verification failed, fall through to normal order lookup
try {
const result = await paymentAPI.verifyOrder(outTradeNo)
order.value = result.data
} catch (_e: unknown) { /* fall through */ }
} }
} }
......
...@@ -374,7 +374,7 @@ onMounted(async () => { ...@@ -374,7 +374,7 @@ onMounted(async () => {
if (checkout.value.balance_disabled) { if (checkout.value.balance_disabled) {
activeTab.value = 'subscription' activeTab.value = 'subscription'
} }
} catch (err: unknown) { console.error('Failed to load checkout info:', err) } } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { loading.value = false } finally { loading.value = false }
}) })
</script> </script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment