feat(payment): support placeholder orders for GMPay and EPay

This commit is contained in:
line-6000
2026-06-16 17:57:20 +08:00
parent 04c2b9b0b6
commit 3ab3659fe4
27 changed files with 1755 additions and 134 deletions
+2 -2
View File
@@ -163,9 +163,9 @@ func seedDefaultSettings() {
defaults := []mdb.Setting{
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeySystemLogLevel, Value: mdb.SettingDefaultSystemLogLevel, Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayEnabled, Value: "false", Type: mdb.SettingTypeBool},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAPIURL, Value: "https://api.okaypay.me/shop/", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayCallbackURL, Value: okPayCallbackURL, Type: mdb.SettingTypeString},
+57
View File
@@ -95,6 +95,63 @@ func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) {
}
}
func TestSeedDefaultSettingsUsesEmptyEpayTokenAndNetwork(t *testing.T) {
db := setupSeedSettingsTestDB(t)
Mdb = db
seedDefaultSettings()
rows := make(map[string]mdb.Setting)
for _, key := range []string{
mdb.SettingKeyEpayDefaultToken,
mdb.SettingKeyEpayDefaultCurrency,
mdb.SettingKeyEpayDefaultNetwork,
} {
var row mdb.Setting
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
t.Fatalf("load %s seed: %v", key, err)
}
rows[key] = row
}
if rows[mdb.SettingKeyEpayDefaultToken].Value != "" {
t.Fatalf("epay.default_token seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultToken].Value)
}
if rows[mdb.SettingKeyEpayDefaultNetwork].Value != "" {
t.Fatalf("epay.default_network seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultNetwork].Value)
}
if rows[mdb.SettingKeyEpayDefaultCurrency].Value != "cny" {
t.Fatalf("epay.default_currency seed = %q, want cny", rows[mdb.SettingKeyEpayDefaultCurrency].Value)
}
}
func TestSeedDefaultSettingsDoesNotOverwriteExistingEpayDefaults(t *testing.T) {
db := setupSeedSettingsTestDB(t)
Mdb = db
for _, row := range []mdb.Setting{
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
} {
if err := Mdb.Create(&row).Error; err != nil {
t.Fatalf("precreate %s: %v", row.Key, err)
}
}
seedDefaultSettings()
for key, want := range map[string]string{
mdb.SettingKeyEpayDefaultToken: "usdt",
mdb.SettingKeyEpayDefaultNetwork: "tron",
} {
var row mdb.Setting
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
t.Fatalf("load %s seed: %v", key, err)
}
if row.Value != want {
t.Fatalf("%s value = %q, want existing %q", key, row.Value, want)
}
}
}
func TestSeedDefaultSettingsDoesNotOverwriteSystemLogLevel(t *testing.T) {
db := setupSeedSettingsTestDB(t)
Mdb = db
+3 -3
View File
@@ -105,12 +105,12 @@ func CountOrdersByStatus() (map[int]int64, error) {
return out, nil
}
// CloseOrderManually transitions a pending order to expired. Only
// touches rows currently in StatusWaitPay so idempotent / safe.
// CloseOrderManually transitions a pending order to expired. It covers both
// payable orders and placeholder orders waiting for token/network selection.
func CloseOrderManually(tradeID string) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusWaitPay).
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
}
+65 -1
View File
@@ -209,7 +209,7 @@ func SaveCallBackOrdersResp(order *mdb.Orders) error {
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
result := dao.Mdb.Model(mdb.Orders{}).
Where("id = ?", id).
Where("status = ?", mdb.StatusWaitPay).
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
Where("created_at <= ?", expirationCutoff).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
@@ -225,6 +225,15 @@ func CountActiveSubOrders(parentTradeId string) (int64, error) {
return count, err
}
// CountSubOrders counts all sub-orders ever created under a parent.
func CountSubOrders(parentTradeId string) (int64, error) {
var count int64
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Count(&count).Error
return count, err
}
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
order := new(mdb.Orders)
@@ -288,6 +297,61 @@ func MarkOrderSelected(tradeId string) error {
Update("is_selected", true).Error
}
// CompleteWaitSelectOrder fills a placeholder order with concrete chain fields
// and makes it payable. It only updates status=WaitSelect rows so concurrent
// switch-network attempts cannot overwrite a payable order.
func CompleteWaitSelectOrder(tradeID string, network string, token string, receiveAddress string, actualAmount float64) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusWaitSelect).
Updates(map[string]interface{}{
"status": mdb.StatusWaitPay,
"network": strings.ToLower(strings.TrimSpace(network)),
"token": strings.ToUpper(strings.TrimSpace(token)),
"receive_address": receiveAddress,
"actual_amount": actualAmount,
"is_selected": false,
"created_at": time.Now(),
})
return result.RowsAffected > 0, result.Error
}
// CompleteWaitSelectOkPayOrderWithTransaction converts a placeholder parent
// directly into an OkPay order. It intentionally does not create a local chain
// lock because OkPay owns the hosted payment target.
func CompleteWaitSelectOkPayOrderWithTransaction(tx *gorm.DB, tradeID string, token string, actualAmount float64) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusWaitSelect).
Updates(map[string]interface{}{
"status": mdb.StatusWaitPay,
"network": mdb.PaymentProviderOkPay,
"token": strings.ToUpper(strings.TrimSpace(token)),
"receive_address": "OKPAY",
"actual_amount": actualAmount,
"is_selected": false,
"pay_provider": mdb.PaymentProviderOkPay,
"created_at": time.Now(),
})
return result.RowsAffected > 0, result.Error
}
// MarkProviderSwitchParentSelectedWithTransaction marks a switch-network parent
// as selected for a hosted payment provider. It intentionally does not fill
// chain fields or create a transaction lock; the provider child order carries
// the concrete payment target.
func MarkProviderSwitchParentSelectedWithTransaction(tx *gorm.DB, tradeID string) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
Updates(map[string]interface{}{
"status": mdb.StatusWaitPay,
"is_selected": true,
"created_at": time.Now(),
})
return result.RowsAffected > 0, result.Error
}
// ExpireOrderByTradeID marks a waiting order as expired. Used to retire failed
// child-order attempts that should not remain selectable/reusable.
func ExpireOrderByTradeID(tradeId string) error {
+21 -1
View File
@@ -4,6 +4,7 @@ import (
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
@@ -20,8 +21,27 @@ func CreateProviderOrderWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) err
return tx.Model(row).Create(row).Error
}
func UpsertProviderOrderCreatingWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "trade_id"}, {Name: "provider"}},
DoUpdates: clause.AssignmentColumns([]string{
"provider_order_id",
"pay_url",
"amount",
"coin",
"status",
"notify_raw",
"updated_at",
}),
}).Create(row).Error
}
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
return UpdateProviderOrderCreatedWithTransaction(dao.Mdb, tradeID, provider, providerOrderID, payURL)
}
func UpdateProviderOrderCreatedWithTransaction(tx *gorm.DB, tradeID string, provider string, providerOrderID string, payURL string) error {
return tx.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Updates(map[string]interface{}{
+25 -13
View File
@@ -1,15 +1,24 @@
package mdb
const (
StatusWaitPay = 1
StatusPaySuccess = 2
StatusExpired = 3
StatusWaitPay = 1
StatusPaySuccess = 2
StatusExpired = 3
// StatusWaitSelect is a placeholder order waiting for the payer to choose
// token+network or a hosted provider such as OkPay. It is not payable until
// switch-network moves it to StatusWaitPay.
StatusWaitSelect = 4
CallBackConfirmOk = 1
CallBackConfirmNo = 2
)
const (
// PaymentTypeEpay is the normalized special payment_type value. Orders
// matching it case-insensitively use the legacy EPay callback shape.
PaymentTypeEpay = "Epay"
// PaymentTypeGmpay is the normalized default for GMPay-created orders.
// It uses the default GMPay JSON callback shape.
PaymentTypeGmpay = "Gmpay"
)
const (
@@ -19,9 +28,10 @@ const (
// PaymentProviderOkPay means this concrete order record is settled through
// the third-party OkayPay/OkPay hosted checkout flow.
//
// In the current design this is typically used by a switch-network-created
// child order, while the parent order keeps its original merchant-facing
// semantics and callback behavior.
// It can be used by a status=4 parent completed in place on first
// switch-network, or by a switch-network-created child order after the
// parent is already concrete. Merchant callback shape is still controlled by
// PaymentType, not by this provider value.
PaymentProviderOkPay = "okpay"
)
@@ -36,21 +46,23 @@ type Orders struct {
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
Token string `gorm:"column:token" json:"token" example:"USDT"`
Network string `gorm:"column:network" json:"network" example:"tron"`
// 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3,4" example:"1"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
// 回调确认状态 1=回调成功 2=未回调/回调失败
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
// PaymentType is not a channel selector. "Epay" changes callback format
// to legacy EPay case-insensitively; "Gmpay" uses GMPay JSON.
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
// PayProvider identifies how this specific order row is collected.
//
// Semantics:
// - parent orders and regular chain child orders use on_chain
// - third-party hosted checkout child orders use their provider name
// - chain parent orders and regular chain child orders use on_chain
// - third-party hosted checkout rows use their provider name
// (for example okpay)
//
// Existing rows default to on_chain for backward compatibility so upgrades
+2 -2
View File
@@ -9,8 +9,8 @@ const (
)
// ProviderOrder stores provider-specific checkout data for one internal order
// row. For OkPay this is expected to bind to the concrete child order created
// by switch-network, not to the parent merchant order.
// row. For OkPay this can bind either to a status=4 parent completed in place
// or to a concrete child order created by later switch-network calls.
//
// Binding rules:
// - trade_id always points to orders.trade_id
+7 -3
View File
@@ -6,14 +6,18 @@ import "github.com/gookit/validate"
type CreateTransactionRequest struct {
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt、ton
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:ton、tron
Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单
Network string `json:"network" form:"network" example:"tron"` // 网络 如:ton、tron;可与 token 同时缺省创建占位订单
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
Name string `json:"name" form:"name" example:"VIP月卡"`
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
// PaymentType is a compatibility flag, not a gateway selector. Only
// "Epay" (case-insensitive) switches callback format to legacy EPay;
// empty or any other value is stored as "Gmpay" and uses GMPay JSON.
// It is optional for GMPay, but must be included in the signature when sent.
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
}
func (r CreateTransactionRequest) Translates() map[string]string {
+1
View File
@@ -9,6 +9,7 @@ type CreateTransactionResponse struct {
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
}
+14 -12
View File
@@ -1,24 +1,26 @@
package response
type CheckoutCounterResponse struct {
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数 加密货币金额
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额status=4 占位订单返回 0
Token string `json:"token" example:"USDT"` // 所属币种status=4 占位订单为空
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址status=4 占位订单为空
Network string `json:"network" example:"tron"` // 网络 TRON ETHstatus=4 占位订单为空
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单通常为本地收银台OkPay 订单为第三方 payLink
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单为空OkPay 订单为第三方 payLink
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected" example:"false"`
IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
}
type CheckStatusResponse struct {
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
// 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `json:"status" enums:"1,2,3" example:"1"`
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
Status int `json:"status" enums:"1,2,3,4" example:"1"`
}
type ManualPaymentResponse struct {
+3 -3
View File
@@ -7,9 +7,9 @@ type NetworkTokenSupport struct {
}
type EpayPublicConfig struct {
DefaultToken string `json:"default_token" example:"usdt"`
DefaultCurrency string `json:"default_currency" example:"cny"`
DefaultNetwork string `json:"default_network" example:"tron"`
DefaultToken string `json:"default_token" example:""` // EPay default token; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; falls back to cny when unset.
DefaultNetwork string `json:"default_network" example:""` // EPay default network; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
}
type SitePublicConfig struct {
+73
View File
@@ -102,6 +102,79 @@ func TestHandleOkPayNotifySendsPaymentNotificationOnSuccess(t *testing.T) {
}
}
func TestHandleOkPayNotifySettlesPlaceholderParent(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const (
shopID = "okpay-shop-placeholder"
shopToken = "okpay-shop-placeholder-token"
parentTradeID = "okpay-placeholder-parent-001"
providerOrderID = "okpay-placeholder-provider-001"
)
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, shopID, mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay shop id: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, shopToken, mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay shop token: %v", err)
}
parent := &mdb.Orders{
TradeId: parentTradeID,
OrderId: "merchant-okpay-placeholder-parent-001",
Amount: 1,
Currency: "CNY",
ActualAmount: 0.15,
ReceiveAddress: "OKPAY",
Token: "USDT",
Network: mdb.PaymentProviderOkPay,
Status: mdb.StatusWaitPay,
IsSelected: true,
PaymentType: mdb.PaymentTypeGmpay,
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(parent).Error; err != nil {
t.Fatalf("seed parent order: %v", err)
}
if err := dao.Mdb.Create(&mdb.ProviderOrder{
TradeId: parentTradeID,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: providerOrderID,
Amount: 0.15,
Coin: "USDT",
Status: mdb.ProviderOrderStatusPending,
}).Error; err != nil {
t.Fatalf("seed provider order: %v", err)
}
form := okPayNotifyTestForm(shopID, shopToken, providerOrderID, parentTradeID, "0.15000000", "USDT")
if err := HandleOkPayNotify(form, "placeholder-okpay-form"); err != nil {
t.Fatalf("handle okpay notify: %v", err)
}
reloadedParent, err := data.GetOrderInfoByTradeId(parentTradeID)
if err != nil {
t.Fatalf("reload parent: %v", err)
}
if reloadedParent.Status != mdb.StatusPaySuccess {
t.Fatalf("parent status = %d, want %d", reloadedParent.Status, mdb.StatusPaySuccess)
}
if reloadedParent.PayBySubId != 0 {
t.Fatalf("parent pay_by_sub_id = %d, want 0", reloadedParent.PayBySubId)
}
if reloadedParent.Token != "USDT" || reloadedParent.Network != mdb.PaymentProviderOkPay || reloadedParent.ReceiveAddress != "OKPAY" || reloadedParent.ActualAmount != 0.15 {
t.Fatalf("okpay parent fields changed: token=%q network=%q address=%q actual=%v", reloadedParent.Token, reloadedParent.Network, reloadedParent.ReceiveAddress, reloadedParent.ActualAmount)
}
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parentTradeID, mdb.PaymentProviderOkPay)
if err != nil {
t.Fatalf("reload provider row: %v", err)
}
if providerRow.Status != mdb.ProviderOrderStatusPaid {
t.Fatalf("provider status = %q, want %q", providerRow.Status, mdb.ProviderOrderStatusPaid)
}
}
func okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, amount, coin string) map[string]string {
form := map[string]string{
"code": "200",
+290 -41
View File
@@ -70,15 +70,44 @@ func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
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()
@@ -93,6 +122,32 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
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
}
@@ -105,11 +160,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
return nil, constant.RateAmountErr
}
decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
@@ -146,7 +197,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl,
Name: req.Name,
PaymentType: req.PaymentType,
PaymentType: paymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey),
}
@@ -161,19 +212,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
return nil, err
}
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
resp := &response.CreateTransactionResponse{
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
Currency: order.Currency,
ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token,
ExpirationTime: expirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
}
return resp, nil
return buildCreateTransactionResponse(order), nil
}
// OrderProcessing marks an order as paid and releases its sqlite reservation.
@@ -206,15 +245,21 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
return err
}
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, 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 == "" {
@@ -232,8 +277,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, err)
}
}
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)
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
@@ -284,8 +331,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
}
// Release parent's own wallet lock
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)
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.
@@ -295,8 +344,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, err)
}
}
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)
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)
}
}
}
@@ -448,9 +499,20 @@ func isOnChainOrder(payProvider string) bool {
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
}
const MaxSubOrders = 2
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
}
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
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()
@@ -469,7 +531,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
if parent.ParentTradeId != "" {
return nil, constant.CannotSwitchSubOrder
}
if parent.Status != mdb.StatusWaitPay {
if parent.Status != mdb.StatusWaitPay && parent.Status != mdb.StatusWaitSelect {
return nil, constant.OrderNotWaitPay
}
@@ -477,6 +539,10 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
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)
@@ -497,8 +563,9 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return buildCheckoutResponse(existing), nil
}
// 4. Check sub-order limit
count, err := data.CountActiveSubOrders(parent.TradeId)
// 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
}
@@ -585,7 +652,63 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
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 strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay) {
paymentType = mdb.PaymentTypeEpay
}
return &response.CheckoutCounterResponse{
TradeId: order.TradeId,
Amount: order.Amount,
@@ -594,14 +717,93 @@ func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse
Currency: order.Currency,
ReceiveAddress: order.ReceiveAddress,
Network: order.Network,
Status: order.Status,
PaymentType: strings.ToLower(paymentType),
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: order.RedirectUrl,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
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
@@ -613,6 +815,29 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
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
@@ -625,16 +850,21 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
return nil, constant.SystemErr
}
_ = data.MarkOrderSelected(parent.TradeId)
updated, markErr := data.MarkProviderSwitchParentSelectedWithTransaction(dao.Mdb, parent.TradeId)
if markErr != nil {
return nil, markErr
}
if !updated {
return nil, constant.OrderStatusConflict
}
_ = data.MarkOrderSelected(existing.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
existing.IsSelected = true
resp := buildCheckoutResponse(existing)
resp.PaymentUrl = providerRow.PayURL
return resp, nil
}
count, err := data.CountActiveSubOrders(parent.TradeId)
count, err := data.CountSubOrders(parent.TradeId)
if err != nil {
return nil, err
}
@@ -711,14 +941,33 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
_ = data.ExpireOrderByTradeID(subTradeID)
return nil, constant.PaymentProviderCreateErr
}
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
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
}
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
resp := buildCheckoutResponse(subOrder)
resp.PaymentUrl = okpayOrder.PayURL
+246 -12
View File
@@ -87,6 +87,85 @@ func TestCreateTransactionRejectsPrivateNotifyURL(t *testing.T) {
}
}
func TestCreateTransactionCreatesWaitSelectPlaceholderWithoutTokenNetwork(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
req := newCreateTransactionRequest("order_wait_select_1", 12.34)
req.Token = ""
req.Network = ""
resp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create placeholder transaction: %v", err)
}
if resp.Status != mdb.StatusWaitSelect {
t.Fatalf("response status = %d, want %d", resp.Status, mdb.StatusWaitSelect)
}
if resp.Token != "" || resp.ReceiveAddress != "" || resp.ActualAmount != 0 {
t.Fatalf("placeholder chain fields = token %q address %q actual %.4f", resp.Token, resp.ReceiveAddress, resp.ActualAmount)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload placeholder order: %v", err)
}
if order.Status != mdb.StatusWaitSelect {
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusWaitSelect)
}
if order.PaymentType != mdb.PaymentTypeGmpay {
t.Fatalf("placeholder payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeGmpay)
}
if order.Token != "" || order.Network != "" || order.ReceiveAddress != "" || order.ActualAmount != 0 {
t.Fatalf("placeholder order has chain fields: %+v", order)
}
var locks int64
if err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).Where("trade_id = ?", resp.TradeId).Count(&locks).Error; err != nil {
t.Fatalf("count transaction locks: %v", err)
}
if locks != 0 {
t.Fatalf("placeholder lock count = %d, want 0", locks)
}
}
func TestCreateTransactionRejectsPartialTokenNetwork(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
req := newCreateTransactionRequest("order_partial_token_network_1", 1)
req.Token = ""
if _, err := CreateTransaction(req, nil); err != constant.ParamsMarshalErr {
t.Fatalf("missing token error = %v, want %v", err, constant.ParamsMarshalErr)
}
req = newCreateTransactionRequest("order_partial_token_network_2", 1)
req.Network = ""
if _, err := CreateTransaction(req, nil); err != constant.ParamsMarshalErr {
t.Fatalf("missing network error = %v, want %v", err, constant.ParamsMarshalErr)
}
}
func TestCreateTransactionNormalizesEpayPaymentTypeCase(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
req := newCreateTransactionRequest("order_epay_payment_type_1", 1)
req.Token = ""
req.Network = ""
req.PaymentType = "epay"
resp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create epay-compatible transaction: %v", err)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if order.PaymentType != mdb.PaymentTypeEpay {
t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
}
}
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
@@ -459,6 +538,146 @@ func TestSwitchNetworkCreatesTonSubOrderAndRawLock(t *testing.T) {
}
}
func TestSwitchNetworkCompletesWaitSelectPlaceholderInPlace(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
t.Fatalf("set forced rate: %v", err)
}
if _, err := data.AddWalletAddress("TWaitSelectAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
req := newCreateTransactionRequest("order_wait_select_switch_1", 10)
req.Token = ""
req.Network = ""
parentResp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create placeholder: %v", err)
}
if parentResp.Status != mdb.StatusWaitSelect {
t.Fatalf("parent response status = %d, want %d", parentResp.Status, mdb.StatusWaitSelect)
}
switched, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "USDT",
Network: mdb.NetworkTron,
})
if err != nil {
t.Fatalf("switch placeholder to tron/usdt: %v", err)
}
if switched.TradeId != parentResp.TradeId {
t.Fatalf("switch returned trade_id = %q, want parent %q", switched.TradeId, parentResp.TradeId)
}
if switched.Status != mdb.StatusWaitPay {
t.Fatalf("switch status = %d, want %d", switched.Status, mdb.StatusWaitPay)
}
if switched.IsSelected {
t.Fatal("switch is_selected = true, want false")
}
if switched.PaymentUrl != "" {
t.Fatalf("switch payment_url = %q, want empty for unselected chain parent", switched.PaymentUrl)
}
if switched.Token != "USDT" || switched.Network != mdb.NetworkTron || switched.ReceiveAddress != "TWaitSelectAddress001" {
t.Fatalf("switch chain fields = token %q network %q address %q", switched.Token, switched.Network, switched.ReceiveAddress)
}
if got := fmt.Sprintf("%.2f", switched.ActualAmount); got != "1.00" {
t.Fatalf("switch actual amount = %s, want 1.00", got)
}
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
if err != nil {
t.Fatalf("reload parent: %v", err)
}
if parent.Status != mdb.StatusWaitPay || parent.ParentTradeId != "" {
t.Fatalf("parent status/parent_trade_id = %d/%q", parent.Status, parent.ParentTradeId)
}
if parent.IsSelected {
t.Fatal("parent is_selected = true, want false")
}
if parent.PaymentType != mdb.PaymentTypeGmpay {
t.Fatalf("parent payment_type = %q, want %q", parent.PaymentType, mdb.PaymentTypeGmpay)
}
count, err := data.CountActiveSubOrders(parent.TradeId)
if err != nil {
t.Fatalf("count sub orders: %v", err)
}
if count != 0 {
t.Fatalf("active sub-order count = %d, want 0", count)
}
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, parent.ReceiveAddress, parent.Token, parent.ActualAmount)
if err != nil {
t.Fatalf("lookup parent lock: %v", err)
}
if lockTradeID != parent.TradeId {
t.Fatalf("parent lock trade_id = %q, want %q", lockTradeID, parent.TradeId)
}
}
func TestSwitchNetworkAfterWaitSelectCompletionUsesExistingSubOrderFlow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
t.Fatalf("set forced rate: %v", err)
}
if _, err := data.AddWalletAddress("TWaitSelectAddress002"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455668"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
req := newCreateTransactionRequest("order_wait_select_reswitch_1", 10)
req.Token = ""
req.Network = ""
parentResp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create placeholder: %v", err)
}
first, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "USDT",
Network: mdb.NetworkTron,
})
if err != nil {
t.Fatalf("first switch: %v", err)
}
second, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "USDT",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("second switch: %v", err)
}
if second.TradeId == first.TradeId {
t.Fatalf("second switch trade_id = parent %q, want sub-order", second.TradeId)
}
if second.Network != mdb.NetworkEthereum || second.Token != "USDT" {
t.Fatalf("second switch token/network = %s/%s", second.Token, second.Network)
}
count, err := data.CountActiveSubOrders(parentResp.TradeId)
if err != nil {
t.Fatalf("count active sub orders: %v", err)
}
if count != 1 {
t.Fatalf("active sub-order count = %d, want 1", count)
}
_, err = SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "USDT",
Network: mdb.NetworkBsc,
})
if err != constant.SubOrderLimitExceeded {
t.Fatalf("third switch error = %v, want %v", err, constant.SubOrderLimitExceeded)
}
}
func TestSwitchNetworkRejectsUnsupportedTonToken(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
@@ -1219,7 +1438,6 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
t.Fatalf("create parent order: %v", err)
}
// Create two sub-orders on different networks.
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
@@ -1229,13 +1447,29 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
t.Fatalf("switch to ethereum sub-order: %v", err)
}
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkBsc,
})
if err != nil {
t.Fatalf("switch to bsc sub-order: %v", err)
bscTradeID := GenerateCode()
bscAddress := strings.ToLower("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
subBsc := &mdb.Orders{
TradeId: bscTradeID,
OrderId: bscTradeID,
ParentTradeId: parentResp.TradeId,
Amount: parentResp.Amount,
Currency: parentResp.Currency,
ActualAmount: subEthResp.ActualAmount,
ReceiveAddress: bscAddress,
Token: "USDT",
Network: mdb.NetworkBsc,
Status: mdb.StatusWaitPay,
IsSelected: true,
CallBackConfirm: mdb.CallBackConfirmOk,
PaymentType: mdb.PaymentTypeGmpay,
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(subBsc).Error; err != nil {
t.Fatalf("seed bsc sibling sub-order: %v", err)
}
if err := data.LockTransaction(mdb.NetworkBsc, bscAddress, "USDT", bscTradeID, subBsc.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock bsc sibling amount: %v", err)
}
// Pay the Ethereum sub-order.
@@ -1252,12 +1486,12 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
}
// BSC sibling must be expired.
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
reloadedBsc, err := data.GetOrderInfoByTradeId(subBsc.TradeId)
if err != nil {
t.Fatalf("reload bsc sub-order: %v", err)
}
if subBsc.Status != mdb.StatusExpired {
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
if reloadedBsc.Status != mdb.StatusExpired {
t.Fatalf("bsc sibling status = %d, want %d (expired)", reloadedBsc.Status, mdb.StatusExpired)
}
// Parent runtime lock must be released.
@@ -1272,7 +1506,7 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
// BSC sibling runtime lock must be released.
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
mdb.NetworkBsc, subBsc.ReceiveAddress, subBsc.Token, subBsc.ActualAmount)
if err != nil {
t.Fatalf("check bsc sibling runtime lock: %v", err)
}
+18
View File
@@ -1,8 +1,11 @@
package service
import (
"strings"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
)
@@ -20,6 +23,10 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
if orderInfo.ID <= 0 {
return nil, ErrOrder
}
paymentType := mdb.PaymentTypeGmpay
if strings.EqualFold(orderInfo.PaymentType, mdb.PaymentTypeEpay) {
paymentType = mdb.PaymentTypeEpay
}
resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId,
@@ -29,10 +36,21 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
Currency: orderInfo.Currency,
ReceiveAddress: orderInfo.ReceiveAddress,
Network: orderInfo.Network,
Status: orderInfo.Status,
PaymentType: strings.ToLower(paymentType),
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: orderInfo.RedirectUrl,
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
IsSelected: orderInfo.IsSelected,
}
if orderInfo.PayProvider == mdb.PaymentProviderOkPay {
providerRow, rowErr := data.GetProviderOrderByTradeIDAndProvider(orderInfo.TradeId, mdb.PaymentProviderOkPay)
if rowErr != nil {
return nil, rowErr
}
if providerRow.ID > 0 {
resp.PaymentUrl = providerRow.PayURL
}
}
return resp, nil
}