Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/internal/handler/payment_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,18 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) {
response.Success(c, resp)
}

// GetUSDTRate returns the current CNY-per-USDT rate, cached for 60s and sourced
// from CoinGecko. Used by the recharge UI to show users the implied USDT amount.
// GET /api/v1/payment/usdt/rate
func (h *PaymentHandler) GetUSDTRate(c *gin.Context) {
entry, err := defaultUSDTRateCache.Get(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, entry)
}

// CreateOrderRequest is the request body for creating a payment order.
type CreateOrderRequest struct {
Amount float64 `json:"amount"`
Expand Down
8 changes: 7 additions & 1 deletion backend/internal/handler/payment_webhook_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) {
h.handleNotify(c, payment.TypeAirwallex)
}

// UsdtNotify handles USDT (EasyPay-protocol crypto gateway) payment notifications.
// GET|POST /api/v1/payment/webhook/usdt
func (h *PaymentWebhookHandler) UsdtNotify(c *gin.Context) {
h.handleNotify(c, payment.TypeUsdt)
}

// handleNotify is the shared logic for all provider webhook handlers.
func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) {
var rawBody string
Expand Down Expand Up @@ -148,7 +154,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
// This allows looking up the correct provider instance before verification.
func extractOutTradeNo(rawBody, providerKey string) string {
switch providerKey {
case payment.TypeEasyPay, payment.TypeAlipay:
case payment.TypeEasyPay, payment.TypeAlipay, payment.TypeUsdt:
values, err := url.ParseQuery(rawBody)
if err == nil {
return values.Get("out_trade_no")
Expand Down
97 changes: 97 additions & 0 deletions backend/internal/handler/usdt_rate_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package handler

import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"

"golang.org/x/sync/singleflight"
)

type usdtRateEntry struct {
CNYPerUSDT float64 `json:"cny_per_usdt"`
UpdatedAt time.Time `json:"updated_at"`
Source string `json:"source"`
}

type usdtRateCache struct {
mu sync.RWMutex
ttl time.Duration
entry *usdtRateEntry
sf singleflight.Group
}

var defaultUSDTRateCache = &usdtRateCache{ttl: 60 * time.Second}

func (c *usdtRateCache) Get(ctx context.Context) (usdtRateEntry, error) {
c.mu.RLock()
cached := c.entry
c.mu.RUnlock()
if cached != nil && time.Since(cached.UpdatedAt) < c.ttl {
return *cached, nil
}

v, err, _ := c.sf.Do("usdt_rate", func() (any, error) {
rate, fetchErr := fetchUSDTRateFromCoinGecko(ctx)
if fetchErr != nil {
// Fall back to stale cache if available so the UI keeps working
// during a transient upstream outage.
c.mu.RLock()
stale := c.entry
c.mu.RUnlock()
if stale != nil {
return *stale, nil
}
return usdtRateEntry{}, fetchErr
}
entry := usdtRateEntry{
CNYPerUSDT: rate,
UpdatedAt: time.Now().UTC(),
Source: "coingecko",
}
c.mu.Lock()
c.entry = &entry
c.mu.Unlock()
return entry, nil
})
if err != nil {
return usdtRateEntry{}, err
}
entry, ok := v.(usdtRateEntry)
if !ok {
return usdtRateEntry{}, fmt.Errorf("usdt rate cache: unexpected value type %T", v)
}
return entry, nil
}

func fetchUSDTRateFromCoinGecko(ctx context.Context) (float64, error) {
const endpoint = "https://api.coingecko.com/api/v3/simple/price?ids=tether&vs_currencies=cny"
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return 0, fmt.Errorf("build request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("coingecko request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return 0, fmt.Errorf("coingecko status=%d", resp.StatusCode)
}
var body struct {
Tether struct {
CNY float64 `json:"cny"`
} `json:"tether"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return 0, fmt.Errorf("decode coingecko: %w", err)
}
if body.Tether.CNY <= 0 {
return 0, fmt.Errorf("coingecko returned non-positive rate: %v", body.Tether.CNY)
}
return body.Tether.CNY, nil
}
2 changes: 2 additions & 0 deletions backend/internal/payment/provider/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ func CreateProvider(providerKey string, instanceID string, config map[string]str
return NewStripe(instanceID, config)
case payment.TypeAirwallex:
return NewAirwallex(instanceID, config)
case payment.TypeUsdt:
return NewUSDT(instanceID, config)
default:
return nil, fmt.Errorf("unknown provider key: %s", providerKey)
}
Expand Down
169 changes: 169 additions & 0 deletions backend/internal/payment/provider/usdt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package provider

import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"time"

"github.com/Wei-Shaw/sub2api/internal/payment"
)

// USDT constants.
const (
usdtHTTPTimeout = 10 * time.Second
usdtDefaultTradeType = "usdt.trc20"
)

// USDT implements payment.Provider for self-hosted crypto gateways that speak
// the EasyPay (彩虹易支付) protocol but settle in stablecoins — e.g. BEpusdt.
//
// Unlike the EasyPay provider (which exposes alipay/wxpay payment types and can
// call mapi.php), this provider exposes a single first-class "usdt" payment type
// and always uses the hosted checkout page (submit.php). The on-chain trade type
// sent to the gateway (default "usdt.trc20") is configurable so other chains or
// stablecoins can be served by additional instances without code changes.
type USDT struct {
instanceID string
config map[string]string
tradeType string
}

// NewUSDT creates a new USDT provider.
// config keys: pid, pkey, apiBase, notifyUrl, returnUrl, tradeType (optional).
func NewUSDT(instanceID string, config map[string]string) (*USDT, error) {
for _, k := range []string{"pid", "pkey", "apiBase", "notifyUrl", "returnUrl"} {
if strings.TrimSpace(config[k]) == "" {
return nil, fmt.Errorf("usdt config missing required key: %s", k)
}
}
cfg := make(map[string]string, len(config))
for k, v := range config {
cfg[k] = v
}
cfg["apiBase"] = normalizeEasyPayAPIBase(cfg["apiBase"])
tradeType := strings.TrimSpace(cfg["tradeType"])
if tradeType == "" {
tradeType = usdtDefaultTradeType
}
return &USDT{instanceID: instanceID, config: cfg, tradeType: tradeType}, nil
}

func (u *USDT) Name() string { return "USDT" }
func (u *USDT) ProviderKey() string { return payment.TypeUsdt }
func (u *USDT) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeUsdt}
}

func (u *USDT) apiBase() string {
if u == nil {
return ""
}
return normalizeEasyPayAPIBase(u.config["apiBase"])
}

func (u *USDT) MerchantIdentityMetadata() map[string]string {
if u == nil {
return nil
}
pid := strings.TrimSpace(u.config["pid"])
if pid == "" {
return nil
}
return map[string]string{"pid": pid}
}

// CreatePayment builds a hosted-checkout (submit.php) URL for browser redirect.
// No server-side API call is made — the user is redirected to the gateway's
// hosted cashier. The crypto trade type (e.g. usdt.trc20) is sent natively, so
// the gateway accepts it directly without any type rewriting.
func (u *USDT) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := u.resolveURLs(req)
params := map[string]string{
"pid": u.config["pid"],
"type": u.tradeType,
"out_trade_no": req.OrderID,
"notify_url": notifyURL,
"return_url": returnURL,
"name": req.Subject,
"money": req.Amount,
}
if req.IsMobile {
params["device"] = deviceMobile
}
params["sign"] = easyPaySign(params, u.config["pkey"])
params["sign_type"] = signTypeMD5

q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
payURL := u.apiBase() + "/submit.php?" + q.Encode()
return &payment.CreatePaymentResponse{PayURL: payURL}, nil
}

func (u *USDT) resolveURLs(req payment.CreatePaymentRequest) (string, string) {
notifyURL := req.NotifyURL
if notifyURL == "" {
notifyURL = u.config["notifyUrl"]
}
returnURL := req.ReturnURL
if returnURL == "" {
returnURL = u.config["returnUrl"]
}
return notifyURL, returnURL
}

// QueryOrder is best-effort. The hosted gateway confirms payment via the notify
// callback (the source of truth); polling is only a reconciliation safety net.
func (u *USDT) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
return &payment.QueryOrderResponse{
TradeNo: tradeNo,
Status: payment.ProviderStatusPending,
Metadata: u.MerchantIdentityMetadata(),
}, nil
}

// VerifyNotification verifies the gateway's async notify callback (MD5 sign over
// the form params, identical to the EasyPay protocol).
func (u *USDT) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
values, err := url.ParseQuery(rawBody)
if err != nil {
return nil, fmt.Errorf("parse notify: %w", err)
}
params := make(map[string]string)
for k := range values {
params[k] = values.Get(k)
}
sign := params["sign"]
if sign == "" {
return nil, fmt.Errorf("missing sign")
}
if !easyPayVerifySign(params, u.config["pkey"], sign) {
return nil, fmt.Errorf("invalid signature")
}
status := payment.ProviderStatusFailed
if params["trade_status"] == tradeStatusSuccess {
status = payment.ProviderStatusSuccess
}
amount, _ := strconv.ParseFloat(params["money"], 64)

metadata := u.MerchantIdentityMetadata()
if pid := strings.TrimSpace(params["pid"]); pid != "" {
if metadata == nil {
metadata = map[string]string{}
}
metadata["pid"] = pid
}
return &payment.PaymentNotification{
TradeNo: params["trade_no"], OrderID: params["out_trade_no"],
Amount: amount, Status: status, RawData: rawBody, Metadata: metadata,
}, nil
}

// Refund is not supported for crypto settlements (on-chain refunds are manual).
func (u *USDT) Refund(_ context.Context, _ payment.RefundRequest) (*payment.RefundResponse, error) {
return nil, fmt.Errorf("usdt: refund not supported")
}
3 changes: 3 additions & 0 deletions backend/internal/payment/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
TypeLink PaymentType = "link"
TypeEasyPay PaymentType = "easypay"
TypeAirwallex PaymentType = "airwallex"
TypeUsdt PaymentType = "usdt"
)

// Order status constants shared across payment and service layers.
Expand Down Expand Up @@ -85,6 +86,8 @@ func GetBasePaymentType(t string) string {
return TypeEasyPay
case t == TypeAirwallex:
return TypeAirwallex
case t == TypeUsdt:
return TypeUsdt
case t == TypeStripe || t == TypeCard || t == TypeLink:
return TypeStripe
case len(t) >= len(TypeAlipay) && t[:len(TypeAlipay)] == TypeAlipay:
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/server/routes/payment.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func RegisterPaymentRoutes(
authenticated.GET("/plans", paymentHandler.GetPlans)
authenticated.GET("/channels", paymentHandler.GetChannels)
authenticated.GET("/limits", paymentHandler.GetLimits)
authenticated.GET("/usdt/rate", paymentHandler.GetUSDTRate)

orders := authenticated.Group("/orders")
{
Expand Down Expand Up @@ -63,6 +64,9 @@ func RegisterPaymentRoutes(
webhook.POST("/wxpay", webhookHandler.WxpayNotify)
webhook.POST("/stripe", webhookHandler.StripeWebhook)
webhook.POST("/airwallex", webhookHandler.AirwallexWebhook)
// USDT (EasyPay-protocol crypto gateway) sends GET or POST callbacks
webhook.GET("/usdt", webhookHandler.UsdtNotify)
webhook.POST("/usdt", webhookHandler.UsdtNotify)
}

// --- Admin payment endpoints (admin auth) ---
Expand Down
4 changes: 3 additions & 1 deletion backend/internal/service/payment_config_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ var providerSensitiveConfigFields = map[string]map[string]struct{}{
payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}},
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
payment.TypeAirwallex: {"apikey": {}, "webhooksecret": {}},
payment.TypeUsdt: {"pkey": {}},
}

// providerPendingOrderProtectedConfigFields lists config keys that cannot be
Expand All @@ -127,6 +128,7 @@ var providerPendingOrderProtectedConfigFields = map[string]map[string]struct{}{
payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}},
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}, "currency": {}},
payment.TypeAirwallex: {"clientid": {}, "apikey": {}, "webhooksecret": {}, "apibase": {}, "accountid": {}, "currency": {}},
payment.TypeUsdt: {"pkey": {}, "pid": {}, "apibase": {}, "tradetype": {}},
}

func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
Expand Down Expand Up @@ -177,7 +179,7 @@ func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, pla
}

var validProviderKeys = map[string]bool{
payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true,
payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true, payment.TypeUsdt: true,
}

func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/payment_order.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req Creat
snapshot["merchant_app_id"] = merchantAppID
}
}
if providerKey == payment.TypeEasyPay {
if providerKey == payment.TypeEasyPay || providerKey == payment.TypeUsdt {
if merchantID := strings.TrimSpace(sel.Config["pid"]); merchantID != "" {
snapshot["merchant_id"] = merchantID
}
Expand Down
Loading
Loading