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:
erio
2026-04-09 21:29:49 +08:00
parent 44a9d91c8f
commit ceee2d0584
11 changed files with 472 additions and 76 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.108.139
0.1.108.140
@@ -1,7 +1,13 @@
//go:build unit
package payment
import (
"encoding/json"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
)
func TestInstanceSupportsType(t *testing.T) {
@@ -85,3 +91,384 @@ func TestInstanceSupportsType(t *testing.T) {
})
}
}
// ---------------------------------------------------------------------------
// Helper to build test PaymentProviderInstance values
// ---------------------------------------------------------------------------
func testInstance(id int64, providerKey, limits string) *dbent.PaymentProviderInstance {
return &dbent.PaymentProviderInstance{
ID: id,
ProviderKey: providerKey,
Limits: limits,
Enabled: true,
}
}
// makeLimitsJSON builds a limits JSON string for a single payment type.
func makeLimitsJSON(paymentType string, cl ChannelLimits) string {
m := map[string]ChannelLimits{paymentType: cl}
b, _ := json.Marshal(m)
return string(b)
}
// ---------------------------------------------------------------------------
// filterByLimits
// ---------------------------------------------------------------------------
func TestFilterByLimits(t *testing.T) {
t.Parallel()
tests := []struct {
name string
candidates []instanceCandidate
paymentType PaymentType
orderAmount float64
wantIDs []int64 // expected surviving instance IDs
}{
{
name: "order below SingleMin is filtered out",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10})), dailyUsed: 0},
},
paymentType: "alipay",
orderAmount: 5,
wantIDs: nil,
},
{
name: "order at exact SingleMin boundary passes",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10})), dailyUsed: 0},
},
paymentType: "alipay",
orderAmount: 10,
wantIDs: []int64{1},
},
{
name: "order above SingleMax is filtered out",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMax: 100})), dailyUsed: 0},
},
paymentType: "alipay",
orderAmount: 150,
wantIDs: nil,
},
{
name: "order at exact SingleMax boundary passes",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMax: 100})), dailyUsed: 0},
},
paymentType: "alipay",
orderAmount: 100,
wantIDs: []int64{1},
},
{
name: "daily used + orderAmount exceeding dailyLimit is filtered out",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 500})), dailyUsed: 480},
},
paymentType: "alipay",
orderAmount: 30,
wantIDs: nil, // 480+30=510 > 500
},
{
name: "daily used + orderAmount equal to dailyLimit passes (strict greater-than)",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 500})), dailyUsed: 480},
},
paymentType: "alipay",
orderAmount: 20,
wantIDs: []int64{1}, // 480+20=500, 500 > 500 is false → passes
},
{
name: "daily used + orderAmount below dailyLimit passes",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 500})), dailyUsed: 400},
},
paymentType: "alipay",
orderAmount: 50,
wantIDs: []int64{1},
},
{
name: "no limits configured passes through",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", ""), dailyUsed: 99999},
},
paymentType: "alipay",
orderAmount: 100,
wantIDs: []int64{1},
},
{
name: "multiple candidates with partial filtering",
candidates: []instanceCandidate{
// singleMax=50, order=80 → filtered out
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMax: 50})), dailyUsed: 0},
// no limits → passes
{inst: testInstance(2, "easypay", ""), dailyUsed: 0},
// singleMin=100, order=80 → filtered out
{inst: testInstance(3, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 100})), dailyUsed: 0},
// daily limit ok → passes (500+80=580 < 1000)
{inst: testInstance(4, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 1000})), dailyUsed: 500},
},
paymentType: "alipay",
orderAmount: 80,
wantIDs: []int64{2, 4},
},
{
name: "zero SingleMin and SingleMax means no single-transaction limit",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 0, SingleMax: 0, DailyLimit: 0})), dailyUsed: 0},
},
paymentType: "alipay",
orderAmount: 99999,
wantIDs: []int64{1},
},
{
name: "all limits combined - order passes all checks",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10, SingleMax: 200, DailyLimit: 1000})), dailyUsed: 500},
},
paymentType: "alipay",
orderAmount: 50,
wantIDs: []int64{1},
},
{
name: "all limits combined - order fails SingleMin",
candidates: []instanceCandidate{
{inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10, SingleMax: 200, DailyLimit: 1000})), dailyUsed: 500},
},
paymentType: "alipay",
orderAmount: 5,
wantIDs: nil,
},
{
name: "empty candidates returns empty",
candidates: nil,
paymentType: "alipay",
orderAmount: 10,
wantIDs: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := filterByLimits(tt.candidates, tt.paymentType, tt.orderAmount)
gotIDs := make([]int64, len(got))
for i, c := range got {
gotIDs[i] = c.inst.ID
}
if !int64SliceEqual(gotIDs, tt.wantIDs) {
t.Fatalf("filterByLimits() returned IDs %v, want %v", gotIDs, tt.wantIDs)
}
})
}
}
// ---------------------------------------------------------------------------
// pickLeastAmount
// ---------------------------------------------------------------------------
func TestPickLeastAmount(t *testing.T) {
t.Parallel()
t.Run("picks candidate with lowest dailyUsed", func(t *testing.T) {
t.Parallel()
candidates := []instanceCandidate{
{inst: testInstance(1, "easypay", ""), dailyUsed: 300},
{inst: testInstance(2, "easypay", ""), dailyUsed: 100},
{inst: testInstance(3, "easypay", ""), dailyUsed: 200},
}
got := pickLeastAmount(candidates)
if got.inst.ID != 2 {
t.Fatalf("pickLeastAmount() picked instance %d, want 2", got.inst.ID)
}
})
t.Run("with equal dailyUsed picks the first one", func(t *testing.T) {
t.Parallel()
candidates := []instanceCandidate{
{inst: testInstance(1, "easypay", ""), dailyUsed: 100},
{inst: testInstance(2, "easypay", ""), dailyUsed: 100},
{inst: testInstance(3, "easypay", ""), dailyUsed: 200},
}
got := pickLeastAmount(candidates)
if got.inst.ID != 1 {
t.Fatalf("pickLeastAmount() picked instance %d, want 1 (first with lowest)", got.inst.ID)
}
})
t.Run("single candidate returns that candidate", func(t *testing.T) {
t.Parallel()
candidates := []instanceCandidate{
{inst: testInstance(42, "easypay", ""), dailyUsed: 999},
}
got := pickLeastAmount(candidates)
if got.inst.ID != 42 {
t.Fatalf("pickLeastAmount() picked instance %d, want 42", got.inst.ID)
}
})
t.Run("zero usage among non-zero picks zero", func(t *testing.T) {
t.Parallel()
candidates := []instanceCandidate{
{inst: testInstance(1, "easypay", ""), dailyUsed: 500},
{inst: testInstance(2, "easypay", ""), dailyUsed: 0},
{inst: testInstance(3, "easypay", ""), dailyUsed: 300},
}
got := pickLeastAmount(candidates)
if got.inst.ID != 2 {
t.Fatalf("pickLeastAmount() picked instance %d, want 2", got.inst.ID)
}
})
}
// ---------------------------------------------------------------------------
// getInstanceChannelLimits
// ---------------------------------------------------------------------------
func TestGetInstanceChannelLimits(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inst *dbent.PaymentProviderInstance
paymentType PaymentType
want ChannelLimits
}{
{
name: "empty limits string returns zero ChannelLimits",
inst: testInstance(1, "easypay", ""),
paymentType: "alipay",
want: ChannelLimits{},
},
{
name: "invalid JSON returns zero ChannelLimits",
inst: testInstance(1, "easypay", "not-json{"),
paymentType: "alipay",
want: ChannelLimits{},
},
{
name: "valid JSON with matching payment type",
inst: testInstance(1, "easypay",
`{"alipay":{"singleMin":5,"singleMax":200,"dailyLimit":1000}}`),
paymentType: "alipay",
want: ChannelLimits{SingleMin: 5, SingleMax: 200, DailyLimit: 1000},
},
{
name: "payment type not in limits returns zero ChannelLimits",
inst: testInstance(1, "easypay",
`{"alipay":{"singleMin":5,"singleMax":200}}`),
paymentType: "wxpay",
want: ChannelLimits{},
},
{
name: "stripe provider uses stripe lookup key regardless of payment type",
inst: testInstance(1, "stripe",
`{"stripe":{"singleMin":10,"singleMax":500,"dailyLimit":5000}}`),
paymentType: "alipay",
want: ChannelLimits{SingleMin: 10, SingleMax: 500, DailyLimit: 5000},
},
{
name: "stripe provider ignores payment type key even if present",
inst: testInstance(1, "stripe",
`{"stripe":{"singleMin":10,"singleMax":500},"alipay":{"singleMin":1,"singleMax":100}}`),
paymentType: "alipay",
want: ChannelLimits{SingleMin: 10, SingleMax: 500},
},
{
name: "non-stripe provider uses payment type as lookup key",
inst: testInstance(1, "easypay",
`{"alipay":{"singleMin":5},"wxpay":{"singleMin":10}}`),
paymentType: "wxpay",
want: ChannelLimits{SingleMin: 10},
},
{
name: "valid JSON with partial limits (only dailyLimit)",
inst: testInstance(1, "easypay",
`{"alipay":{"dailyLimit":800}}`),
paymentType: "alipay",
want: ChannelLimits{DailyLimit: 800},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := getInstanceChannelLimits(tt.inst, tt.paymentType)
if got != tt.want {
t.Fatalf("getInstanceChannelLimits() = %+v, want %+v", got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// startOfDay
// ---------------------------------------------------------------------------
func TestStartOfDay(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in time.Time
want time.Time
}{
{
name: "midday returns midnight of same day",
in: time.Date(2025, 6, 15, 14, 30, 45, 123456789, time.UTC),
want: time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC),
},
{
name: "midnight returns same time",
in: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
want: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
},
{
name: "last second of day returns midnight of same day",
in: time.Date(2025, 12, 31, 23, 59, 59, 999999999, time.UTC),
want: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC),
},
{
name: "preserves timezone location",
in: time.Date(2025, 3, 10, 15, 0, 0, 0, time.FixedZone("CST", 8*3600)),
want: time.Date(2025, 3, 10, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := startOfDay(tt.in)
if !got.Equal(tt.want) {
t.Fatalf("startOfDay(%v) = %v, want %v", tt.in, got, tt.want)
}
// Also verify location is preserved.
if got.Location().String() != tt.want.Location().String() {
t.Fatalf("startOfDay() location = %v, want %v", got.Location(), tt.want.Location())
}
})
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// int64SliceEqual compares two int64 slices for equality.
// Both nil and empty slices are treated as equal.
func int64SliceEqual(a, b []int64) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+5 -3
View File
@@ -25,6 +25,8 @@ const (
easypayStatusPaid = 1
easypayHTTPTimeout = 10 * time.Second
maxEasypayResponseSize = 1 << 20 // 1MB
tradeStatusSuccess = "TRADE_SUCCESS"
signTypeMD5 = "MD5"
)
// EasyPay implements payment.Provider for the EasyPay aggregation platform.
@@ -80,7 +82,7 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym
params["cid"] = cid
}
params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = "MD5"
params["sign_type"] = signTypeMD5
q := url.Values{}
for k, v := range params {
@@ -107,7 +109,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
params["device"] = "mobile"
}
params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = "MD5"
params["sign_type"] = signTypeMD5
body, err := e.post(ctx, strings.TrimRight(e.config["apiBase"], "/")+"/mapi.php", params)
if err != nil {
@@ -184,7 +186,7 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
return nil, fmt.Errorf("invalid signature")
}
status := payment.ProviderStatusFailed
if params["trade_status"] == "TRADE_SUCCESS" {
if params["trade_status"] == tradeStatusSuccess {
status = payment.ProviderStatusSuccess
}
amount, _ := strconv.ParseFloat(params["money"], 64)
+24
View File
@@ -35,6 +35,30 @@ const (
OrderStatusRefundFailed = "REFUND_FAILED"
)
// Order types distinguish balance recharges from subscription purchases.
const (
OrderTypeBalance = "balance"
OrderTypeSubscription = "subscription"
)
// Entity statuses shared across users, groups, etc.
const (
EntityStatusActive = "active"
)
// Deduction types for refund flow.
const (
DeductionTypeBalance = "balance"
DeductionTypeSubscription = "subscription"
DeductionTypeNone = "none"
)
// Payment notification status values.
const (
NotificationStatusSuccess = "success"
NotificationStatusPaid = "paid"
)
// Provider-level status constants returned by provider implementations
// to the service layer (lowercase, distinct from OrderStatus uppercase constants).
const (
@@ -18,7 +18,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
}
// Look up order by out_trade_no (the external order ID we sent to the provider)
@@ -120,7 +120,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)
@@ -246,7 +246,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)})
+7 -7
View File
@@ -21,7 +21,7 @@ import (
func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) {
if req.OrderType == "" {
req.OrderType = "balance"
req.OrderType = payment.OrderTypeBalance
}
cfg, err := s.configService.GetPaymentConfig(ctx)
if err != nil {
@@ -41,7 +41,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if user.Status != "active" {
if user.Status != payment.EntityStatusActive {
return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled")
}
amount := req.Amount
@@ -66,10 +66,10 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
}
func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig) (*dbent.SubscriptionPlan, error) {
if req.OrderType == "balance" && cfg.BalanceDisabled {
if req.OrderType == payment.OrderTypeBalance && cfg.BalanceDisabled {
return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled")
}
if req.OrderType == "subscription" {
if req.OrderType == payment.OrderTypeSubscription {
return s.validateSubOrder(ctx, req)
}
if (cfg.MinAmount > 0 && req.Amount < cfg.MinAmount) || (cfg.MaxAmount > 0 && req.Amount > cfg.MaxAmount) {
@@ -88,7 +88,7 @@ func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRe
return nil, infraerrors.NotFound("PLAN_NOT_AVAILABLE", "plan not found or not for sale")
}
group, err := s.groupRepo.GetByID(ctx, plan.GroupID)
if err != nil || group.Status != "active" {
if err != nil || group.Status != payment.EntityStatusActive {
return nil, infraerrors.NotFound("GROUP_NOT_FOUND", "subscription group is no longer available")
}
if !group.IsSubscriptionType() {
@@ -426,8 +426,8 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s
slog.Warn("query upstream failed", "orderID", o.ID, "error", err)
return ""
}
if resp.Status == "paid" {
_ = s.HandlePaymentNotification(ctx, &payment.PaymentNotification{TradeNo: o.PaymentTradeNo, OrderID: o.OutTradeNo, Amount: resp.Amount, Status: "success"}, prov.ProviderKey())
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 {
+8 -8
View File
@@ -32,7 +32,7 @@ func (s *PaymentService) RequestRefund(ctx context.Context, oid, uid int64, reas
nr := strings.TrimSpace(reason)
now := time.Now()
by := fmt.Sprintf("%d", uid)
c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.UserIDEQ(uid), paymentorder.StatusEQ(OrderStatusCompleted), paymentorder.OrderTypeEQ("balance")).SetStatus(OrderStatusRefundRequested).SetRefundRequestedAt(now).SetRefundRequestReason(nr).SetRefundRequestedBy(by).SetRefundAmount(o.Amount).Save(ctx)
c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.UserIDEQ(uid), paymentorder.StatusEQ(OrderStatusCompleted), paymentorder.OrderTypeEQ(payment.OrderTypeBalance)).SetStatus(OrderStatusRefundRequested).SetRefundRequestedAt(now).SetRefundRequestReason(nr).SetRefundRequestedBy(by).SetRefundAmount(o.Amount).Save(ctx)
if err != nil {
return fmt.Errorf("update: %w", err)
}
@@ -51,7 +51,7 @@ func (s *PaymentService) validateRefundRequest(ctx context.Context, oid, uid int
if o.UserID != uid {
return nil, infraerrors.Forbidden("FORBIDDEN", "no permission")
}
if o.OrderType != "balance" {
if o.OrderType != payment.OrderTypeBalance {
return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "only balance orders can request refund")
}
if o.Status != OrderStatusCompleted {
@@ -86,7 +86,7 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
if rr == "" {
rr = fmt.Sprintf("refund order:%d", o.ID)
}
p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct, DeductionType: "none"}
p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct, DeductionType: payment.DeductionTypeNone}
if deduct {
if er := s.prepDeduct(ctx, o, p, force); er != nil {
return nil, er, nil
@@ -96,8 +96,8 @@ 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 == "subscription" {
p.DeductionType = "subscription"
if o.OrderType == payment.OrderTypeSubscription {
p.DeductionType = payment.DeductionTypeSubscription
return nil
}
u, err := s.userRepo.GetByID(ctx, o.UserID)
@@ -107,7 +107,7 @@ func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder,
}
return nil
}
p.DeductionType = "balance"
p.DeductionType = payment.DeductionTypeBalance
p.BalanceToDeduct = math.Min(p.RefundAmount, u.Balance)
return nil
}
@@ -120,7 +120,7 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref
if c == 0 {
return nil, infraerrors.Conflict("CONFLICT", "order status changed")
}
if p.DeductionType == "balance" && p.BalanceToDeduct > 0 {
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 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)
@@ -173,7 +173,7 @@ func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*Refu
}
func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr error) bool {
if p.DeductionType == "balance" && p.BalanceToDeduct > 0 {
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
slog.Error("[CRITICAL] rollback failed", "orderID", p.OrderID, "amount", p.BalanceToDeduct, "error", err)
s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "balanceDeducted": p.BalanceToDeduct})
+33 -50
View File
@@ -2359,7 +2359,7 @@ async function loadSettings() {
loadFailed.value = false
try {
const settings = await adminAPI.settings.getSettings()
;(settings as any).payment_load_balance_strategy = settings.payment_load_balance_strategy || 'round-robin'
settings.payment_load_balance_strategy = settings.payment_load_balance_strategy || 'round-robin'
// Only assign non-null values from backend (null means unconfigured, keep defaults)
for (const [key, value] of Object.entries(settings)) {
if (value !== null && value !== undefined) {
@@ -2383,11 +2383,9 @@ async function loadSettings() {
smtpPasswordManuallyEdited.value = false
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
} catch (error: any) {
} catch (error: unknown) {
loadFailed.value = true
appStore.showError(
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
)
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToLoad')))
} finally {
loading.value = false
}
@@ -2399,8 +2397,7 @@ async function loadSubscriptionGroups() {
subscriptionGroups.value = groups.filter(
(group) => group.subscription_type === 'subscription' && group.status === 'active'
)
} catch (error) {
console.error('Failed to load subscription groups:', error)
} catch (_error: unknown) {
subscriptionGroups.value = []
}
}
@@ -2551,10 +2548,8 @@ async function saveSettings() {
await appStore.fetchPublicSettings(true)
await adminSettingsStore.fetch(true)
appStore.showSuccess(t('admin.settings.settingsSaved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSave')))
} finally {
saving.value = false
}
@@ -2573,10 +2568,8 @@ async function testSmtpConnection() {
})
// API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess'))
} catch (error: any) {
appStore.showError(
t('admin.settings.failedToTestSmtp') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToTestSmtp')))
} finally {
testingSmtp.value = false
}
@@ -2603,10 +2596,8 @@ async function sendTestEmail() {
})
// API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(result.message || t('admin.settings.testEmailSent'))
} catch (error: any) {
appStore.showError(
t('admin.settings.failedToSendTestEmail') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSendTestEmail')))
} finally {
sendingTestEmail.value = false
}
@@ -2619,8 +2610,8 @@ async function loadAdminApiKey() {
const status = await adminAPI.settings.getAdminApiKey()
adminApiKeyExists.value = status.exists
adminApiKeyMasked.value = status.masked_key
} catch (error: any) {
console.error('Failed to load admin API key status:', error)
} catch (_error: unknown) {
// Silent fail - admin API key status is non-critical
} finally {
adminApiKeyLoading.value = false
}
@@ -2634,8 +2625,8 @@ async function createAdminApiKey() {
adminApiKeyExists.value = true
adminApiKeyMasked.value = result.key.substring(0, 10) + '...' + result.key.slice(-4)
appStore.showSuccess(t('admin.settings.adminApiKey.keyGenerated'))
} catch (error: any) {
appStore.showError(error.message || t('common.error'))
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('common.error')))
} finally {
adminApiKeyOperating.value = false
}
@@ -2655,8 +2646,8 @@ async function deleteAdminApiKey() {
adminApiKeyMasked.value = ''
newAdminApiKey.value = ''
appStore.showSuccess(t('admin.settings.adminApiKey.keyDeleted'))
} catch (error: any) {
appStore.showError(error.message || t('common.error'))
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('common.error')))
} finally {
adminApiKeyOperating.value = false
}
@@ -2679,8 +2670,8 @@ async function loadOverloadCooldownSettings() {
try {
const settings = await adminAPI.settings.getOverloadCooldownSettings()
Object.assign(overloadCooldownForm, settings)
} catch (error: any) {
console.error('Failed to load overload cooldown settings:', error)
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
overloadCooldownLoading.value = false
}
@@ -2695,10 +2686,8 @@ async function saveOverloadCooldownSettings() {
})
Object.assign(overloadCooldownForm, updated)
appStore.showSuccess(t('admin.settings.overloadCooldown.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.overloadCooldown.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.overloadCooldown.saveFailed')))
} finally {
overloadCooldownSaving.value = false
}
@@ -2710,8 +2699,8 @@ async function loadStreamTimeoutSettings() {
try {
const settings = await adminAPI.settings.getStreamTimeoutSettings()
Object.assign(streamTimeoutForm, settings)
} catch (error: any) {
console.error('Failed to load stream timeout settings:', error)
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
streamTimeoutLoading.value = false
}
@@ -2729,10 +2718,8 @@ async function saveStreamTimeoutSettings() {
})
Object.assign(streamTimeoutForm, updated)
appStore.showSuccess(t('admin.settings.streamTimeout.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.streamTimeout.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.streamTimeout.saveFailed')))
} finally {
streamTimeoutSaving.value = false
}
@@ -2748,8 +2735,8 @@ async function loadRectifierSettings() {
if (!Array.isArray(rectifierForm.apikey_signature_patterns)) {
rectifierForm.apikey_signature_patterns = []
}
} catch (error: any) {
console.error('Failed to load rectifier settings:', error)
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
rectifierLoading.value = false
}
@@ -2772,10 +2759,8 @@ async function saveRectifierSettings() {
rectifierForm.apikey_signature_patterns = []
}
appStore.showSuccess(t('admin.settings.rectifier.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.rectifier.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.rectifier.saveFailed')))
} finally {
rectifierSaving.value = false
}
@@ -2809,8 +2794,8 @@ async function loadBetaPolicySettings() {
try {
const settings = await adminAPI.settings.getBetaPolicySettings()
betaPolicyForm.rules = settings.rules
} catch (error: any) {
console.error('Failed to load beta policy settings:', error)
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
betaPolicyLoading.value = false
}
@@ -2824,10 +2809,8 @@ async function saveBetaPolicySettings() {
})
betaPolicyForm.rules = updated.rules
appStore.showSuccess(t('admin.settings.betaPolicy.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.betaPolicy.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.betaPolicy.saveFailed')))
} finally {
betaPolicySaving.value = false
}
@@ -2932,7 +2915,7 @@ function openEditProvider(provider: ProviderInstance) {
showProviderDialog.value = true
}
async function handleSaveProvider(payload: any) {
async function handleSaveProvider(payload: Partial<ProviderInstance>) {
providerSaving.value = true
try {
if (editingProvider.value) {
@@ -142,7 +142,7 @@ onMounted(async () => {
try {
const result = await paymentAPI.verifyOrder(outTradeNo)
order.value = result.data
} catch {
} catch (_err: unknown) {
// Verification failed, fall through to normal order lookup
}
}
@@ -151,7 +151,7 @@ onMounted(async () => {
if (!order.value && orderId) {
try {
order.value = await paymentStore.pollOrderStatus(orderId)
} catch {
} catch (_err: unknown) {
// Order lookup failed, will show returnInfo fallback
}
}
+1 -1
View File
@@ -497,7 +497,7 @@ onMounted(async () => {
if (checkout.value.balance_disabled) {
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 }
// Fetch active subscriptions (uses cache, non-blocking)
subscriptionStore.fetchActiveSubscriptions().catch(() => {})
+1 -1
View File
@@ -161,7 +161,7 @@ async function fetchOrders() {
})
orders.value = res.data.items || []
pagination.total = res.data.total || 0
} catch (err) {
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false