From b50873d3342ce363a54b28729cea12a300822569 Mon Sep 17 00:00:00 2001 From: Uladzislau Kabiak Date: Fri, 28 Aug 2026 22:35:33 +0200 Subject: [PATCH] feat(payment): add trybit payment gateway Add Trybit payment gateway configuration, invoice creation, webhook callback verification, and wallet top-up flow across backend and frontend. --- controller/payment_webhook_availability.go | 20 + controller/topup.go | 22 + controller/topup_trybit.go | 472 ++++++++++++++++++ controller/topup_trybit_test.go | 208 ++++++++ model/option.go | 18 + model/topup.go | 99 ++++ router/api-router.go | 3 + setting/payment_trybit.go | 10 + .../system-settings/billing/index.tsx | 6 + .../billing/section-registry.tsx | 8 + .../integrations/payment-settings-section.tsx | 91 +++- .../integrations/trybit-settings-section.tsx | 119 +++++ web/src/features/system-settings/types.ts | 6 + web/src/features/wallet/api.ts | 26 + .../wallet/components/recharge-form-card.tsx | 3 +- web/src/features/wallet/constants.ts | 2 + .../features/wallet/hooks/use-payment.test.ts | 33 ++ web/src/features/wallet/hooks/use-payment.ts | 55 +- web/src/features/wallet/lib/payment.ts | 18 + web/src/features/wallet/types.ts | 17 + 20 files changed, 1214 insertions(+), 22 deletions(-) create mode 100644 controller/topup_trybit.go create mode 100644 controller/topup_trybit_test.go create mode 100644 setting/payment_trybit.go create mode 100644 web/src/features/system-settings/integrations/trybit-settings-section.tsx diff --git a/controller/payment_webhook_availability.go b/controller/payment_webhook_availability.go index aa26e5acf5ae..4a5687287d3e 100644 --- a/controller/payment_webhook_availability.go +++ b/controller/payment_webhook_availability.go @@ -92,6 +92,26 @@ func isWaffoPancakeWebhookEnabled() bool { return isWaffoPancakeTopUpEnabled() } +func isTrybitTopUpEnabled() bool { + if !isPaymentComplianceConfirmed() { + return false + } + if !setting.TrybitEnabled { + return false + } + return isTrybitWebhookConfigured() && + strings.TrimSpace(setting.TrybitApiKey) != "" && + strings.TrimSpace(setting.TrybitShopId) != "" +} + +func isTrybitWebhookConfigured() bool { + return strings.TrimSpace(setting.TrybitSecretKey) != "" +} + +func isTrybitWebhookEnabled() bool { + return isTrybitTopUpEnabled() +} + func isEpayTopUpEnabled() bool { if !isPaymentComplianceConfirmed() { return false diff --git a/controller/topup.go b/controller/topup.go index 30f8d2215784..98c7056beb9f 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -74,6 +74,26 @@ func GetTopUpInfo(c *gin.Context) { } } + enableTrybit := isTrybitTopUpEnabled() + if enableTrybit { + hasTrybit := false + for _, method := range payMethods { + if method["type"] == model.PaymentMethodTrybit { + hasTrybit = true + break + } + } + + if !hasTrybit { + payMethods = append(payMethods, map[string]string{ + "name": "Trybit (Crypto)", + "type": model.PaymentMethodTrybit, + "color": "#8B5CF6", + "min_topup": strconv.Itoa(setting.TrybitMinTopUp), + }) + } + } + // 如果启用了 Waffo 支付,添加到支付方法列表 enableWaffo := isWaffoTopUpEnabled() if enableWaffo { @@ -102,6 +122,7 @@ func GetTopUpInfo(c *gin.Context) { "enable_creem_topup": isCreemTopUpEnabled(), "enable_waffo_topup": enableWaffo, "enable_waffo_pancake_topup": enableWaffoPancake, + "enable_trybit_topup": enableTrybit, "enable_redemption": complianceConfirmed, "payment_compliance_confirmed": complianceConfirmed, "payment_compliance_terms_version": operation_setting.CurrentComplianceTermsVersion, @@ -117,6 +138,7 @@ func GetTopUpInfo(c *gin.Context) { "stripe_min_topup": setting.StripeMinTopUp, "waffo_min_topup": setting.WaffoMinTopUp, "waffo_pancake_min_topup": setting.WaffoPancakeMinTopUp, + "trybit_min_topup": setting.TrybitMinTopUp, "amount_options": operation_setting.GetPaymentSetting().AmountOptions, "discount": operation_setting.GetPaymentSetting().AmountDiscount, "topup_link": common.TopUpLink, diff --git a/controller/topup_trybit.go b/controller/topup_trybit.go new file mode 100644 index 000000000000..3e4fd680e4fc --- /dev/null +++ b/controller/topup_trybit.go @@ -0,0 +1,472 @@ +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/shopspring/decimal" + "github.com/thanhpk/randstr" +) + +const ( + trybitInvoiceCreateURL = "https://api.trybit.com/v2/invoice/create" + trybitInvoiceInfoURL = "https://api.trybit.com/v2/invoice/merchant/info" + trybitInvoiceCurrency = "USD" + trybitStatusSuccess = "success" + trybitInvoiceStatusPaid = "paid" + trybitInvoiceStatusOverpaid = "overpaid" + trybitInvoiceStatusCanceled = "canceled" + trybitInvoiceIdPrefix = "INV-" + trybitExpiryLayout = "2006-01-02 15:04:05.999999" + trybitRequestTimeout = 30 * time.Second + trybitResponseLimit = 1 << 20 + trybitCallbackTokenTTL = 5 * time.Minute + trybitCallbackLeeway = 30 * time.Second +) + +type TrybitPayRequest struct { + Amount int64 `json:"amount"` +} + +type trybitInvoice struct { + InvoiceId string + PayLink string + ExpiresAt int64 +} + +type trybitInvoiceCreateRequest struct { + ShopId string `json:"shop_id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + OrderId string `json:"order_id"` + Email string `json:"email,omitempty"` +} + +type trybitInvoiceCreateResponse struct { + Status string `json:"status"` + Result struct { + Uuid string `json:"uuid"` + Link string `json:"link"` + ExpiryDate string `json:"expiry_date"` + } `json:"result"` +} + +// The callback token only ever carries the invoice id, so that is the single +// identifier it can bind; the order is bound to the invoice by our own stored +// provider ref rather than by anything the callback claims. +type trybitCallbackClaims struct { + jwt.RegisteredClaims + Id string `json:"id"` +} + +type trybitInvoiceInfoRequest struct { + Uuids []string `json:"uuids"` +} + +type trybitInvoiceInfoResponse struct { + Status string `json:"status"` + Result []struct { + Uuid string `json:"uuid"` + Status string `json:"status"` + OrderId string `json:"order_id"` + } `json:"result"` +} + +type trybitNotifyRequest struct { + Status string `json:"status" form:"status"` + InvoiceId string `json:"invoice_id" form:"invoice_id"` + OrderId string `json:"order_id" form:"order_id"` + Token string `json:"token" form:"token"` + Currency string `json:"currency" form:"currency"` + AmountCrypto float64 `json:"amount_crypto" form:"amount_crypto"` +} + +func RequestTrybitAmount(c *gin.Context) { + var req TrybitPayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + + if req.Amount < int64(setting.TrybitMinTopUp) { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.TrybitMinTopUp)}) + return + } + + id := c.GetInt("id") + if rejectInvalidTopUpQuota(c, id, req.Amount) { + return + } + + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + + payMoney := getTrybitPayMoney(req.Amount, group) + if payMoney <= 0.01 { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "success", "data": decimal.NewFromFloat(payMoney).StringFixed(2)}) +} + +func RequestTrybitPay(c *gin.Context) { + var req TrybitPayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + + ctx := c.Request.Context() + + if !isTrybitTopUpEnabled() { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Trybit 支付未启用"}) + return + } + + if req.Amount < int64(setting.TrybitMinTopUp) { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.TrybitMinTopUp)}) + return + } + + id := c.GetInt("id") + if rejectInvalidTopUpQuota(c, id, req.Amount) { + return + } + + user, err := model.GetUserById(id, false) + if err != nil || user == nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"}) + return + } + + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + + payMoney := getTrybitPayMoney(req.Amount, group) + if payMoney <= 0.01 { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + tradeNo := fmt.Sprintf("TRYBIT-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6)) + + topUp := &model.TopUp{ + UserId: id, + Amount: normalizeTrybitTopUpAmount(req.Amount), + Money: payMoney, + TradeNo: tradeNo, + PaymentMethod: model.PaymentMethodTrybit, + PaymentProvider: model.PaymentProviderTrybit, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := topUp.Insert(); err != nil { + logger.LogError(ctx, fmt.Sprintf("Trybit 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error())) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + invoice, err := genTrybitInvoice(ctx, tradeNo, payMoney, user.Email) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Trybit 创建发票失败 user_id=%d trade_no=%s money=%.2f error=%q", id, tradeNo, payMoney, err.Error())) + markTrybitTopUpFailed(ctx, tradeNo) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + if err := model.BindTopUpProviderRef(tradeNo, invoice.InvoiceId, invoice.ExpiresAt); err != nil { + logger.LogError(ctx, fmt.Sprintf("Trybit 绑定发票信息失败 trade_no=%s invoice_id=%s error=%q", tradeNo, invoice.InvoiceId, err.Error())) + markTrybitTopUpFailed(ctx, tradeNo) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + logger.LogInfo(ctx, fmt.Sprintf("Trybit 充值订单创建成功 user_id=%d trade_no=%s invoice_id=%s amount=%d money=%.2f expires_at=%d", id, tradeNo, invoice.InvoiceId, topUp.Amount, payMoney, invoice.ExpiresAt)) + + c.JSON(http.StatusOK, gin.H{ + "message": "success", + "data": gin.H{ + "pay_link": invoice.PayLink, + "order_id": tradeNo, + "expires_at": invoice.ExpiresAt, + }, + }) +} + +func markTrybitTopUpFailed(ctx context.Context, tradeNo string) { + if err := model.UpdatePendingTopUpStatus(tradeNo, model.PaymentProviderTrybit, common.TopUpStatusFailed); err != nil && + !errors.Is(err, model.ErrTopUpNotFound) && + !errors.Is(err, model.ErrTopUpStatusInvalid) { + logger.LogError(ctx, fmt.Sprintf("Trybit 标记充值订单失败状态失败 trade_no=%s error=%q", tradeNo, err.Error())) + } +} + +func postTrybit(ctx context.Context, url string, payload any, out any) ([]byte, error) { + body, err := common.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Token "+strings.TrimSpace(setting.TrybitApiKey)) + + client := &http.Client{Timeout: trybitRequestTimeout} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, trybitResponseLimit)) + if err != nil { + return nil, err + } + if resp.StatusCode/100 != 2 { + return respBody, fmt.Errorf("trybit http status %d body %q", resp.StatusCode, string(respBody)) + } + if err := common.Unmarshal(respBody, out); err != nil { + return respBody, err + } + return respBody, nil +} + +// The callback only proves which invoice it refers to, so both the payment state +// and the invoice/order binding are read back from Trybit with our own api key +// instead of being taken from what the callback claims. +func fetchTrybitInvoiceStatus(ctx context.Context, invoiceId string, orderId string) (string, error) { + var parsed trybitInvoiceInfoResponse + respBody, err := postTrybit(ctx, trybitInvoiceInfoURL, &trybitInvoiceInfoRequest{Uuids: []string{invoiceId}}, &parsed) + if err != nil { + return "", err + } + if parsed.Status != trybitStatusSuccess { + return "", fmt.Errorf("trybit invoice info status %q body %q", parsed.Status, string(respBody)) + } + + for _, item := range parsed.Result { + if normalizeTrybitInvoiceId(item.Uuid) != normalizeTrybitInvoiceId(invoiceId) { + continue + } + if item.OrderId != orderId { + return "", fmt.Errorf("trybit invoice %q carries order id %q not %q", invoiceId, item.OrderId, orderId) + } + return item.Status, nil + } + return "", fmt.Errorf("trybit invoice %q missing from merchant info body %q", invoiceId, string(respBody)) +} + +func genTrybitInvoice(ctx context.Context, tradeNo string, payMoney float64, email string) (*trybitInvoice, error) { + var parsed trybitInvoiceCreateResponse + respBody, err := postTrybit(ctx, trybitInvoiceCreateURL, &trybitInvoiceCreateRequest{ + ShopId: strings.TrimSpace(setting.TrybitShopId), + Amount: payMoney, + Currency: trybitInvoiceCurrency, + OrderId: tradeNo, + Email: email, + }, &parsed) + if err != nil { + return nil, err + } + if parsed.Status != trybitStatusSuccess { + return nil, fmt.Errorf("trybit invoice status %q body %q", parsed.Status, string(respBody)) + } + if parsed.Result.Uuid == "" || parsed.Result.Link == "" { + return nil, fmt.Errorf("trybit invoice missing uuid or link body %q", string(respBody)) + } + + expiry, err := time.ParseInLocation(trybitExpiryLayout, parsed.Result.ExpiryDate, time.UTC) + if err != nil { + return nil, fmt.Errorf("trybit invoice expiry %q unparsable: %w", parsed.Result.ExpiryDate, err) + } + + return &trybitInvoice{ + InvoiceId: parsed.Result.Uuid, + PayLink: parsed.Result.Link, + ExpiresAt: expiry.Unix(), + }, nil +} + +func TrybitNotify(c *gin.Context) { + ctx := c.Request.Context() + callerIp := c.ClientIP() + + if !isTrybitWebhookEnabled() { + logger.LogWarn(ctx, fmt.Sprintf("Trybit 回调被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, callerIp)) + c.AbortWithStatus(http.StatusForbidden) + return + } + + var req trybitNotifyRequest + if err := c.ShouldBind(&req); err != nil { + logger.LogWarn(ctx, fmt.Sprintf("Trybit 回调解析失败 client_ip=%s error=%q", callerIp, err.Error())) + c.AbortWithStatus(http.StatusBadRequest) + return + } + if req.OrderId == "" || req.InvoiceId == "" || req.Token == "" { + logger.LogWarn(ctx, fmt.Sprintf("Trybit 回调字段缺失 client_ip=%s order_id=%s invoice_id=%s has_token=%t", callerIp, req.OrderId, req.InvoiceId, req.Token != "")) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + if err := verifyTrybitCallbackToken(strings.TrimSpace(setting.TrybitSecretKey), &req); err != nil { + logger.LogWarn(ctx, fmt.Sprintf("Trybit 回调验签失败 client_ip=%s order_id=%s invoice_id=%s error=%q", callerIp, req.OrderId, req.InvoiceId, err.Error())) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + LockOrder(req.OrderId) + defer UnlockOrder(req.OrderId) + + topUp := model.GetTopUpByTradeNo(req.OrderId) + if topUp == nil { + logger.LogWarn(ctx, fmt.Sprintf("Trybit 回调对应订单不存在 client_ip=%s order_id=%s invoice_id=%s", callerIp, req.OrderId, req.InvoiceId)) + c.Status(http.StatusOK) + return + } + if topUp.PaymentProvider != model.PaymentProviderTrybit { + logger.LogWarn(ctx, fmt.Sprintf("Trybit 回调订单支付网关不匹配 client_ip=%s order_id=%s payment_provider=%s", callerIp, req.OrderId, topUp.PaymentProvider)) + c.Status(http.StatusOK) + return + } + // A failed provider ref bind leaves a live, payable invoice attached to an + // order that never stored its id, so an empty ref falls back to the callback + // and is validated against the invoice's own order id instead. + invoiceRef := topUp.ProviderRef + if invoiceRef == "" { + invoiceRef = trybitInvoiceIdPrefix + normalizeTrybitInvoiceId(req.InvoiceId) + } else if normalizeTrybitInvoiceId(invoiceRef) != normalizeTrybitInvoiceId(req.InvoiceId) { + logger.LogWarn(ctx, fmt.Sprintf("Trybit 回调发票号不匹配 client_ip=%s order_id=%s provider_ref=%s invoice_id=%s", callerIp, req.OrderId, topUp.ProviderRef, req.InvoiceId)) + c.Status(http.StatusOK) + return + } + + invoiceStatus, err := fetchTrybitInvoiceStatus(ctx, invoiceRef, req.OrderId) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Trybit 回调查询发票状态失败 client_ip=%s order_id=%s invoice_id=%s error=%q", callerIp, req.OrderId, req.InvoiceId, err.Error())) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + if invoiceStatus != trybitInvoiceStatusPaid && invoiceStatus != trybitInvoiceStatusOverpaid { + expired := topUp.ExpiresAt > 0 && time.Now().Unix() > topUp.ExpiresAt + if invoiceStatus == trybitInvoiceStatusCanceled || expired { + logger.LogWarn(ctx, fmt.Sprintf("Trybit 发票已终止,不入账 client_ip=%s order_id=%s invoice_id=%s invoice_status=%s expired=%t", callerIp, req.OrderId, req.InvoiceId, invoiceStatus, expired)) + markTrybitTopUpFailed(ctx, req.OrderId) + c.Status(http.StatusOK) + return + } + // Crypto confirmation lags the callback, so acknowledging a still-open + // invoice would burn the only delivery Trybit makes for it. + logger.LogWarn(ctx, fmt.Sprintf("Trybit 发票未支付,等待重投 client_ip=%s order_id=%s invoice_id=%s invoice_status=%s callback_status=%s amount_crypto=%v currency=%s", callerIp, req.OrderId, req.InvoiceId, invoiceStatus, req.Status, req.AmountCrypto, req.Currency)) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + alreadyDone, err := model.RechargeTrybit(req.OrderId, callerIp) + if err != nil { + if errors.Is(err, model.ErrTopUpNotFound) || errors.Is(err, model.ErrPaymentMethodMismatch) || errors.Is(err, model.ErrTopUpStatusInvalid) { + logger.LogWarn(ctx, fmt.Sprintf("Trybit 回调订单不可入账 client_ip=%s order_id=%s error=%q", callerIp, req.OrderId, err.Error())) + c.Status(http.StatusOK) + return + } + // Anything else may be transient, so fail loudly and let Trybit redeliver. + logger.LogError(ctx, fmt.Sprintf("Trybit 回调入账失败 client_ip=%s order_id=%s invoice_id=%s error=%q", callerIp, req.OrderId, req.InvoiceId, err.Error())) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + if alreadyDone { + logger.LogInfo(ctx, fmt.Sprintf("Trybit 回调重复投递,订单已入账 client_ip=%s order_id=%s invoice_id=%s", callerIp, req.OrderId, req.InvoiceId)) + } else { + logger.LogInfo(ctx, fmt.Sprintf("Trybit 回调入账成功 client_ip=%s order_id=%s invoice_id=%s amount_crypto=%v currency=%s", callerIp, req.OrderId, req.InvoiceId, req.AmountCrypto, req.Currency)) + } + c.Status(http.StatusOK) +} + +func verifyTrybitCallbackToken(secret string, req *trybitNotifyRequest) error { + claims := &trybitCallbackClaims{} + if _, err := jwt.ParseWithClaims(req.Token, claims, func(*jwt.Token) (any, error) { + return []byte(secret), nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithExpirationRequired(), jwt.WithLeeway(trybitCallbackLeeway)); err != nil { + return fmt.Errorf("token rejected: %w", err) + } + // ParseWithClaims only rejects an expired token; bounding how far ahead exp + // sits keeps the documented five-minute replay window from being widened by + // a long-lived token. + if claims.ExpiresAt == nil || time.Until(claims.ExpiresAt.Time) > trybitCallbackTokenTTL+trybitCallbackLeeway { + return errors.New("token expiry out of range") + } + if normalizeTrybitInvoiceId(claims.Id) == "" { + return errors.New("token carries no invoice id") + } + if normalizeTrybitInvoiceId(claims.Id) != normalizeTrybitInvoiceId(req.InvoiceId) { + return fmt.Errorf("token invoice id %q does not match callback invoice id %q", claims.Id, req.InvoiceId) + } + return nil +} + +func normalizeTrybitInvoiceId(invoiceId string) string { + return strings.TrimPrefix(invoiceId, trybitInvoiceIdPrefix) +} + +func getTrybitPayMoney(amount int64, group string) float64 { + dAmount := decimal.NewFromInt(amount) + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + dAmount = dAmount.Div(decimal.NewFromFloat(common.QuotaPerUnit)).Truncate(0) + } + + topupGroupRatio := common.GetTopupGroupRatio(group) + if topupGroupRatio == 0 { + topupGroupRatio = 1 + } + + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok && ds > 0 { + discount = ds + } + + return dAmount. + Mul(decimal.NewFromFloat(setting.TrybitUnitPrice)). + Mul(decimal.NewFromFloat(topupGroupRatio)). + Mul(decimal.NewFromFloat(discount)). + Round(2). + InexactFloat64() +} + +func normalizeTrybitTopUpAmount(amount int64) int64 { + if operation_setting.GetQuotaDisplayType() != operation_setting.QuotaDisplayTypeTokens { + return amount + } + + normalized := decimal.NewFromInt(amount). + Div(decimal.NewFromFloat(common.QuotaPerUnit)). + IntPart() + if normalized < 1 { + return 1 + } + return normalized +} diff --git a/controller/topup_trybit_test.go b/controller/topup_trybit_test.go new file mode 100644 index 000000000000..e9f22cb789f7 --- /dev/null +++ b/controller/topup_trybit_test.go @@ -0,0 +1,208 @@ +package controller + +import ( + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const trybitTestSecret = "trybit-test-secret" + +func signTrybitToken(t *testing.T, secret string, method jwt.SigningMethod, claims jwt.Claims) string { + t.Helper() + token := jwt.NewWithClaims(method, claims) + var key any = []byte(secret) + if method == jwt.SigningMethodNone { + key = jwt.UnsafeAllowNoneSignatureType + } + signed, err := token.SignedString(key) + require.NoError(t, err) + return signed +} + +func trybitTokenClaims(invoiceId string, expiresIn time.Duration) jwt.Claims { + return &trybitCallbackClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), + }, + Id: invoiceId, + } +} + +func TestVerifyTrybitCallbackToken(t *testing.T) { + cases := []struct { + name string + token func(t *testing.T) string + invoiceId string + wantErr string + }{ + { + name: "accepts a token bound to the callback invoice", + token: func(t *testing.T) string { + return signTrybitToken(t, trybitTestSecret, jwt.SigningMethodHS256, trybitTokenClaims("3ZTNHA3W", 5*time.Minute)) + }, + invoiceId: "3ZTNHA3W", + }, + { + name: "accepts a prefixed invoice id on either side", + token: func(t *testing.T) string { + return signTrybitToken(t, trybitTestSecret, jwt.SigningMethodHS256, trybitTokenClaims("INV-3ZTNHA3W", 5*time.Minute)) + }, + invoiceId: "3ZTNHA3W", + }, + { + name: "rejects a token signed with another secret", + token: func(t *testing.T) string { + return signTrybitToken(t, "other-secret", jwt.SigningMethodHS256, trybitTokenClaims("3ZTNHA3W", 5*time.Minute)) + }, + invoiceId: "3ZTNHA3W", + wantErr: "token rejected", + }, + { + name: "rejects an unsigned token", + token: func(t *testing.T) string { + return signTrybitToken(t, trybitTestSecret, jwt.SigningMethodNone, trybitTokenClaims("3ZTNHA3W", 5*time.Minute)) + }, + invoiceId: "3ZTNHA3W", + wantErr: "token rejected", + }, + { + name: "rejects an expired token", + token: func(t *testing.T) string { + return signTrybitToken(t, trybitTestSecret, jwt.SigningMethodHS256, trybitTokenClaims("3ZTNHA3W", -2*time.Minute)) + }, + invoiceId: "3ZTNHA3W", + wantErr: "token rejected", + }, + { + name: "rejects a token without an expiry", + token: func(t *testing.T) string { + return signTrybitToken(t, trybitTestSecret, jwt.SigningMethodHS256, &trybitCallbackClaims{Id: "3ZTNHA3W"}) + }, + invoiceId: "3ZTNHA3W", + wantErr: "token rejected", + }, + { + name: "rejects a token that outlives the replay window", + token: func(t *testing.T) string { + return signTrybitToken(t, trybitTestSecret, jwt.SigningMethodHS256, trybitTokenClaims("3ZTNHA3W", 24*time.Hour)) + }, + invoiceId: "3ZTNHA3W", + wantErr: "token expiry out of range", + }, + { + name: "rejects a token that binds no invoice", + token: func(t *testing.T) string { + return signTrybitToken(t, trybitTestSecret, jwt.SigningMethodHS256, trybitTokenClaims("", 5*time.Minute)) + }, + invoiceId: "3ZTNHA3W", + wantErr: "token carries no invoice id", + }, + { + name: "rejects a token issued for another invoice", + token: func(t *testing.T) string { + return signTrybitToken(t, trybitTestSecret, jwt.SigningMethodHS256, trybitTokenClaims("OTHER123", 5*time.Minute)) + }, + invoiceId: "3ZTNHA3W", + wantErr: "does not match callback invoice id", + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + req := &trybitNotifyRequest{ + Status: trybitStatusSuccess, + InvoiceId: testCase.invoiceId, + OrderId: "TRYBIT-1-1787930619786-qho0pq", + Token: testCase.token(t), + } + + err := verifyTrybitCallbackToken(trybitTestSecret, req) + + if testCase.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), testCase.wantErr) + }) + } +} + +func TestNormalizeTrybitInvoiceId(t *testing.T) { + assert.Equal(t, "3ZTNHA3W", normalizeTrybitInvoiceId("INV-3ZTNHA3W")) + assert.Equal(t, "3ZTNHA3W", normalizeTrybitInvoiceId("3ZTNHA3W")) + assert.Equal(t, "", normalizeTrybitInvoiceId("")) +} + +func TestTrybitPayMoneyMatchesCreditedUnits(t *testing.T) { + general := operation_setting.GetGeneralSetting() + payment := operation_setting.GetPaymentSetting() + originalDisplay := general.QuotaDisplayType + originalQuotaPerUnit := common.QuotaPerUnit + originalUnitPrice := setting.TrybitUnitPrice + originalDiscount := payment.AmountDiscount + t.Cleanup(func() { + general.QuotaDisplayType = originalDisplay + common.QuotaPerUnit = originalQuotaPerUnit + setting.TrybitUnitPrice = originalUnitPrice + payment.AmountDiscount = originalDiscount + }) + + common.QuotaPerUnit = 500000 + setting.TrybitUnitPrice = 2 + payment.AmountDiscount = map[int]float64{} + + cases := []struct { + name string + displayType string + amount int64 + wantUnits int64 + wantMoney float64 + }{ + { + name: "tokens amount on a unit boundary", + displayType: operation_setting.QuotaDisplayTypeTokens, + amount: 2500000, + wantUnits: 5, + wantMoney: 10, + }, + { + name: "tokens amount with a fractional unit is charged as the credited units", + displayType: operation_setting.QuotaDisplayTypeTokens, + amount: 2600000, + wantUnits: 5, + wantMoney: 10, + }, + { + name: "tokens amount below one unit is priced at zero so the handler rejects it", + displayType: operation_setting.QuotaDisplayTypeTokens, + amount: 100000, + wantUnits: 1, + wantMoney: 0, + }, + { + name: "currency amount is charged per credited unit", + displayType: operation_setting.QuotaDisplayTypeUSD, + amount: 5, + wantUnits: 5, + wantMoney: 10, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + general.QuotaDisplayType = testCase.displayType + + assert.Equal(t, testCase.wantUnits, normalizeTrybitTopUpAmount(testCase.amount)) + assert.Equal(t, testCase.wantMoney, getTrybitPayMoney(testCase.amount, "default")) + }) + } +} diff --git a/model/option.go b/model/option.go index d78706537a80..71b8c075bb8e 100644 --- a/model/option.go +++ b/model/option.go @@ -116,6 +116,12 @@ func InitOptionMap() { common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp) common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID + common.OptionMap["TrybitEnabled"] = strconv.FormatBool(setting.TrybitEnabled) + common.OptionMap["TrybitApiKey"] = setting.TrybitApiKey + common.OptionMap["TrybitSecretKey"] = setting.TrybitSecretKey + common.OptionMap["TrybitShopId"] = setting.TrybitShopId + common.OptionMap["TrybitUnitPrice"] = strconv.FormatFloat(setting.TrybitUnitPrice, 'f', -1, 64) + common.OptionMap["TrybitMinTopUp"] = strconv.Itoa(setting.TrybitMinTopUp) common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -494,6 +500,18 @@ func updateOptionMap(key string, value string) (err error) { setting.WaffoPancakeStoreID = value case "WaffoPancakeProductID": setting.WaffoPancakeProductID = value + case "TrybitEnabled": + setting.TrybitEnabled = value == "true" + case "TrybitApiKey": + setting.TrybitApiKey = value + case "TrybitSecretKey": + setting.TrybitSecretKey = value + case "TrybitShopId": + setting.TrybitShopId = value + case "TrybitUnitPrice": + setting.TrybitUnitPrice, _ = strconv.ParseFloat(value, 64) + case "TrybitMinTopUp": + setting.TrybitMinTopUp, _ = strconv.Atoi(value) case "WaffoPancakeUnitPrice": setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64) case "WaffoPancakeMinTopUp": diff --git a/model/topup.go b/model/topup.go index a8b91b77500e..6c9744948b04 100644 --- a/model/topup.go +++ b/model/topup.go @@ -17,9 +17,11 @@ type TopUp struct { Amount int64 `json:"amount"` Money float64 `json:"money"` TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` + ProviderRef string `json:"provider_ref" gorm:"type:varchar(255);default:''"` PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` PaymentProvider string `json:"payment_provider" gorm:"type:varchar(50);default:''"` CreateTime int64 `json:"create_time"` + ExpiresAt int64 `json:"expires_at"` CompleteTime int64 `json:"complete_time"` Status string `json:"status"` } @@ -29,6 +31,7 @@ const ( PaymentMethodCreem = "creem" PaymentMethodWaffo = "waffo" PaymentMethodWaffoPancake = "waffo_pancake" + PaymentMethodTrybit = "trybit" PaymentMethodBalance = "balance" ) @@ -38,6 +41,7 @@ const ( PaymentProviderCreem = "creem" PaymentProviderWaffo = "waffo" PaymentProviderWaffoPancake = "waffo_pancake" + PaymentProviderTrybit = "trybit" PaymentProviderBalance = "balance" ) @@ -123,6 +127,36 @@ func (topUp *TopUp) Update() error { return err } +// BindTopUpProviderRef only touches a pending order, so a callback that has +// already settled it cannot be overwritten by a late checkout response. +func BindTopUpProviderRef(tradeNo string, providerRef string, expiresAt int64) error { + if tradeNo == "" { + return errors.New("未提供支付单号") + } + if providerRef == "" { + return errors.New("未提供支付方订单号") + } + + refCol := "`trade_no`" + if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) { + refCol = `"trade_no"` + } + + result := DB.Model(&TopUp{}). + Where(refCol+" = ? AND status = ?", tradeNo, common.TopUpStatusPending). + Updates(map[string]interface{}{ + "provider_ref": providerRef, + "expires_at": expiresAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrTopUpStatusInvalid + } + return nil +} + func GetTopUpById(id int) *TopUp { var topUp *TopUp var err error @@ -713,3 +747,68 @@ func RechargeWaffoPancake(tradeNo string) (err error) { return nil } + +// RechargeTrybit credits the order from the amount stored on our own pending +// row, never from the callback body, and the row lock plus the pending-status +// check make a redelivered callback idempotent. +func RechargeTrybit(tradeNo string, callerIp string) (alreadyDone bool, err error) { + if tradeNo == "" { + return false, errors.New("未提供支付单号") + } + + var quotaToAdd int + topUp := &TopUp{} + + refCol := "`trade_no`" + if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + if err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrTopUpNotFound + } + return err + } + if topUp.PaymentProvider != PaymentProviderTrybit { + return ErrPaymentMethodMismatch + } + if topUp.Status == common.TopUpStatusSuccess { + alreadyDone = true + return nil + } + if topUp.Status != common.TopUpStatusPending { + return ErrTopUpStatusInvalid + } + + var quotaErr error + quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict( + decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)), + ) + if quotaErr != nil || quotaToAdd <= 0 { + return ErrInvalidTopUpQuota + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + return err + } + return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil) + }) + if err != nil { + if !errors.Is(err, ErrTopUpNotFound) && !errors.Is(err, ErrPaymentMethodMismatch) && !errors.Is(err, ErrTopUpStatusInvalid) { + common.SysError("trybit topup failed: " + err.Error()) + } + return false, err + } + if alreadyDone { + return true, nil + } + syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, "trybit topup") + + common.SysLog(fmt.Sprintf("Trybit 充值成功 trade_no=%s user_id=%d quota_to_add=%d money=%.2f", topUp.TradeNo, topUp.UserId, quotaToAdd, topUp.Money)) + RecordTopupLog(topUp.UserId, fmt.Sprintf("使用 Trybit 充值成功,充值额度: %v,支付金额:%.2f", logger.LogQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentProviderTrybit) + return false, nil +} diff --git a/router/api-router.go b/router/api-router.go index a9d75be4a677..0e32db44bc3a 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -77,6 +77,7 @@ func SetApiRouter(router *gin.Engine) { //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) userRoute.POST("/epay/notify", anonymousRequestBodyLimit, controller.EpayNotify) userRoute.GET("/epay/notify", controller.EpayNotify) + userRoute.POST("/trybit/notify", anonymousRequestBodyLimit, controller.TrybitNotify) userRoute.GET("/groups", controller.GetUserGroups) selfRoute := userRoute.Group("/") @@ -110,6 +111,8 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay) selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay) + selfRoute.POST("/trybit/amount", controller.RequestTrybitAmount) + selfRoute.POST("/trybit/pay", middleware.CriticalRateLimit(), controller.RequestTrybitPay) selfRoute.POST("/aff_transfer", middleware.UserCriticalRateLimit("aff-transfer"), controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) diff --git a/setting/payment_trybit.go b/setting/payment_trybit.go new file mode 100644 index 000000000000..2ab3315b7db8 --- /dev/null +++ b/setting/payment_trybit.go @@ -0,0 +1,10 @@ +package setting + +var ( + TrybitEnabled bool + TrybitApiKey string + TrybitSecretKey string + TrybitShopId string + TrybitUnitPrice float64 = 1.0 + TrybitMinTopUp int = 5 +) diff --git a/web/src/features/system-settings/billing/index.tsx b/web/src/features/system-settings/billing/index.tsx index a49bd0a85d85..263c502f112f 100644 --- a/web/src/features/system-settings/billing/index.tsx +++ b/web/src/features/system-settings/billing/index.tsx @@ -103,6 +103,12 @@ const defaultBillingSettings: BillingSettings = { WaffoPancakeReturnURL: '', WaffoPancakeStoreID: '', WaffoPancakeProductID: '', + TrybitEnabled: false, + TrybitApiKey: '', + TrybitSecretKey: '', + TrybitShopId: '', + TrybitUnitPrice: 1, + TrybitMinTopUp: 5, 'checkin_setting.enabled': false, 'checkin_setting.min_quota': 1000, 'checkin_setting.max_quota': 10000, diff --git a/web/src/features/system-settings/billing/section-registry.tsx b/web/src/features/system-settings/billing/section-registry.tsx index 43cd0e986b91..bade40a1fa5b 100644 --- a/web/src/features/system-settings/billing/section-registry.tsx +++ b/web/src/features/system-settings/billing/section-registry.tsx @@ -178,6 +178,14 @@ const BILLING_SECTIONS = [ }} waffoPancakeProvisionedStoreID={settings.WaffoPancakeStoreID ?? ''} waffoPancakeProvisionedProductID={settings.WaffoPancakeProductID ?? ''} + trybitDefaultValues={{ + TrybitEnabled: settings.TrybitEnabled ?? false, + TrybitApiKey: settings.TrybitApiKey ?? '', + TrybitSecretKey: settings.TrybitSecretKey ?? '', + TrybitShopId: settings.TrybitShopId ?? '', + TrybitUnitPrice: settings.TrybitUnitPrice ?? 1, + TrybitMinTopUp: settings.TrybitMinTopUp ?? 5, + }} complianceDefaults={{ confirmed: settings['payment_setting.compliance_confirmed'] ?? false, termsVersion: diff --git a/web/src/features/system-settings/integrations/payment-settings-section.tsx b/web/src/features/system-settings/integrations/payment-settings-section.tsx index 17f3d0b688a2..cbb97db46d2c 100644 --- a/web/src/features/system-settings/integrations/payment-settings-section.tsx +++ b/web/src/features/system-settings/integrations/payment-settings-section.tsx @@ -62,6 +62,10 @@ import { AmountDiscountVisualEditor } from './amount-discount-visual-editor' import { AmountOptionsVisualEditor } from './amount-options-visual-editor' import { CreemProductsVisualEditor } from './creem-products-visual-editor' import { PaymentMethodsVisualEditor } from './payment-methods-visual-editor' +import { + TrybitSettingsSection, + type TrybitSettingsValues, +} from './trybit-settings-section' import { formatJsonForEditor, getJsonError, @@ -176,13 +180,21 @@ const paymentSchema = z.object({ WaffoPancakeMerchantID: z.string(), WaffoPancakePrivateKey: z.string(), WaffoPancakeReturnURL: z.string(), + TrybitEnabled: z.boolean(), + TrybitApiKey: z.string(), + TrybitSecretKey: z.string(), + TrybitShopId: z.string(), + TrybitUnitPrice: z.coerce.number().min(0), + TrybitMinTopUp: z.coerce.number().min(1), }) type PaymentFormValues = z.infer type WaffoFormFieldValues = Omit type PaymentBaseFormValues = Omit< PaymentFormValues, - keyof WaffoFormFieldValues | keyof WaffoPancakeSettingsValues + | keyof WaffoFormFieldValues + | keyof WaffoPancakeSettingsValues + | keyof TrybitSettingsValues > const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1' @@ -201,6 +213,7 @@ type PaymentSettingsSectionProps = { waffoPancakeDefaultValues: WaffoPancakeSettingsValues waffoPancakeProvisionedStoreID?: string waffoPancakeProvisionedProductID?: string + trybitDefaultValues: TrybitSettingsValues complianceDefaults: PaymentComplianceDefaults } @@ -219,6 +232,7 @@ export function PaymentSettingsSection({ waffoPancakeDefaultValues, waffoPancakeProvisionedStoreID, waffoPancakeProvisionedProductID, + trybitDefaultValues, complianceDefaults, }: PaymentSettingsSectionProps) { const { t } = useTranslation() @@ -229,8 +243,14 @@ export function PaymentSettingsSection({ ...defaultValues, ...waffoDefaultValues, ...waffoPancakeDefaultValues, + ...trybitDefaultValues, }), - [defaultValues, waffoDefaultValues, waffoPancakeDefaultValues] + [ + defaultValues, + waffoDefaultValues, + waffoPancakeDefaultValues, + trybitDefaultValues, + ] ) const initialRef = React.useRef(initialFormValues) const defaultsSignature = React.useMemo( @@ -404,6 +424,19 @@ export function PaymentSettingsSection({ [setPaymentValue] ) + const setTrybitValue = React.useCallback( + ( + key: K, + value: TrybitSettingsValues[K] + ) => { + setPaymentValue( + key as keyof PaymentFormValues, + value as PaymentFormValues[keyof PaymentFormValues] + ) + }, + [setPaymentValue] + ) + React.useEffect(() => { const parsedDefaults = JSON.parse(defaultsSignature) as PaymentFormValues initialRef.current = parsedDefaults @@ -457,6 +490,12 @@ export function PaymentSettingsSection({ WaffoPancakeReturnURL: removeTrailingSlash( values.WaffoPancakeReturnURL.trim() ), + TrybitEnabled: values.TrybitEnabled, + TrybitApiKey: values.TrybitApiKey.trim(), + TrybitSecretKey: values.TrybitSecretKey.trim(), + TrybitShopId: values.TrybitShopId.trim(), + TrybitUnitPrice: values.TrybitUnitPrice, + TrybitMinTopUp: values.TrybitMinTopUp, } const initial = { @@ -504,6 +543,10 @@ export function PaymentSettingsSection({ WaffoPancakeReturnURL: removeTrailingSlash( initialRef.current.WaffoPancakeReturnURL.trim() ), + TrybitEnabled: initialRef.current.TrybitEnabled, + TrybitShopId: initialRef.current.TrybitShopId, + TrybitUnitPrice: initialRef.current.TrybitUnitPrice, + TrybitMinTopUp: initialRef.current.TrybitMinTopUp, } const updates: Array<{ key: string; value: string | number | boolean }> = [] @@ -701,6 +744,32 @@ export function PaymentSettingsSection({ updates.push({ key: 'WaffoPayMethods', value: sanitized.WaffoPayMethods }) } + if (sanitized.TrybitEnabled !== initial.TrybitEnabled) { + updates.push({ key: 'TrybitEnabled', value: sanitized.TrybitEnabled }) + } + + if (sanitized.TrybitShopId !== initial.TrybitShopId) { + updates.push({ key: 'TrybitShopId', value: sanitized.TrybitShopId }) + } + + if (sanitized.TrybitUnitPrice !== initial.TrybitUnitPrice) { + updates.push({ key: 'TrybitUnitPrice', value: sanitized.TrybitUnitPrice }) + } + + if (sanitized.TrybitMinTopUp !== initial.TrybitMinTopUp) { + updates.push({ key: 'TrybitMinTopUp', value: sanitized.TrybitMinTopUp }) + } + + // The API returns secrets masked, so the form always starts empty: push + // whatever the operator typed and leave the stored value alone otherwise. + if (sanitized.TrybitApiKey) { + updates.push({ key: 'TrybitApiKey', value: sanitized.TrybitApiKey }) + } + + if (sanitized.TrybitSecretKey) { + updates.push({ key: 'TrybitSecretKey', value: sanitized.TrybitSecretKey }) + } + const hasWaffoPancakeChanges = sanitized.WaffoPancakeMerchantID !== initial.WaffoPancakeMerchantID || sanitized.WaffoPancakePrivateKey.length > 0 || @@ -795,6 +864,14 @@ export function PaymentSettingsSection({ WaffoPancakePrivateKey: currentFormValues.WaffoPancakePrivateKey, WaffoPancakeReturnURL: currentFormValues.WaffoPancakeReturnURL, } + const trybitValues: TrybitSettingsValues = { + TrybitEnabled: currentFormValues.TrybitEnabled, + TrybitApiKey: currentFormValues.TrybitApiKey, + TrybitSecretKey: currentFormValues.TrybitSecretKey, + TrybitShopId: currentFormValues.TrybitShopId, + TrybitUnitPrice: currentFormValues.TrybitUnitPrice, + TrybitMinTopUp: currentFormValues.TrybitMinTopUp, + } return ( @@ -877,13 +954,14 @@ export function PaymentSettingsSection({ />
- + {t('General')} Epay {t('Stripe')} Creem Waffo Pancake Waffo + Trybit
@@ -1626,6 +1704,13 @@ export function PaymentSettingsSection({ onPayMethodsChange={setWaffoPayMethods} /> + + + +
diff --git a/web/src/features/system-settings/integrations/trybit-settings-section.tsx b/web/src/features/system-settings/integrations/trybit-settings-section.tsx new file mode 100644 index 000000000000..85ea0d663326 --- /dev/null +++ b/web/src/features/system-settings/integrations/trybit-settings-section.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from 'react-i18next' + +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +import { SettingsSwitchField } from '../components/settings-form-layout' + +export interface TrybitSettingsValues { + TrybitEnabled: boolean + TrybitApiKey: string + TrybitSecretKey: string + TrybitShopId: string + TrybitUnitPrice: number + TrybitMinTopUp: number +} + +interface Props { + values: TrybitSettingsValues + onValueChange: ( + key: K, + value: TrybitSettingsValues[K] + ) => void +} + +export function TrybitSettingsSection({ values, onValueChange }: Props) { + const { t } = useTranslation() + + return ( +
+
+

{t('Trybit Crypto Gateway')}

+

+ {t( + 'Cryptocurrency checkout — users are redirected to a Trybit invoice and credited once the callback is verified.' + )} +

+
+ + + {t( + 'Obtain the API key, secret key and shop ID from the Trybit dashboard. The secret key verifies the callback signature, so keep both keys out of the client. In the Trybit project settings both fee sides must be set to the client, otherwise invoice creation is rejected rather than absorbing the fee.' + )} + + + + onValueChange('TrybitEnabled', v)} + label={t('Enable Trybit')} + className='py-0' + /> + +
+
+ + + onValueChange('TrybitApiKey', event.target.value) + } + /> +
+
+ + + onValueChange('TrybitSecretKey', event.target.value) + } + /> +
+
+ +
+
+ + + onValueChange('TrybitShopId', event.target.value) + } + /> +
+
+ + + onValueChange( + 'TrybitUnitPrice', + event.target.value === '' ? 0 : event.target.valueAsNumber + ) + } + /> +
+
+ + + onValueChange( + 'TrybitMinTopUp', + event.target.value === '' ? 5 : event.target.valueAsNumber + ) + } + /> +
+
+
+ ) +} diff --git a/web/src/features/system-settings/types.ts b/web/src/features/system-settings/types.ts index 063694b21cca..b340d6a15e9b 100644 --- a/web/src/features/system-settings/types.ts +++ b/web/src/features/system-settings/types.ts @@ -331,6 +331,12 @@ export type BillingSettings = { // section (saved via /api/option/waffo-pancake/save). WaffoPancakeStoreID: string WaffoPancakeProductID: string + TrybitEnabled: boolean + TrybitApiKey: string + TrybitSecretKey: string + TrybitShopId: string + TrybitUnitPrice: number + TrybitMinTopUp: number 'checkin_setting.enabled': boolean 'checkin_setting.min_quota': number 'checkin_setting.max_quota': number diff --git a/web/src/features/wallet/api.ts b/web/src/features/wallet/api.ts index e2f7073ddcaa..ddde0f533f25 100644 --- a/web/src/features/wallet/api.ts +++ b/web/src/features/wallet/api.ts @@ -39,6 +39,8 @@ import type { WaffoPaymentResponse, WaffoPancakePaymentRequest, WaffoPancakePaymentResponse, + TrybitPaymentRequest, + TrybitPaymentResponse, } from './types' // ============================================================================ @@ -181,6 +183,30 @@ export async function requestWaffoPancakePayment( return res.data } +/** + * Calculate payment amount for Trybit payment + */ +export async function calculateTrybitAmount( + request: AmountRequest +): Promise { + const res = await api.post('/api/user/trybit/amount', request, { + skipBusinessError: true, + } as Record) + return res.data +} + +/** + * Request Trybit payment + */ +export async function requestTrybitPayment( + request: TrybitPaymentRequest +): Promise { + const res = await api.post('/api/user/trybit/pay', request, { + skipBusinessError: true, + } as Record) + return res.data +} + /** * Get affiliate code */ diff --git a/web/src/features/wallet/components/recharge-form-card.tsx b/web/src/features/wallet/components/recharge-form-card.tsx index 95f3663add8f..8b426fddf314 100644 --- a/web/src/features/wallet/components/recharge-form-card.tsx +++ b/web/src/features/wallet/components/recharge-form-card.tsx @@ -134,7 +134,8 @@ export function RechargeFormCard({ topupInfo?.enable_online_topup || topupInfo?.enable_stripe_topup || enableWaffoTopup || - enableWaffoPancakeTopup + enableWaffoPancakeTopup || + topupInfo?.enable_trybit_topup const hasAnyTopup = hasConfigurableTopup || enableCreemTopup const hasStandardPaymentMethods = Array.isArray(topupInfo?.pay_methods) && topupInfo.pay_methods.length > 0 diff --git a/web/src/features/wallet/constants.ts b/web/src/features/wallet/constants.ts index 50bb45eb5b41..ab7e2ac1b48d 100644 --- a/web/src/features/wallet/constants.ts +++ b/web/src/features/wallet/constants.ts @@ -36,6 +36,7 @@ export const PAYMENT_TYPES = { CREEM: 'creem', WAFFO: 'waffo', WAFFO_PANCAKE: 'waffo_pancake', + TRYBIT: 'trybit', } as const /** @@ -53,6 +54,7 @@ export const PAYMENT_ICON_COLORS = { [PAYMENT_TYPES.CREEM]: '#6366F1', [PAYMENT_TYPES.WAFFO]: '#2563EB', [PAYMENT_TYPES.WAFFO_PANCAKE]: '#F97316', + [PAYMENT_TYPES.TRYBIT]: '#8B5CF6', } as const /** diff --git a/web/src/features/wallet/hooks/use-payment.test.ts b/web/src/features/wallet/hooks/use-payment.test.ts index 210e873f14e5..9f4c6c04a752 100644 --- a/web/src/features/wallet/hooks/use-payment.test.ts +++ b/web/src/features/wallet/hooks/use-payment.test.ts @@ -41,9 +41,42 @@ describe('payment amount routing', () => { calls.push('pancake') return { success: true, data: '4' } }, + trybit: async () => { + calls.push('trybit') + return { success: true, data: '5' } + }, }) expect(amount).toBe(18.75) expect(calls).toEqual(['waffo:120']) }) + + test('uses the dedicated Trybit amount calculator', async () => { + const calls: string[] = [] + const amount = await requestPaymentAmount(40, PAYMENT_TYPES.TRYBIT, { + regular: async () => { + calls.push('regular') + return { success: true, data: '1' } + }, + stripe: async () => { + calls.push('stripe') + return { success: true, data: '2' } + }, + waffo: async () => { + calls.push('waffo') + return { success: true, data: '3' } + }, + waffoPancake: async () => { + calls.push('pancake') + return { success: true, data: '4' } + }, + trybit: async (request) => { + calls.push(`trybit:${request.amount}`) + return { success: true, data: '40.00' } + }, + }) + + expect(amount).toBe(40) + expect(calls).toEqual(['trybit:40']) + }) }) diff --git a/web/src/features/wallet/hooks/use-payment.ts b/web/src/features/wallet/hooks/use-payment.ts index eb228a94f489..822ddcf76caa 100644 --- a/web/src/features/wallet/hooks/use-payment.ts +++ b/web/src/features/wallet/hooks/use-payment.ts @@ -25,14 +25,17 @@ import { calculateStripeAmount, calculateWaffoAmount, calculateWaffoPancakeAmount, + calculateTrybitAmount, requestPayment, requestStripePayment, + requestTrybitPayment, isApiSuccess, } from '../api' import { isStripePayment, isWaffoPayment, isWaffoPancakePayment, + isTrybitPayment, submitPaymentForm, } from '../lib' import type { AmountRequest, AmountResponse } from '../types' @@ -48,6 +51,7 @@ export interface PaymentAmountCalculators { stripe: AmountCalculator waffo: AmountCalculator waffoPancake: AmountCalculator + trybit: AmountCalculator } const defaultPaymentAmountCalculators: PaymentAmountCalculators = { @@ -55,6 +59,7 @@ const defaultPaymentAmountCalculators: PaymentAmountCalculators = { stripe: calculateStripeAmount, waffo: calculateWaffoAmount, waffoPancake: calculateWaffoPancakeAmount, + trybit: calculateTrybitAmount, } export async function requestPaymentAmount( @@ -69,6 +74,8 @@ export async function requestPaymentAmount( calculator = calculators.waffo } else if (isWaffoPancakePayment(paymentType)) { calculator = calculators.waffoPancake + } else if (isTrybitPayment(paymentType)) { + calculator = calculators.trybit } const response = await calculator({ amount: topupAmount }) @@ -112,32 +119,44 @@ export function usePayment() { setProcessing(true) const isStripe = isStripePayment(paymentType) + const isTrybit = isTrybitPayment(paymentType) const amount = Math.floor(topupAmount) - const response = isStripe - ? await requestStripePayment({ - amount, - payment_method: 'stripe', - }) - : await requestPayment({ - amount, - payment_method: paymentType, - }) + // Stripe and Trybit both hand back a hosted checkout link; every other + // gateway goes through the epay form submission below. + if (isStripe || isTrybit) { + const response = isStripe + ? await requestStripePayment({ + amount, + payment_method: 'stripe', + }) + : await requestTrybitPayment({ amount }) + + if (!isApiSuccess(response)) { + toast.error(response.message || i18next.t('Payment request failed')) + return false + } - if (!isApiSuccess(response)) { - toast.error(response.message || i18next.t('Payment request failed')) - return false - } + if (!response.data?.pay_link) { + return false + } - // Handle Stripe payment - if (isStripe && response.data?.pay_link) { - window.open(response.data.pay_link as string, '_blank') + window.open(response.data.pay_link, '_blank') toast.success(i18next.t('Redirecting to payment page...')) return true } - // Handle non-Stripe payment - if (!isStripe && response.data) { + const response = await requestPayment({ + amount, + payment_method: paymentType, + }) + + if (!isApiSuccess(response)) { + toast.error(response.message || i18next.t('Payment request failed')) + return false + } + + if (response.data) { const url = (response as unknown as { url?: string }).url if (url) { submitPaymentForm(url, response.data) diff --git a/web/src/features/wallet/lib/payment.ts b/web/src/features/wallet/lib/payment.ts index 0821e38789d8..73b6ca74a666 100644 --- a/web/src/features/wallet/lib/payment.ts +++ b/web/src/features/wallet/lib/payment.ts @@ -93,6 +93,16 @@ export function isWaffoPancakePayment(paymentType: string): boolean { return paymentType === PAYMENT_TYPES.WAFFO_PANCAKE } +/** + * Check if payment method is Trybit + * + * Trybit is a crypto gateway that returns a hosted invoice link, so it takes + * the pay_link redirect path rather than the generic epay form submission. + */ +export function isTrybitPayment(paymentType: string): boolean { + return paymentType === PAYMENT_TYPES.TRYBIT +} + export interface PaymentProcessors { regular: (topupAmount: number, paymentType: string) => Promise waffo: (topupAmount: number, payMethodIndex: number) => Promise @@ -144,6 +154,10 @@ export function getDefaultPaymentType(topupInfo: TopupInfo | null): string { return PAYMENT_TYPES.WAFFO_PANCAKE } + if (topupInfo.enable_trybit_topup) { + return PAYMENT_TYPES.TRYBIT + } + return DEFAULT_PAYMENT_TYPE } @@ -171,6 +185,10 @@ export function getMinTopupAmount(topupInfo: TopupInfo | null): number { return topupInfo.waffo_pancake_min_topup || DEFAULT_MIN_TOPUP } + if (topupInfo.enable_trybit_topup) { + return topupInfo.trybit_min_topup || DEFAULT_MIN_TOPUP + } + return DEFAULT_MIN_TOPUP } diff --git a/web/src/features/wallet/types.ts b/web/src/features/wallet/types.ts index 5da08f09f140..f05b93ffe699 100644 --- a/web/src/features/wallet/types.ts +++ b/web/src/features/wallet/types.ts @@ -45,6 +45,11 @@ export type CreemPaymentResponse = ApiResponse<{ checkout_url: string }> export type WaffoPaymentResponse = ApiResponse< { payment_url?: string } | string > +export type TrybitPaymentResponse = ApiResponse<{ + pay_link: string + order_id: string + expires_at: number +}> export type WaffoPancakePaymentResponse = ApiResponse< | { checkout_url?: string @@ -150,6 +155,10 @@ export interface TopupInfo { enable_waffo_pancake_topup?: boolean /** Minimum topup amount for Waffo Pancake */ waffo_pancake_min_topup?: number + /** Whether Trybit topup is enabled */ + enable_trybit_topup?: boolean + /** Minimum topup amount for Trybit */ + trybit_min_topup?: number /** Whether redemption code usage is enabled */ enable_redemption?: boolean /** Whether compliance confirmation has been completed */ @@ -204,6 +213,14 @@ export interface WaffoPancakePaymentRequest { amount: number } +/** + * Trybit payment request parameters + */ +export interface TrybitPaymentRequest { + /** Topup amount */ + amount: number +} + /** * Amount calculation request */