fix(topup): reject uncreditable orders before payment (#6845)
* fix(topup): reject uncreditable orders before payment * fix(topup): align zero-ratio Stripe validation * fix(topup): mirror settlement conversions in validation
This commit is contained in:
@@ -187,6 +187,66 @@ func getMinTopup() int64 {
|
|||||||
return int64(minTopup)
|
return int64(minTopup)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTopUpQuota(amount int64) (int, error) {
|
||||||
|
quota := decimal.NewFromInt(amount)
|
||||||
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
|
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||||
|
quota = decimal.NewFromInt(quota.Div(quotaPerUnit).IntPart()).Mul(quotaPerUnit)
|
||||||
|
} else {
|
||||||
|
quota = quota.Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||||
|
}
|
||||||
|
return common.QuotaFromDecimalStrict(quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMaxTopUpAmount() int64 {
|
||||||
|
if common.QuotaPerUnit <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||||
|
maxStoredAmount := decimal.NewFromInt(common.MaxQuota - 1).
|
||||||
|
Div(quotaPerUnit).
|
||||||
|
Floor()
|
||||||
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
|
return maxStoredAmount.Add(decimal.NewFromInt(1)).
|
||||||
|
Mul(quotaPerUnit).
|
||||||
|
Ceil().
|
||||||
|
Sub(decimal.NewFromInt(1)).
|
||||||
|
IntPart()
|
||||||
|
}
|
||||||
|
return maxStoredAmount.IntPart()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCreditedQuota(quota decimal.Decimal) error {
|
||||||
|
value, err := common.QuotaFromDecimalStrict(quota)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("充值额度超出系统可表示范围")
|
||||||
|
}
|
||||||
|
if value <= 0 {
|
||||||
|
return errors.New("充值额度必须大于 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTopUpQuota(amount int64) error {
|
||||||
|
quota, err := getTopUpQuota(amount)
|
||||||
|
if err == nil && quota > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
maxAmount := getMaxTopUpAmount()
|
||||||
|
if maxAmount > 0 && amount > maxAmount {
|
||||||
|
return fmt.Errorf("单笔充值数量不能大于 %d", maxAmount)
|
||||||
|
}
|
||||||
|
return errors.New("充值数量无效")
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectInvalidTopUpQuota(c *gin.Context, amount int64) bool {
|
||||||
|
if err := validateTopUpQuota(amount); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func RequestEpay(c *gin.Context) {
|
func RequestEpay(c *gin.Context) {
|
||||||
var req EpayRequest
|
var req EpayRequest
|
||||||
err := c.ShouldBindJSON(&req)
|
err := c.ShouldBindJSON(&req)
|
||||||
@@ -198,6 +258,9 @@ func RequestEpay(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if rejectInvalidTopUpQuota(c, req.Amount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
group, err := model.GetUserGroup(id, true)
|
group, err := model.GetUserGroup(id, true)
|
||||||
@@ -412,6 +475,9 @@ func RequestAmount(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if rejectInvalidTopUpQuota(c, req.Amount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
group, err := model.GetUserGroup(id, true)
|
group, err := model.GetUserGroup(id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
"github.com/thanhpk/randstr"
|
"github.com/thanhpk/randstr"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,6 +97,10 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateCreditedQuota(decimal.NewFromInt(selectedProduct.Quota)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
user, _ := model.GetUserById(id, false)
|
user, _ := model.GetUserById(id, false)
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTopUpQuotaValidation(t *testing.T) {
|
||||||
|
oldQuotaPerUnit := common.QuotaPerUnit
|
||||||
|
oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
|
||||||
|
common.QuotaPerUnit = 500000
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.QuotaPerUnit = oldQuotaPerUnit
|
||||||
|
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
|
||||||
|
})
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
displayType string
|
||||||
|
amount int64
|
||||||
|
wantQuota int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "currency amount below limit",
|
||||||
|
displayType: operation_setting.QuotaDisplayTypeUSD,
|
||||||
|
amount: 4294,
|
||||||
|
wantQuota: 2_147_000_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "currency amount above limit",
|
||||||
|
displayType: operation_setting.QuotaDisplayTypeUSD,
|
||||||
|
amount: 4295,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token amount preserves settlement truncation",
|
||||||
|
displayType: operation_setting.QuotaDisplayTypeTokens,
|
||||||
|
amount: common.MaxQuota,
|
||||||
|
wantQuota: 2_147_000_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token amount above settlement limit",
|
||||||
|
displayType: operation_setting.QuotaDisplayTypeTokens,
|
||||||
|
amount: 2_147_500_000,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
operation_setting.GetGeneralSetting().QuotaDisplayType = tc.displayType
|
||||||
|
quota, err := getTopUpQuota(tc.amount)
|
||||||
|
if tc.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.wantQuota, quota)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTopUpQuotaReturnsMaximumAmount(t *testing.T) {
|
||||||
|
oldQuotaPerUnit := common.QuotaPerUnit
|
||||||
|
oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
|
||||||
|
common.QuotaPerUnit = 500000
|
||||||
|
operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.QuotaPerUnit = oldQuotaPerUnit
|
||||||
|
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
|
||||||
|
})
|
||||||
|
|
||||||
|
maxAmount := decimal.NewFromInt(common.MaxQuota - 1).
|
||||||
|
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||||
|
Floor().IntPart()
|
||||||
|
|
||||||
|
require.NoError(t, validateTopUpQuota(maxAmount))
|
||||||
|
err := validateTopUpQuota(maxAmount + 1)
|
||||||
|
require.EqualError(t, err, "单笔充值数量不能大于 4294")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
|
||||||
|
oldQuotaPerUnit := common.QuotaPerUnit
|
||||||
|
oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
|
||||||
|
common.QuotaPerUnit = 500000
|
||||||
|
operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.QuotaPerUnit = oldQuotaPerUnit
|
||||||
|
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
|
||||||
|
})
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Request = httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/user/amount",
|
||||||
|
strings.NewReader(`{"amount":4295}`),
|
||||||
|
)
|
||||||
|
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
RequestAmount(ctx)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
assert.JSONEq(t, `{"message":"error","data":"单笔充值数量不能大于 4294"}`, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) {
|
||||||
|
require.NoError(t, validateCreditedQuota(decimal.NewFromInt(common.MaxQuota-1)))
|
||||||
|
require.EqualError(t, validateCreditedQuota(decimal.Zero), "充值额度必须大于 0")
|
||||||
|
require.EqualError(
|
||||||
|
t,
|
||||||
|
validateCreditedQuota(decimal.NewFromInt(common.MaxQuota)),
|
||||||
|
"充值额度超出系统可表示范围",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripeCreditedQuotaIncludesGroupRatio(t *testing.T) {
|
||||||
|
oldQuotaPerUnit := common.QuotaPerUnit
|
||||||
|
oldTopupGroupRatio := common.TopupGroupRatio2JSONString()
|
||||||
|
common.QuotaPerUnit = 500000
|
||||||
|
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"vip":2}`))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.QuotaPerUnit = oldQuotaPerUnit
|
||||||
|
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(oldTopupGroupRatio))
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, validateCreditedQuota(getStripeCreditedQuota(2147, "vip")))
|
||||||
|
require.Error(t, validateCreditedQuota(getStripeCreditedQuota(2148, "vip")))
|
||||||
|
|
||||||
|
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"free":0}`))
|
||||||
|
assert.True(t, decimal.NewFromInt(500000).Equal(getStripeCreditedQuota(1, "free")))
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
"github.com/stripe/stripe-go/v81"
|
"github.com/stripe/stripe-go/v81"
|
||||||
"github.com/stripe/stripe-go/v81/checkout/session"
|
"github.com/stripe/stripe-go/v81/checkout/session"
|
||||||
"github.com/stripe/stripe-go/v81/webhook"
|
"github.com/stripe/stripe-go/v81/webhook"
|
||||||
@@ -47,12 +48,20 @@ func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.Amount > 10000 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值数量不能大于 10000"})
|
||||||
|
return
|
||||||
|
}
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
group, err := model.GetUserGroup(id, true)
|
group, err := model.GetUserGroup(id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateCreditedQuota(getStripeCreditedQuota(req.Amount, group)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
payMoney := getStripePayMoney(float64(req.Amount), group)
|
payMoney := getStripePayMoney(float64(req.Amount), group)
|
||||||
if payMoney <= 0.01 {
|
if payMoney <= 0.01 {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||||
@@ -88,6 +97,12 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
|||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
user, _ := model.GetUserById(id, false)
|
user, _ := model.GetUserById(id, false)
|
||||||
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
|
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
|
||||||
|
if err := validateCreditedQuota(
|
||||||
|
decimal.NewFromFloat(chargedMoney).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||||
|
); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||||
referenceId := "ref_" + common.Sha1([]byte(reference))
|
referenceId := "ref_" + common.Sha1([]byte(reference))
|
||||||
@@ -394,6 +409,16 @@ func GetChargedAmount(count float64, user model.User) float64 {
|
|||||||
return count * topUpGroupRatio
|
return count * topUpGroupRatio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getStripeCreditedQuota(amount int64, group string) decimal.Decimal {
|
||||||
|
topUpGroupRatio := common.GetTopupGroupRatio(group)
|
||||||
|
if topUpGroupRatio == 0 {
|
||||||
|
topUpGroupRatio = 1
|
||||||
|
}
|
||||||
|
return decimal.NewFromInt(amount).
|
||||||
|
Mul(decimal.NewFromFloat(topUpGroupRatio)).
|
||||||
|
Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||||
|
}
|
||||||
|
|
||||||
func getStripePayMoney(amount float64, group string) float64 {
|
func getStripePayMoney(amount float64, group string) float64 {
|
||||||
originalAmount := amount
|
originalAmount := amount
|
||||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ func RequestWaffoAmount(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if rejectInvalidTopUpQuota(c, req.Amount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
group, err := model.GetUserGroup(id, true)
|
group, err := model.GetUserGroup(id, true)
|
||||||
@@ -157,6 +160,9 @@ func RequestWaffoPay(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if rejectInvalidTopUpQuota(c, req.Amount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
user, err := model.GetUserById(id, false)
|
user, err := model.GetUserById(id, false)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ func RequestWaffoPancakeAmount(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if rejectInvalidTopUpQuota(c, req.Amount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
group, err := model.GetUserGroup(id, true)
|
group, err := model.GetUserGroup(id, true)
|
||||||
@@ -351,6 +354,9 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if rejectInvalidTopUpQuota(c, req.Amount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
user, err := model.GetUserById(id, false)
|
user, err := model.GetUserById(id, false)
|
||||||
|
|||||||
Reference in New Issue
Block a user