mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
993 lines
33 KiB
Go
993 lines
33 KiB
Go
package service
|
|
|
|
import (
|
|
cryptorand "crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/GMWalletApp/epusdt/config"
|
|
"github.com/GMWalletApp/epusdt/model/dao"
|
|
"github.com/GMWalletApp/epusdt/model/data"
|
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
"github.com/GMWalletApp/epusdt/model/request"
|
|
"github.com/GMWalletApp/epusdt/model/response"
|
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
|
"github.com/GMWalletApp/epusdt/util/constant"
|
|
"github.com/GMWalletApp/epusdt/util/log"
|
|
"github.com/GMWalletApp/epusdt/util/math"
|
|
"github.com/GMWalletApp/epusdt/util/security"
|
|
"github.com/dromara/carbon/v2"
|
|
"github.com/shopspring/decimal"
|
|
)
|
|
|
|
const (
|
|
CnyMinimumPaymentAmount = 0.01
|
|
UsdtMinimumPaymentAmount = 0.01
|
|
IncrementalMaximumNumber = 100
|
|
)
|
|
|
|
var (
|
|
gCreateTransactionLock sync.Mutex
|
|
gOrderProcessingLock sync.Mutex
|
|
)
|
|
|
|
// apiKeyID safely extracts the primary key from an ApiKey row.
|
|
// Returns 0 when apiKey is nil (middleware didn't run — shouldn't happen on authed routes).
|
|
func apiKeyID(apiKey *mdb.ApiKey) uint64 {
|
|
if apiKey == nil {
|
|
return 0
|
|
}
|
|
return apiKey.ID
|
|
}
|
|
|
|
func normalizeOrderAddressByNetwork(network, address string) string {
|
|
network = strings.ToLower(strings.TrimSpace(network))
|
|
address = strings.TrimSpace(address)
|
|
switch network {
|
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
|
return strings.ToLower(address)
|
|
case mdb.NetworkTon:
|
|
if normalized, err := addressutil.NormalizeTonAddress(address); err == nil {
|
|
return normalized
|
|
}
|
|
return address
|
|
case mdb.NetworkAptos:
|
|
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
|
|
return normalized
|
|
}
|
|
return address
|
|
default:
|
|
return address
|
|
}
|
|
}
|
|
|
|
func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
|
|
tokenRow, err := data.GetEnabledChainTokenBySymbol(network, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tokenRow == nil || tokenRow.ID == 0 {
|
|
return nil, constant.SupportedAssetNotFound
|
|
}
|
|
if !ChainTokenReadyForPayment(*tokenRow) {
|
|
return nil, constant.SupportedAssetNotFound
|
|
}
|
|
return tokenRow, nil
|
|
}
|
|
|
|
func validateCreateTransactionTokenNetwork(token, network string) error {
|
|
if (token == "") != (network == "") {
|
|
return constant.ParamsMarshalErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildCreateTransactionResponse(order *mdb.Orders) *response.CreateTransactionResponse {
|
|
return &response.CreateTransactionResponse{
|
|
TradeId: order.TradeId,
|
|
OrderId: order.OrderId,
|
|
Amount: order.Amount,
|
|
Currency: order.Currency,
|
|
ActualAmount: order.ActualAmount,
|
|
ReceiveAddress: order.ReceiveAddress,
|
|
Token: order.Token,
|
|
Status: order.Status,
|
|
ExpirationTime: carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp(),
|
|
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
|
}
|
|
}
|
|
|
|
// CreateTransaction creates a new payment order.
|
|
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
|
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
|
network := strings.ToLower(strings.TrimSpace(req.Network))
|
|
if err := validateCreateTransactionTokenNetwork(token, network); err != nil {
|
|
return nil, err
|
|
}
|
|
notifyURL := strings.TrimSpace(req.NotifyUrl)
|
|
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
|
|
return nil, constant.NotifyURLErr
|
|
}
|
|
paymentType := mdb.PaymentTypeGmpay
|
|
if strings.EqualFold(req.PaymentType, mdb.PaymentTypeEpay) {
|
|
paymentType = mdb.PaymentTypeEpay
|
|
}
|
|
|
|
gCreateTransactionLock.Lock()
|
|
defer gCreateTransactionLock.Unlock()
|
|
|
|
amountPrecision := data.GetAmountPrecision()
|
|
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
|
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if exist.ID > 0 {
|
|
return nil, constant.OrderAlreadyExists
|
|
}
|
|
|
|
decimalPayAmount := decimal.NewFromFloat(payAmount)
|
|
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
|
|
return nil, constant.PayAmountErr
|
|
}
|
|
|
|
if token == "" && network == "" {
|
|
tradeID := GenerateCode()
|
|
order := &mdb.Orders{
|
|
TradeId: tradeID,
|
|
OrderId: req.OrderId,
|
|
Amount: payAmount,
|
|
Currency: currency,
|
|
Status: mdb.StatusWaitSelect,
|
|
NotifyUrl: notifyURL,
|
|
RedirectUrl: req.RedirectUrl,
|
|
Name: req.Name,
|
|
PaymentType: paymentType,
|
|
PayProvider: mdb.PaymentProviderOnChain,
|
|
ApiKeyID: apiKeyID(apiKey),
|
|
}
|
|
if err = data.CreateOrderWithTransaction(dao.Mdb, order); err != nil {
|
|
return nil, err
|
|
}
|
|
return buildCreateTransactionResponse(order), nil
|
|
}
|
|
|
|
if !data.IsChainEnabled(network) {
|
|
return nil, constant.ChainNotEnabled
|
|
}
|
|
if _, err = ensureEnabledOrderAsset(network, token); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
|
if rate <= 0 {
|
|
return nil, constant.RateAmountErr
|
|
}
|
|
|
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
return nil, constant.PayAmountErr
|
|
}
|
|
|
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(walletAddress) <= 0 {
|
|
return nil, constant.NotAvailableWalletAddress
|
|
}
|
|
|
|
tradeID := GenerateCode()
|
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), amountPrecision)
|
|
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if availableAddress == "" {
|
|
return nil, constant.NotAvailableAmountErr
|
|
}
|
|
|
|
tx := dao.Mdb.Begin()
|
|
order := &mdb.Orders{
|
|
TradeId: tradeID,
|
|
OrderId: req.OrderId,
|
|
Amount: payAmount,
|
|
Currency: currency,
|
|
ActualAmount: availableAmount,
|
|
ReceiveAddress: availableAddress,
|
|
Token: token,
|
|
Network: network,
|
|
Status: mdb.StatusWaitPay,
|
|
NotifyUrl: notifyURL,
|
|
RedirectUrl: req.RedirectUrl,
|
|
Name: req.Name,
|
|
PaymentType: paymentType,
|
|
PayProvider: mdb.PaymentProviderOnChain,
|
|
ApiKeyID: apiKeyID(apiKey),
|
|
}
|
|
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
|
tx.Rollback()
|
|
_ = data.UnLockTransactionByTradeId(tradeID)
|
|
return nil, err
|
|
}
|
|
if err = tx.Commit().Error; err != nil {
|
|
tx.Rollback()
|
|
_ = data.UnLockTransactionByTradeId(tradeID)
|
|
return nil, err
|
|
}
|
|
|
|
return buildCreateTransactionResponse(order), nil
|
|
}
|
|
|
|
// OrderProcessing marks an order as paid and releases its sqlite reservation.
|
|
func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|
gOrderProcessingLock.Lock()
|
|
defer gOrderProcessingLock.Unlock()
|
|
|
|
tx := dao.Mdb.Begin()
|
|
exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if exist.ID > 0 {
|
|
tx.Rollback()
|
|
return constant.OrderBlockAlreadyProcess
|
|
}
|
|
|
|
updated, err := data.OrderSuccessWithTransaction(tx, req)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if !updated {
|
|
tx.Rollback()
|
|
return constant.OrderStatusConflict
|
|
}
|
|
if err = tx.Commit().Error; err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
// Load order to check parent-child relationship
|
|
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
|
if err != nil {
|
|
if strings.TrimSpace(req.Network) != "" && strings.TrimSpace(req.ReceiveAddress) != "" && strings.TrimSpace(req.Token) != "" && req.Amount > 0 {
|
|
if unlockErr := data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); unlockErr != nil {
|
|
log.Sugar.Warnf("[order] fallback unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, unlockErr)
|
|
}
|
|
}
|
|
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
|
}
|
|
if hasTransactionLock(order) {
|
|
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
|
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", order.TradeId, err)
|
|
}
|
|
}
|
|
|
|
// Parent order paid directly: expire all sub-orders and release their locks
|
|
if order.ParentTradeId == "" {
|
|
subs, subErr := data.GetActiveSubOrders(order.TradeId)
|
|
if subErr != nil {
|
|
log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr)
|
|
return fmt.Errorf("load sub-orders failed, parent_trade_id=%s: %w", order.TradeId, subErr)
|
|
}
|
|
for _, sub := range subs {
|
|
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
|
|
log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err)
|
|
}
|
|
if sub.PayProvider != "" && sub.PayProvider != mdb.PaymentProviderOnChain {
|
|
if err = data.MarkProviderOrderExpired(sub.TradeId, sub.PayProvider); err != nil {
|
|
log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, err)
|
|
}
|
|
}
|
|
if hasTransactionLock(&sub) {
|
|
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
|
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId)
|
|
if err != nil {
|
|
log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err)
|
|
return fmt.Errorf("load parent order failed, parent_trade_id=%s: %w", order.ParentTradeId, err)
|
|
}
|
|
|
|
// Snapshot siblings for lock release after DB state transition commits.
|
|
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
|
|
if err != nil {
|
|
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
|
return fmt.Errorf("load sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
|
}
|
|
|
|
finalizeTx := dao.Mdb.Begin()
|
|
|
|
// Mark parent as paid with sub-order's payment details
|
|
updatedParent, markErr := data.MarkParentOrderSuccessWithTransaction(finalizeTx, parent.TradeId, order)
|
|
if markErr != nil {
|
|
finalizeTx.Rollback()
|
|
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, markErr)
|
|
return fmt.Errorf("mark parent success failed, parent_trade_id=%s: %w", parent.TradeId, markErr)
|
|
}
|
|
if !updatedParent {
|
|
finalizeTx.Rollback()
|
|
return fmt.Errorf("parent order not updated, trade_id=%s is not in wait-pay status", parent.TradeId)
|
|
}
|
|
|
|
if err = data.ExpireSiblingSubOrdersWithTransaction(finalizeTx, parent.TradeId, order.TradeId); err != nil {
|
|
finalizeTx.Rollback()
|
|
return fmt.Errorf("expire sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
|
}
|
|
|
|
if err = finalizeTx.Commit().Error; err != nil {
|
|
finalizeTx.Rollback()
|
|
return fmt.Errorf("commit parent finalize tx failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
|
}
|
|
|
|
// Sub-order should not trigger its own callback (notify_url is empty).
|
|
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
|
|
// reset it only after the parent order is successfully finalized.
|
|
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
|
|
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
|
|
}
|
|
|
|
// Release parent's own wallet lock
|
|
if hasTransactionLock(parent) {
|
|
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
|
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
|
}
|
|
}
|
|
|
|
// Release sibling locks after their status transitions commit.
|
|
for _, sib := range siblings {
|
|
if sib.PayProvider != "" && sib.PayProvider != mdb.PaymentProviderOnChain {
|
|
if err = data.MarkProviderOrderExpired(sib.TradeId, sib.PayProvider); err != nil {
|
|
log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, err)
|
|
}
|
|
}
|
|
if hasTransactionLock(&sib) {
|
|
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
|
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
|
|
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
|
availableAddress := ""
|
|
availableAmount := amount
|
|
amountPrecision := data.GetAmountPrecision()
|
|
|
|
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
|
for _, address := range walletAddress {
|
|
normalizedAddress := normalizeOrderAddressByNetwork(network, address.Address)
|
|
err := data.LockTransaction(network, normalizedAddress, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
|
|
if err == nil {
|
|
return normalizedAddress, nil
|
|
}
|
|
if errors.Is(err, data.ErrTransactionLocked) {
|
|
continue
|
|
}
|
|
return "", err
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
for i := 0; i < IncrementalMaximumNumber; i++ {
|
|
address, err := tryLockWalletFunc(availableAmount)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
if address == "" {
|
|
decimalOldAmount := decimal.NewFromFloat(availableAmount)
|
|
decimalIncr := decimal.New(1, int32(-amountPrecision))
|
|
availableAmount = math.MustParsePrecFloat64(decimalOldAmount.Add(decimalIncr).InexactFloat64(), amountPrecision)
|
|
continue
|
|
}
|
|
availableAddress = address
|
|
break
|
|
}
|
|
return availableAddress, availableAmount, nil
|
|
}
|
|
|
|
// GenerateCode creates a unique trade id.
|
|
func GenerateCode() string {
|
|
buf := make([]byte, 18)
|
|
if _, err := cryptorand.Read(buf); err != nil {
|
|
panic(fmt.Sprintf("generate trade id: crypto/rand failed: %v", err))
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf)
|
|
}
|
|
|
|
// GetOrderInfoByTradeId returns a validated order.
|
|
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
|
order, err := data.GetOrderInfoByTradeId(tradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if order.ID <= 0 {
|
|
return nil, constant.OrderNotExists
|
|
}
|
|
return order, nil
|
|
}
|
|
|
|
// SubmitManualPayment verifies a submitted transaction hash and marks the
|
|
// matching on-chain order paid using the same path as automatic chain scans.
|
|
func SubmitManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
|
tradeId = strings.TrimSpace(tradeId)
|
|
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
|
|
|
order, err := GetOrderInfoByTradeId(tradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return submitManualPaymentForOrder(order, blockTransactionId)
|
|
}
|
|
|
|
// SubmitCashierManualPayment is the public cashier variant. It rejects hashes
|
|
// already stored on any order before touching RPC so repeated/public probes do
|
|
// not spend RPC quota. Admin mark-paid intentionally keeps using
|
|
// SubmitManualPayment and the existing verification path.
|
|
func SubmitCashierManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
|
tradeId = strings.TrimSpace(tradeId)
|
|
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
|
|
|
order, err := GetOrderInfoByTradeId(tradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = validateManualPaymentOrder(order); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = ensureManualBlockTransactionUnused(order, blockTransactionId); err != nil {
|
|
return nil, err
|
|
}
|
|
return submitManualPaymentForOrder(order, blockTransactionId)
|
|
}
|
|
|
|
func submitManualPaymentForOrder(order *mdb.Orders, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
|
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
|
if err := validateManualPaymentOrder(order); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
verifiedBlockTransactionID, err := ValidateManualOrderPayment(order, blockTransactionId)
|
|
if err != nil {
|
|
var rspErr *constant.RspError
|
|
if errors.As(err, &rspErr) {
|
|
return nil, err
|
|
}
|
|
return nil, constant.ManualPaymentVerifyErr
|
|
}
|
|
if err = OrderProcessing(&request.OrderProcessingRequest{
|
|
ReceiveAddress: order.ReceiveAddress,
|
|
Currency: order.Currency,
|
|
Token: order.Token,
|
|
Network: order.Network,
|
|
Amount: order.ActualAmount,
|
|
TradeId: order.TradeId,
|
|
BlockTransactionId: verifiedBlockTransactionID,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
updatedOrder, err := GetOrderInfoByTradeId(order.TradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &response.ManualPaymentResponse{
|
|
TradeId: updatedOrder.TradeId,
|
|
Status: updatedOrder.Status,
|
|
BlockTransactionId: updatedOrder.BlockTransactionId,
|
|
}, nil
|
|
}
|
|
|
|
func validateManualPaymentOrder(order *mdb.Orders) error {
|
|
if order.Status != mdb.StatusWaitPay {
|
|
return constant.OrderNotWaitPay
|
|
}
|
|
if !isOnChainOrder(order.PayProvider) {
|
|
return constant.ManualPaymentProviderErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isOnChainOrder(payProvider string) bool {
|
|
payProvider = strings.TrimSpace(payProvider)
|
|
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
|
}
|
|
|
|
func hasTransactionLock(order *mdb.Orders) bool {
|
|
return order != nil &&
|
|
isOnChainOrder(order.PayProvider) &&
|
|
strings.TrimSpace(order.Network) != "" &&
|
|
strings.TrimSpace(order.ReceiveAddress) != "" &&
|
|
strings.TrimSpace(order.Token) != "" &&
|
|
order.ActualAmount > 0
|
|
}
|
|
|
|
const MaxSubOrders = 1
|
|
|
|
// SwitchNetwork completes a status=4 placeholder in place, or creates/returns
|
|
// an existing sub-order for a different payment target once the parent is
|
|
// already concrete.
|
|
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
|
|
gCreateTransactionLock.Lock()
|
|
defer gCreateTransactionLock.Unlock()
|
|
|
|
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
|
network := strings.ToLower(strings.TrimSpace(req.Network))
|
|
|
|
// 1. Load parent order
|
|
parent, err := data.GetOrderInfoByTradeId(req.TradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if parent.ID <= 0 {
|
|
return nil, constant.OrderNotExists
|
|
}
|
|
if parent.ParentTradeId != "" {
|
|
return nil, constant.CannotSwitchSubOrder
|
|
}
|
|
if parent.Status != mdb.StatusWaitPay && parent.Status != mdb.StatusWaitSelect {
|
|
return nil, constant.OrderNotWaitPay
|
|
}
|
|
|
|
if network == mdb.PaymentProviderOkPay {
|
|
return switchToOkPay(parent, token)
|
|
}
|
|
|
|
if parent.Status == mdb.StatusWaitSelect {
|
|
return completeWaitSelectOrder(parent, token, network)
|
|
}
|
|
|
|
// 2. Same token+network as parent → mark selected and return
|
|
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
|
|
_ = data.MarkOrderSelected(parent.TradeId)
|
|
parent.IsSelected = true
|
|
return buildCheckoutResponse(parent), nil
|
|
}
|
|
|
|
// 3. Existing active sub-order for this token+network → return it
|
|
existing, err := data.GetSubOrderByTokenNetwork(parent.TradeId, token, network)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existing.ID > 0 {
|
|
_ = data.MarkOrderSelected(parent.TradeId)
|
|
_ = data.MarkOrderSelected(existing.TradeId)
|
|
_ = data.RefreshOrderExpiration(parent.TradeId)
|
|
existing.IsSelected = true
|
|
return buildCheckoutResponse(existing), nil
|
|
}
|
|
|
|
// 4. Only one sub-order may ever be created under a parent. Existing
|
|
// active sub-orders are reused above; any other target after that is blocked.
|
|
count, err := data.CountSubOrders(parent.TradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count >= MaxSubOrders {
|
|
return nil, constant.SubOrderLimitExceeded
|
|
}
|
|
|
|
// 5. Validate the target chain and asset before any rate lookup so
|
|
// disabled/unknown tokens return the stable unsupported-asset error.
|
|
if !data.IsChainEnabled(network) {
|
|
return nil, constant.ChainNotEnabled
|
|
}
|
|
if _, err = ensureEnabledOrderAsset(network, token); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 6. Calculate amount for the new network
|
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
|
if rate <= 0 {
|
|
return nil, constant.RateAmountErr
|
|
}
|
|
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
return nil, constant.PayAmountErr
|
|
}
|
|
|
|
// 7. Find and lock wallet
|
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(walletAddress) <= 0 {
|
|
return nil, constant.NotAvailableWalletAddress
|
|
}
|
|
|
|
subTradeID := GenerateCode()
|
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
|
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if availableAddress == "" {
|
|
return nil, constant.NotAvailableAmountErr
|
|
}
|
|
|
|
// 8. Create sub-order
|
|
tx := dao.Mdb.Begin()
|
|
subOrder := &mdb.Orders{
|
|
TradeId: subTradeID,
|
|
OrderId: subTradeID, // sub-order uses its own trade_id as order_id (unique constraint)
|
|
ParentTradeId: parent.TradeId,
|
|
Amount: parent.Amount,
|
|
Currency: parent.Currency,
|
|
ActualAmount: availableAmount,
|
|
ReceiveAddress: availableAddress,
|
|
Token: token,
|
|
Network: network,
|
|
Status: mdb.StatusWaitPay,
|
|
IsSelected: true,
|
|
NotifyUrl: "",
|
|
RedirectUrl: parent.RedirectUrl,
|
|
Name: parent.Name,
|
|
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
|
|
PaymentType: parent.PaymentType,
|
|
PayProvider: mdb.PaymentProviderOnChain,
|
|
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
|
|
}
|
|
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
|
|
tx.Rollback()
|
|
_ = data.UnLockTransactionByTradeId(subTradeID)
|
|
return nil, err
|
|
}
|
|
if err = tx.Commit().Error; err != nil {
|
|
tx.Rollback()
|
|
_ = data.UnLockTransactionByTradeId(subTradeID)
|
|
return nil, err
|
|
}
|
|
|
|
// Mark parent as selected and refresh its expiration to match the sub-order
|
|
_ = data.MarkOrderSelected(parent.TradeId)
|
|
_ = data.RefreshOrderExpiration(parent.TradeId)
|
|
|
|
return buildCheckoutResponse(subOrder), nil
|
|
}
|
|
|
|
func completeWaitSelectOrder(parent *mdb.Orders, token string, network string) (*response.CheckoutCounterResponse, error) {
|
|
if !data.IsChainEnabled(network) {
|
|
return nil, constant.ChainNotEnabled
|
|
}
|
|
if _, err := ensureEnabledOrderAsset(network, token); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
|
if rate <= 0 {
|
|
return nil, constant.RateAmountErr
|
|
}
|
|
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
return nil, constant.PayAmountErr
|
|
}
|
|
|
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(walletAddress) <= 0 {
|
|
return nil, constant.NotAvailableWalletAddress
|
|
}
|
|
|
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
|
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(parent.TradeId, network, token, amount, walletAddress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if availableAddress == "" {
|
|
return nil, constant.NotAvailableAmountErr
|
|
}
|
|
|
|
updated, err := data.CompleteWaitSelectOrder(parent.TradeId, network, token, availableAddress, availableAmount)
|
|
if err != nil {
|
|
_ = data.UnLockTransactionByTradeId(parent.TradeId)
|
|
return nil, err
|
|
}
|
|
if !updated {
|
|
_ = data.UnLockTransactionByTradeId(parent.TradeId)
|
|
return nil, constant.OrderStatusConflict
|
|
}
|
|
|
|
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buildCheckoutResponse(order), nil
|
|
}
|
|
|
|
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
|
|
paymentType := mdb.PaymentTypeGmpay
|
|
if isEPayOrder(order) {
|
|
paymentType = mdb.PaymentTypeEpay
|
|
}
|
|
return &response.CheckoutCounterResponse{
|
|
TradeId: order.TradeId,
|
|
Amount: order.Amount,
|
|
ActualAmount: order.ActualAmount,
|
|
Token: order.Token,
|
|
Currency: order.Currency,
|
|
ReceiveAddress: order.ReceiveAddress,
|
|
Network: order.Network,
|
|
Status: order.Status,
|
|
PaymentType: strings.ToLower(paymentType),
|
|
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
|
RedirectUrl: buildPublicRedirectURL(order),
|
|
CreatedAt: order.CreatedAt.TimestampMilli(),
|
|
IsSelected: order.IsSelected,
|
|
}
|
|
}
|
|
|
|
func completeWaitSelectOkPayOrder(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
|
if rate <= 0 {
|
|
return nil, constant.RateAmountErr
|
|
}
|
|
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
return nil, constant.PayAmountErr
|
|
}
|
|
|
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
|
returnURL := strings.TrimSpace(parent.RedirectUrl)
|
|
if returnURL == "" {
|
|
returnURL = data.GetOkPayReturnURL()
|
|
}
|
|
if returnURL == "" {
|
|
returnURL = fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), parent.TradeId)
|
|
}
|
|
|
|
tx := dao.Mdb.Begin()
|
|
providerRow := &mdb.ProviderOrder{
|
|
TradeId: parent.TradeId,
|
|
Provider: mdb.PaymentProviderOkPay,
|
|
ProviderOrderID: "",
|
|
PayURL: "",
|
|
Amount: amount,
|
|
Coin: token,
|
|
Status: mdb.ProviderOrderStatusCreating,
|
|
NotifyRaw: "",
|
|
}
|
|
if err := data.UpsertProviderOrderCreatingWithTransaction(tx, providerRow); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit().Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
okpayOrder, err := createOkPayDepositOrder(parent.TradeId, amount, token, returnURL)
|
|
if err != nil {
|
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
return nil, constant.PaymentProviderCreateErr
|
|
}
|
|
|
|
finalizeTx := dao.Mdb.Begin()
|
|
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, parent.TradeId, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
|
finalizeTx.Rollback()
|
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
return nil, err
|
|
}
|
|
updated, err := data.CompleteWaitSelectOkPayOrderWithTransaction(finalizeTx, parent.TradeId, token, amount)
|
|
if err != nil {
|
|
finalizeTx.Rollback()
|
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
return nil, err
|
|
}
|
|
if !updated {
|
|
finalizeTx.Rollback()
|
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
return nil, constant.OrderStatusConflict
|
|
}
|
|
if err = finalizeTx.Commit().Error; err != nil {
|
|
finalizeTx.Rollback()
|
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
return nil, err
|
|
}
|
|
|
|
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := buildCheckoutResponse(order)
|
|
resp.PaymentUrl = okpayOrder.PayURL
|
|
return resp, nil
|
|
}
|
|
|
|
func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
|
if !data.GetOkPayEnabled() {
|
|
return nil, constant.PaymentProviderNotEnabled
|
|
}
|
|
if data.GetOkPayShopID() == "" || data.GetOkPayShopToken() == "" || data.GetOkPayAPIURL() == "" || data.GetOkPayCallbackURL() == "" {
|
|
return nil, constant.PaymentProviderConfigErr
|
|
}
|
|
if !okPayTokenAllowed(token) {
|
|
return nil, constant.PaymentProviderNotSupport
|
|
}
|
|
|
|
if parent.Status == mdb.StatusWaitSelect {
|
|
return completeWaitSelectOkPayOrder(parent, token)
|
|
}
|
|
|
|
if parent.PayProvider == mdb.PaymentProviderOkPay && strings.EqualFold(parent.Token, token) {
|
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parent.TradeId, mdb.PaymentProviderOkPay)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
|
return nil, constant.SystemErr
|
|
}
|
|
_ = data.MarkOrderSelected(parent.TradeId)
|
|
_ = data.RefreshOrderExpiration(parent.TradeId)
|
|
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := buildCheckoutResponse(order)
|
|
resp.PaymentUrl = providerRow.PayURL
|
|
return resp, nil
|
|
}
|
|
|
|
existing, err := data.GetSubOrderByTokenPayProvider(parent.TradeId, token, mdb.PaymentProviderOkPay)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existing.ID > 0 {
|
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(existing.TradeId, mdb.PaymentProviderOkPay)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
|
return nil, constant.SystemErr
|
|
}
|
|
updated, markErr := data.MarkProviderSwitchParentSelectedWithTransaction(dao.Mdb, parent.TradeId)
|
|
if markErr != nil {
|
|
return nil, markErr
|
|
}
|
|
if !updated {
|
|
return nil, constant.OrderStatusConflict
|
|
}
|
|
_ = data.MarkOrderSelected(existing.TradeId)
|
|
existing.IsSelected = true
|
|
resp := buildCheckoutResponse(existing)
|
|
resp.PaymentUrl = providerRow.PayURL
|
|
return resp, nil
|
|
}
|
|
|
|
count, err := data.CountSubOrders(parent.TradeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count >= MaxSubOrders {
|
|
return nil, constant.SubOrderLimitExceeded
|
|
}
|
|
|
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
|
if rate <= 0 {
|
|
return nil, constant.RateAmountErr
|
|
}
|
|
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
return nil, constant.PayAmountErr
|
|
}
|
|
|
|
subTradeID := GenerateCode()
|
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
|
returnURL := strings.TrimSpace(parent.RedirectUrl)
|
|
if returnURL == "" {
|
|
returnURL = data.GetOkPayReturnURL()
|
|
}
|
|
if returnURL == "" {
|
|
returnURL = fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), parent.TradeId)
|
|
}
|
|
|
|
tx := dao.Mdb.Begin()
|
|
subOrder := &mdb.Orders{
|
|
TradeId: subTradeID,
|
|
OrderId: subTradeID,
|
|
ParentTradeId: parent.TradeId,
|
|
Amount: parent.Amount,
|
|
Currency: parent.Currency,
|
|
ActualAmount: amount,
|
|
ReceiveAddress: "OKPAY",
|
|
Token: token,
|
|
Network: mdb.NetworkTron,
|
|
Status: mdb.StatusWaitPay,
|
|
IsSelected: true,
|
|
NotifyUrl: "",
|
|
RedirectUrl: parent.RedirectUrl,
|
|
Name: parent.Name,
|
|
CallBackConfirm: mdb.CallBackConfirmOk,
|
|
PaymentType: parent.PaymentType,
|
|
PayProvider: mdb.PaymentProviderOkPay,
|
|
ApiKeyID: parent.ApiKeyID,
|
|
}
|
|
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
providerRow := &mdb.ProviderOrder{
|
|
TradeId: subTradeID,
|
|
Provider: mdb.PaymentProviderOkPay,
|
|
ProviderOrderID: "",
|
|
PayURL: "",
|
|
Amount: amount,
|
|
Coin: token,
|
|
Status: mdb.ProviderOrderStatusCreating,
|
|
}
|
|
if err = data.CreateProviderOrderWithTransaction(tx, providerRow); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
if err = tx.Commit().Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
okpayOrder, err := createOkPayDepositOrder(subTradeID, amount, token, returnURL)
|
|
if err != nil {
|
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
|
return nil, constant.PaymentProviderCreateErr
|
|
}
|
|
|
|
finalizeTx := dao.Mdb.Begin()
|
|
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
|
finalizeTx.Rollback()
|
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
|
return nil, err
|
|
}
|
|
updated, err := data.MarkProviderSwitchParentSelectedWithTransaction(finalizeTx, parent.TradeId)
|
|
if err != nil {
|
|
finalizeTx.Rollback()
|
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
|
return nil, err
|
|
}
|
|
if !updated {
|
|
finalizeTx.Rollback()
|
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
|
return nil, constant.OrderStatusConflict
|
|
}
|
|
if err = finalizeTx.Commit().Error; err != nil {
|
|
finalizeTx.Rollback()
|
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
|
return nil, err
|
|
}
|
|
|
|
resp := buildCheckoutResponse(subOrder)
|
|
resp.PaymentUrl = okpayOrder.PayURL
|
|
return resp, nil
|
|
}
|
|
|
|
func okPayTokenAllowed(token string) bool {
|
|
for _, item := range data.GetOkPayAllowTokens() {
|
|
if strings.EqualFold(item, token) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|