mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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.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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user