From be82609939d0636176d54e2050cb93e8dd452730 Mon Sep 17 00:00:00 2001 From: erio Date: Wed, 8 Apr 2026 17:11:32 +0800 Subject: [PATCH] fix(payment): audit fixes for alipay/wxpay/stripe payment providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Extract YuanToFen/FenToYuan to payment/amount.go using shopspring/decimal - Require alipay publicKey in config validation - Fix wxpay webhook response to return JSON per V3 spec - Remove wxpay certSerial fallback to publicKeyId - Define magic strings as named constants in wxpay/alipay providers - Add slog warning for wxpay H5→Native payment downgrade - Make EncryptionKey validation return error on invalid (non-empty) key - Make decryptConfig propagate errors instead of returning nil - Add idempotency check in doBalance to prevent stuck FAILED retries Frontend: - Fix dashboard currency symbol from $ to ¥ - Fix AdminPaymentPlansView any type to proper SubscriptionPlan type - Make quick amount buttons follow selected payment method limits - Center help image with larger height and text below --- backend/cmd/server/wire_gen.go | 5 +- .../handler/payment_webhook_handler.go | 24 +++++--- backend/internal/payment/amount.go | 24 ++++++++ backend/internal/payment/provider/alipay.go | 22 ++++--- backend/internal/payment/provider/stripe.go | 25 ++------ backend/internal/payment/provider/wxpay.go | 58 ++++++++++--------- backend/internal/payment/wire.go | 17 +++--- .../service/payment_config_providers.go | 22 ++++--- .../service/payment_config_service.go | 4 +- .../internal/service/payment_fulfillment.go | 22 +++++-- .../orders/AdminPaymentDashboardView.vue | 4 +- .../admin/orders/AdminPaymentPlansView.vue | 2 +- frontend/src/views/user/PaymentView.vue | 22 ++++--- 13 files changed, 157 insertions(+), 94 deletions(-) create mode 100644 backend/internal/payment/amount.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 4cc01d007f..0a0cc84b4d 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -210,7 +210,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService) channelHandler := admin.NewChannelHandler(channelService, billingService) registry := payment.ProvideRegistry() - encryptionKey := payment.ProvideEncryptionKey(configConfig) + encryptionKey, err := payment.ProvideEncryptionKey(configConfig) + if err != nil { + return nil, err + } defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey) settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService) diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index fa4b176fa6..bf404118b7 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -75,7 +75,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) provider, err := h.registry.GetProviderByKey(providerKey) if err != nil { slog.Warn("[Payment Webhook] provider not registered", "provider", providerKey, "error", err) - c.String(http.StatusOK, successResponse(providerKey)) + writeSuccessResponse(c, providerKey) return } @@ -98,7 +98,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) // nil notification means irrelevant event (e.g. Stripe non-payment event); return success. if notification == nil { - c.String(http.StatusOK, successResponse(providerKey)) + writeSuccessResponse(c, providerKey) return } @@ -108,15 +108,25 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) return } - c.String(http.StatusOK, successResponse(providerKey)) + writeSuccessResponse(c, providerKey) } -// successResponse returns the provider-specific success response string. -func successResponse(providerKey string) string { +// wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook. +type wxpaySuccessResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// writeSuccessResponse sends the provider-specific success response. +// WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"}; +// Stripe expects an empty 200; others accept plain text "success". +func writeSuccessResponse(c *gin.Context, providerKey string) { switch providerKey { + case payment.TypeWxpay: + c.JSON(http.StatusOK, wxpaySuccessResponse{Code: "SUCCESS", Message: "成功"}) case payment.TypeStripe: - return "" + c.String(http.StatusOK, "") default: - return "success" + c.String(http.StatusOK, "success") } } diff --git a/backend/internal/payment/amount.go b/backend/internal/payment/amount.go new file mode 100644 index 0000000000..8489ffa326 --- /dev/null +++ b/backend/internal/payment/amount.go @@ -0,0 +1,24 @@ +package payment + +import ( + "fmt" + + "github.com/shopspring/decimal" +) + +const centsPerYuan = 100 + +// YuanToFen converts a CNY yuan string (e.g. "10.50") to fen (int64). +// Uses shopspring/decimal for precision. +func YuanToFen(yuanStr string) (int64, error) { + d, err := decimal.NewFromString(yuanStr) + if err != nil { + return 0, fmt.Errorf("invalid amount: %s", yuanStr) + } + return d.Mul(decimal.NewFromInt(centsPerYuan)).IntPart(), nil +} + +// FenToYuan converts fen (int64) to yuan as a float64 for interface compatibility. +func FenToYuan(fen int64) float64 { + return decimal.NewFromInt(fen).Div(decimal.NewFromInt(centsPerYuan)).InexactFloat64() +} diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go index 28e02c2a4b..3eca0b2cc2 100644 --- a/backend/internal/payment/provider/alipay.go +++ b/backend/internal/payment/provider/alipay.go @@ -19,6 +19,13 @@ const ( alipayProductCodeWapPay = "QUICK_WAP_WAY" ) +// Alipay response constants. +const ( + alipayFundChangeYes = "Y" + alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST" + alipayRefundSuffix = "-refund" +) + // Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK. type Alipay struct { instanceID string @@ -56,10 +63,11 @@ func (a *Alipay) getClient() (*alipay.Client, error) { if pubKey == "" { pubKey = a.config["alipayPublicKey"] } - if pubKey != "" { - if err := client.LoadAliPayPublicKey(pubKey); err != nil { - return nil, fmt.Errorf("alipay load public key: %w", err) - } + if pubKey == "" { + return nil, fmt.Errorf("alipay config missing required key: publicKey (or alipayPublicKey)") + } + if err := client.LoadAliPayPublicKey(pubKey); err != nil { + return nil, fmt.Errorf("alipay load public key: %w", err) } a.client = client return a.client, nil @@ -225,13 +233,13 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen } refundStatus := payment.ProviderStatusPending - if result.FundChange == "Y" { + if result.FundChange == alipayFundChangeYes { refundStatus = payment.ProviderStatusSuccess } refundID := result.TradeNo if refundID == "" { - refundID = req.OrderID + "-refund" + refundID = req.OrderID + alipayRefundSuffix } return &payment.RefundResponse{ @@ -261,7 +269,7 @@ func isTradeNotExist(err error) bool { if err == nil { return false } - return strings.Contains(err.Error(), "ACQ.TRADE_NOT_EXIST") + return strings.Contains(err.Error(), alipayErrTradeNotExist) } // Ensure interface compliance. diff --git a/backend/internal/payment/provider/stripe.go b/backend/internal/payment/provider/stripe.go index 15fe6ba90e..15359d45ba 100644 --- a/backend/internal/payment/provider/stripe.go +++ b/backend/internal/payment/provider/stripe.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "math" - "strconv" "strings" "sync" @@ -19,7 +17,6 @@ const ( stripeCurrency = "cny" stripeEventPaymentSuccess = "payment_intent.succeeded" stripeEventPaymentFailed = "payment_intent.payment_failed" - stripeCentsPerYuan = 100 ) // Stripe implements the payment.CancelableProvider interface for Stripe payments. @@ -63,20 +60,6 @@ func (s *Stripe) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeStripe} } -// centsToYuan converts an amount in cents (int64) to yuan (float64). -func centsToYuan(cents int64) float64 { - return float64(cents) / stripeCentsPerYuan -} - -// yuanToCents converts a CNY yuan string to cents (int64). -func yuanToCents(amountStr string) (int64, error) { - amount, err := strconv.ParseFloat(amountStr, 64) - if err != nil { - return 0, fmt.Errorf("invalid amount: %s", amountStr) - } - return int64(math.Round(amount * 100)), nil -} - // stripePaymentMethodTypes maps our PaymentType to Stripe payment_method_types. var stripePaymentMethodTypes = map[string][]string{ payment.TypeCard: {"card"}, @@ -89,7 +72,7 @@ var stripePaymentMethodTypes = map[string][]string{ func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { s.ensureInit() - amountInCents, err := yuanToCents(req.Amount) + amountInCents, err := payment.YuanToFen(req.Amount) if err != nil { return nil, fmt.Errorf("stripe create payment: %w", err) } @@ -153,7 +136,7 @@ func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query return &payment.QueryOrderResponse{ TradeNo: pi.ID, Status: status, - Amount: centsToYuan(pi.Amount), + Amount: payment.FenToYuan(pi.Amount), }, nil } @@ -194,7 +177,7 @@ func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string return &payment.PaymentNotification{ TradeNo: pi.ID, OrderID: pi.Metadata["orderId"], - Amount: centsToYuan(pi.Amount), + Amount: payment.FenToYuan(pi.Amount), Status: status, RawData: rawBody, }, nil @@ -204,7 +187,7 @@ func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) { s.ensureInit() - amountInCents, err := yuanToCents(req.Amount) + amountInCents, err := payment.YuanToFen(req.Amount) if err != nil { return nil, fmt.Errorf("stripe refund: %w", err) } diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index 5448aba3c3..27a65a1314 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -6,9 +6,8 @@ import ( "crypto/rsa" "fmt" "io" - "math" + "log/slog" "net/http" - "strconv" "strings" "sync" "time" @@ -27,9 +26,26 @@ import ( // WeChat Pay constants. const ( - wxpayCurrency = "CNY" - wxpayH5Type = "Wap" - wxpayFenPerYuan = 100 + wxpayCurrency = "CNY" + wxpayH5Type = "Wap" +) + +// WeChat Pay trade states. +const ( + wxpayTradeStateSuccess = "SUCCESS" + wxpayTradeStateRefund = "REFUND" + wxpayTradeStateClosed = "CLOSED" + wxpayTradeStatePayError = "PAYERROR" +) + +// WeChat Pay notification event types. +const ( + wxpayEventTransactionSuccess = "TRANSACTION.SUCCESS" +) + +// WeChat Pay error codes. +const ( + wxpayErrNoAuth = "NO_AUTH" ) type Wxpay struct { @@ -78,9 +94,6 @@ func (w *Wxpay) ensureClient() (*core.Client, error) { return nil, err } certSerial := w.config["certSerial"] - if certSerial == "" { - certSerial = w.config["publicKeyId"] - } verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey) client, err := core.NewClient(context.Background(), option.WithMerchantCredential(w.config["mchId"], certSerial, privateKey), @@ -109,14 +122,6 @@ func (w *Wxpay) loadKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) { return privateKey, publicKey, nil } -func yuanToFen(s string) (int64, error) { - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return 0, fmt.Errorf("invalid amount: %s", s) - } - return int64(math.Round(f * 100)), nil -} - func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { client, err := w.ensureClient() if err != nil { @@ -129,7 +134,7 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ if notifyURL == "" { return nil, fmt.Errorf("wxpay notifyUrl is required") } - totalFen, err := yuanToFen(req.Amount) + totalFen, err := payment.YuanToFen(req.Amount) if err != nil { return nil, fmt.Errorf("wxpay create payment: %w", err) } @@ -138,9 +143,10 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ if err == nil { return resp, nil } - if !strings.Contains(err.Error(), "NO_AUTH") { + if !strings.Contains(err.Error(), wxpayErrNoAuth) { return nil, err } + slog.Warn("wxpay H5 payment not authorized, falling back to native", "order", req.OrderID) } return w.createOrder(ctx, client, req, notifyURL, totalFen, false) } @@ -201,11 +207,11 @@ func wxSV(s *string) string { func mapWxState(s string) string { switch s { - case "SUCCESS": + case wxpayTradeStateSuccess: return payment.ProviderStatusPaid - case "REFUND": + case wxpayTradeStateRefund: return payment.ProviderStatusRefunded - case "CLOSED", "PAYERROR": + case wxpayTradeStateClosed, wxpayTradeStatePayError: return payment.ProviderStatusFailed default: return payment.ProviderStatusPending @@ -226,7 +232,7 @@ func (w *Wxpay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryO } var amt float64 if tx.Amount != nil && tx.Amount.Total != nil { - amt = float64(*tx.Amount.Total) / wxpayFenPerYuan + amt = payment.FenToYuan(*tx.Amount.Total) } id := tradeNo if tx.TransactionId != nil { @@ -255,15 +261,15 @@ func (w *Wxpay) VerifyNotification(ctx context.Context, rawBody string, headers if err != nil { return nil, fmt.Errorf("wxpay verify notification: %w", err) } - if nr.EventType != "TRANSACTION.SUCCESS" { + if nr.EventType != wxpayEventTransactionSuccess { return nil, nil } var amt float64 if tx.Amount != nil && tx.Amount.Total != nil { - amt = float64(*tx.Amount.Total) / wxpayFenPerYuan + amt = payment.FenToYuan(*tx.Amount.Total) } st := payment.ProviderStatusFailed - if wxSV(tx.TradeState) == "SUCCESS" { + if wxSV(tx.TradeState) == wxpayTradeStateSuccess { st = payment.ProviderStatusSuccess } return &payment.PaymentNotification{ @@ -277,7 +283,7 @@ func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment if err != nil { return nil, err } - rf, err := yuanToFen(req.Amount) + rf, err := payment.YuanToFen(req.Amount) if err != nil { return nil, fmt.Errorf("wxpay refund amount: %w", err) } diff --git a/backend/internal/payment/wire.go b/backend/internal/payment/wire.go index 880c55456d..9717465d8e 100644 --- a/backend/internal/payment/wire.go +++ b/backend/internal/payment/wire.go @@ -2,6 +2,7 @@ package payment import ( "encoding/hex" + "fmt" "log/slog" dbent "github.com/Wei-Shaw/sub2api/ent" @@ -14,20 +15,22 @@ import ( type EncryptionKey []byte // ProvideEncryptionKey derives the payment encryption key from the TOTP encryption key in config. -func ProvideEncryptionKey(cfg *config.Config) EncryptionKey { +// When the key is empty, nil is returned (payment features that need encryption will be disabled). +// When the key is non-empty but invalid (bad hex or wrong length), an error is returned +// to prevent startup with a misconfigured encryption key. +func ProvideEncryptionKey(cfg *config.Config) (EncryptionKey, error) { if cfg.Totp.EncryptionKey == "" { - return nil + slog.Warn("payment encryption key not configured — encrypted payment config will be unavailable") + return nil, nil } key, err := hex.DecodeString(cfg.Totp.EncryptionKey) if err != nil { - slog.Error("invalid payment encryption key", "error", err) - return nil + return nil, fmt.Errorf("invalid payment encryption key (hex decode): %w", err) } if len(key) != 32 { - slog.Error("payment encryption key must be 32 bytes", "got", len(key)) - return nil + return nil, fmt.Errorf("payment encryption key must be 32 bytes, got %d", len(key)) } - return EncryptionKey(key) + return EncryptionKey(key), nil } // ProvideRegistry creates an empty payment provider registry. diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go index cf73b1867d..8220dc991b 100644 --- a/backend/internal/service/payment_config_providers.go +++ b/backend/internal/service/payment_config_providers.go @@ -49,13 +49,16 @@ func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Conte Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode, } - resp.Config = s.decryptAndMaskConfig(inst.Config) + resp.Config, err = s.decryptAndMaskConfig(inst.Config) + if err != nil { + return nil, fmt.Errorf("decrypt config for instance %d: %w", inst.ID, err) + } result = append(result, resp) } return result, nil } -func (s *PaymentConfigService) decryptAndMaskConfig(encrypted string) map[string]string { +func (s *PaymentConfigService) decryptAndMaskConfig(encrypted string) (map[string]string, error) { return s.decryptConfig(encrypted) } @@ -201,7 +204,10 @@ func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newCon if err != nil { return nil, fmt.Errorf("load existing provider: %w", err) } - existing := s.decryptConfig(inst.Config) + existing, err := s.decryptConfig(inst.Config) + if err != nil { + return nil, fmt.Errorf("decrypt existing config for instance %d: %w", id, err) + } if existing == nil { return newConfig, nil } @@ -211,19 +217,19 @@ func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newCon return existing, nil } -func (s *PaymentConfigService) decryptConfig(encrypted string) map[string]string { +func (s *PaymentConfigService) decryptConfig(encrypted string) (map[string]string, error) { if encrypted == "" { - return nil + return nil, nil } decrypted, err := payment.Decrypt(encrypted, s.encryptionKey) if err != nil { - return nil + return nil, fmt.Errorf("decrypt config: %w", err) } var raw map[string]string if err := json.Unmarshal([]byte(decrypted), &raw); err != nil { - return nil + return nil, fmt.Errorf("unmarshal decrypted config: %w", err) } - return raw + return raw, nil } func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error { diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 7fc85df79a..9042c3aba7 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -242,8 +242,8 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri if err != nil || len(instances) == 0 { return "" } - cfg := s.decryptConfig(instances[0].Config) - if cfg == nil { + cfg, err := s.decryptConfig(instances[0].Config) + if err != nil || cfg == nil { return "" } return cfg[payment.ConfigKeyPublishableKey] diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 9b530d6912..db92ff2bf2 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -147,19 +147,33 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6 } func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) error { - rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused} - if err := s.redeemService.CreateCode(ctx, rc); err != nil { - return fmt.Errorf("create redeem code: %w", err) + // Idempotency: check if redeem code already exists (from a previous partial run) + existing, _ := s.redeemService.GetByCode(ctx, o.RechargeCode) + if existing != nil { + if existing.IsUsed() { + // Code already created and redeemed — just mark completed + return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") + } + // Code exists but unused — skip creation, proceed to redeem + } else { + rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused} + if err := s.redeemService.CreateCode(ctx, rc); err != nil { + return fmt.Errorf("create redeem code: %w", err) + } } if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil { return fmt.Errorf("redeem balance: %w", err) } + return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") +} + +func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrder, auditAction string) error { now := time.Now() _, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusRecharging)).SetStatus(OrderStatusCompleted).SetCompletedAt(now).Save(ctx) if err != nil { return fmt.Errorf("mark completed: %w", err) } - s.writeAuditLog(ctx, o.ID, "RECHARGE_SUCCESS", "system", map[string]any{"rechargeCode": o.RechargeCode, "amount": o.Amount}) + s.writeAuditLog(ctx, o.ID, auditAction, "system", map[string]any{"rechargeCode": o.RechargeCode, "amount": o.Amount}) return nil } diff --git a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue index 7320037d31..06bc921870 100644 --- a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue @@ -42,7 +42,7 @@ {{ t('payment.methods.' + method.type, method.type) }}
- ${{ method.amount.toFixed(2) }} + ¥{{ method.amount.toFixed(2) }} ({{ method.count }})
@@ -57,7 +57,7 @@ {{ idx + 1 }} {{ user.email }} - ${{ user.amount.toFixed(2) }} + ¥{{ user.amount.toFixed(2) }} diff --git a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue index 57b0f351cb..f2819c3714 100644 --- a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue @@ -219,7 +219,7 @@ async function loadPlans() { try { const res = await adminPaymentAPI.getPlans() // Backend returns features as newline-separated string; parse to array - plans.value = (res.data || []).map((p: any) => ({ + plans.value = (res.data || []).map((p: Omit & { features: string | string[] }) => ({ ...p, features: typeof p.features === 'string' ? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean) diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 3ec654d753..5dc396ec70 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -30,8 +30,8 @@

{{ amountError }}

@@ -81,11 +81,11 @@
-
+
-

{{ checkout.help_text }}

+

{{ checkout.help_text }}

@@ -213,9 +213,15 @@ function amountFitsMethod(amt: number, methodType: string): boolean { return true } -// Global range for AmountInput quick buttons (precomputed by backend) -const globalMinAmount = computed(() => checkout.value.global_min) -const globalMaxAmount = computed(() => checkout.value.global_max) +// Amount range: use selected method's limits when available, fallback to global +const activeMinAmount = computed(() => { + const ml = selectedLimit.value + return ml?.single_min && ml.single_min > 0 ? ml.single_min : checkout.value.global_min +}) +const activeMaxAmount = computed(() => { + const ml = selectedLimit.value + return ml?.single_max && ml.single_max > 0 ? ml.single_max : checkout.value.global_max +}) // Selected method's limits (for validation and error messages) const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value])