mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-22 16:27:01 +00:00
fix(payment): audit fixes for alipay/wxpay/stripe payment providers
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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ method.amount.toFixed(2) }}</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">¥{{ method.amount.toFixed(2) }}</span>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
<span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ user.amount.toFixed(2) }}</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">¥{{ user.amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<SubscriptionPlan, 'features'> & { features: string | string[] }) => ({
|
||||
...p,
|
||||
features: typeof p.features === 'string'
|
||||
? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean)
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
<AmountInput
|
||||
v-model="amount"
|
||||
:amounts="[10, 20, 50, 100, 200, 500, 1000, 2000, 5000]"
|
||||
:min="globalMinAmount"
|
||||
:max="globalMaxAmount"
|
||||
:min="activeMinAmount"
|
||||
:max="activeMaxAmount"
|
||||
/>
|
||||
<p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
|
||||
</div>
|
||||
@@ -81,11 +81,11 @@
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="checkout.help_text || checkout.help_image_url" class="card p-4">
|
||||
<div :class="checkout.help_image_url && checkout.help_text ? 'flex items-start gap-4' : ''">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<img v-if="checkout.help_image_url" :src="checkout.help_image_url" alt=""
|
||||
class="h-24 w-24 flex-shrink-0 cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
|
||||
class="h-40 max-w-full cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
|
||||
@click="previewImage = checkout.help_image_url" />
|
||||
<p v-if="checkout.help_text" class="text-sm text-gray-500 dark:text-gray-400">{{ checkout.help_text }}</p>
|
||||
<p v-if="checkout.help_text" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ checkout.help_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user