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
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1.108.73
|
||||
0.1.108.140
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user