hyapp-server/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go

906 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package wallet_test
import (
"context"
"strings"
"testing"
"time"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
walletservice "hyapp/services/wallet-service/internal/service/wallet"
)
func TestVerifyCoinSellerRechargeReceiptMifaPayReturnsPaidSnapshot(t *testing.T) {
mifaPay := &fakeMifaPayClient{
queryResp: walletservice.MifaPayQueryOrderResponse{
OrderID: "receipt-mifa-1",
ProviderOrderID: "mb_receipt_mifa_1",
Status: "1",
Amount: "125000",
Currency: "PHP",
PayType: "GCASH",
RawJSON: `{"status":"1"}`,
},
}
svc := walletservice.New(nil)
svc.SetMifaPayClient(mifaPay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderMifaPay,
ExternalOrderNo: "receipt-mifa-1",
OrderDateMS: 1767225600000,
ProviderCurrencyCode: "php",
ProviderAmountMinor: 125000,
ProviderPayType: "gcash",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay failed: %v", err)
}
if !receipt.Verified || receipt.ProviderOrderID != "mb_receipt_mifa_1" || receipt.ProviderAmountMinor != 125000 || receipt.CurrencyCode != "PHP" || receipt.RawJSON == "" {
t.Fatalf("mifapay receipt snapshot mismatch: %+v", receipt)
}
if mifaPay.queryReq.OrderID != "receipt-mifa-1" || mifaPay.queryReq.OrderCreatedAtMS != 1767225600000 {
t.Fatalf("mifapay query request mismatch: %+v", mifaPay.queryReq)
}
}
func TestVerifyCoinSellerRechargeReceiptMifaPayRejectsAmountMismatch(t *testing.T) {
mifaPay := &fakeMifaPayClient{
queryResp: walletservice.MifaPayQueryOrderResponse{
OrderID: "receipt-mifa-amount",
Status: "1",
Amount: "125000",
Currency: "PHP",
RawJSON: `{"status":"1"}`,
},
}
svc := walletservice.New(nil)
svc.SetMifaPayClient(mifaPay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderMifaPay,
ExternalOrderNo: "receipt-mifa-amount",
OrderDateMS: 1767225600000,
ProviderCurrencyCode: "PHP",
ProviderAmountMinor: 125001,
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay amount mismatch failed: %v", err)
}
if receipt.Verified || receipt.FailureReason != "mifapay amount mismatch" {
t.Fatalf("mifapay amount mismatch should return failed snapshot, got %+v", receipt)
}
}
func TestVerifyCoinSellerRechargeReceiptMifaPayConvertsINRMajorAmount(t *testing.T) {
mifaPay := &fakeMifaPayClient{
queryResp: walletservice.MifaPayQueryOrderResponse{
OrderID: "receipt-mifa-inr",
Status: "1",
Amount: "100",
Currency: "INR",
RawJSON: `{"status":"1","amount":"100","currency":"INR"}`,
},
}
svc := walletservice.New(nil)
svc.SetMifaPayClient(mifaPay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderMifaPay,
ExternalOrderNo: "receipt-mifa-inr",
OrderDateMS: 1767225600000,
ProviderCurrencyCode: "INR",
ProviderAmountMinor: 10000,
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay INR failed: %v", err)
}
if !receipt.Verified || receipt.ProviderAmountMinor != 10000 {
t.Fatalf("mifapay INR whole-major amount should verify as local minor amount, got %+v", receipt)
}
}
func TestVerifyCoinSellerRechargeReceiptMifaPayRejectsInvalidAmountWhenExpected(t *testing.T) {
mifaPay := &fakeMifaPayClient{
queryResp: walletservice.MifaPayQueryOrderResponse{
OrderID: "receipt-mifa-bad-amount",
Status: "1",
Amount: "bad",
Currency: "PHP",
RawJSON: `{"status":"1","amount":"bad"}`,
},
}
svc := walletservice.New(nil)
svc.SetMifaPayClient(mifaPay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderMifaPay,
ExternalOrderNo: "receipt-mifa-bad-amount",
OrderDateMS: 1767225600000,
ProviderCurrencyCode: "PHP",
ProviderAmountMinor: 125000,
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay invalid amount failed: %v", err)
}
if receipt.Verified || receipt.FailureReason != "mifapay amount is invalid" {
t.Fatalf("mifapay invalid amount should return failed snapshot, got %+v", receipt)
}
}
func TestVerifyCoinSellerRechargeReceiptMifaPayRejectsInvalidAmountWithoutExpected(t *testing.T) {
mifaPay := &fakeMifaPayClient{
queryResp: walletservice.MifaPayQueryOrderResponse{
OrderID: "receipt-mifa-empty-amount",
Status: "1",
Amount: "",
Currency: "PHP",
RawJSON: `{"status":"1","amount":""}`,
},
}
svc := walletservice.New(nil)
svc.SetMifaPayClient(mifaPay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderMifaPay,
ExternalOrderNo: "receipt-mifa-empty-amount",
OrderDateMS: 1767225600000,
ProviderCurrencyCode: "PHP",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay empty amount failed: %v", err)
}
if receipt.Verified || receipt.FailureReason != "mifapay amount is invalid" {
t.Fatalf("mifapay empty amount should return failed snapshot, got %+v", receipt)
}
}
func TestVerifyCoinSellerRechargeReceiptMifaPayWithoutDateUsesRecentLookup(t *testing.T) {
mifaPay := &fakeMifaPayClient{
querySeq: []fakeMifaPayQueryResult{
{err: xerr.New(xerr.NotFound, "mifapay order not found")},
{resp: walletservice.MifaPayQueryOrderResponse{
OrderID: "receipt-mifa-no-date",
ProviderOrderID: "mb_receipt_mifa_no_date",
Status: "1",
Amount: "125000",
Currency: "PHP",
PayType: "GCASH",
RawJSON: `{"status":"1"}`,
}},
},
}
svc := walletservice.New(nil)
svc.SetMifaPayClient(mifaPay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderMifaPay,
ExternalOrderNo: "receipt-mifa-no-date",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay without date failed: %v", err)
}
if !receipt.Verified || receipt.ProviderOrderID != "mb_receipt_mifa_no_date" {
t.Fatalf("mifapay no-date receipt snapshot mismatch: %+v", receipt)
}
if len(mifaPay.queryReqs) != 2 {
t.Fatalf("mifapay no-date lookup should continue after not found, got %+v", mifaPay.queryReqs)
}
firstDate := time.UnixMilli(mifaPay.queryReqs[0].OrderCreatedAtMS).UTC()
secondDate := time.UnixMilli(mifaPay.queryReqs[1].OrderCreatedAtMS).UTC()
if firstDate.Hour() != 0 || secondDate.Hour() != 0 || !sameUTCDate(secondDate, firstDate.AddDate(0, 0, -1)) {
t.Fatalf("mifapay no-date lookup should walk backwards by UTC date: %+v", mifaPay.queryReqs)
}
}
func TestVerifyCoinSellerRechargeReceiptMifaPayWithoutDateReturnsUnpaidSnapshot(t *testing.T) {
mifaPay := &fakeMifaPayClient{
querySeq: []fakeMifaPayQueryResult{
{err: xerr.New(xerr.NotFound, "mifapay order not found")},
{resp: walletservice.MifaPayQueryOrderResponse{
OrderID: "receipt-mifa-unpaid",
Status: "0",
Amount: "125000",
Currency: "PHP",
RawJSON: `{"status":"0"}`,
}},
},
}
svc := walletservice.New(nil)
svc.SetMifaPayClient(mifaPay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderMifaPay,
ExternalOrderNo: "receipt-mifa-unpaid",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay unpaid failed: %v", err)
}
if receipt.Verified || receipt.FailureReason != "mifapay order status 0" {
t.Fatalf("mifapay unpaid should return failed snapshot, got %+v", receipt)
}
if len(mifaPay.queryReqs) != 2 {
t.Fatalf("mifapay unpaid should stop once an order is found, got %+v", mifaPay.queryReqs)
}
}
func TestVerifyCoinSellerRechargeReceiptV5PayRejectsCurrencyMismatchWithoutWriting(t *testing.T) {
v5Pay := &fakeV5PayClient{
queryResp: walletservice.V5PayQueryOrderResponse{
OrderID: "receipt-v5-1",
ProviderOrderID: "tx_receipt_v5_1",
Status: "2",
Amount: "48.00",
Currency: "TRY",
ProductType: "BANK_TRANSFER",
RawJSON: `{"status":"2"}`,
},
}
svc := walletservice.New(nil)
svc.SetV5PayClient(v5Pay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
ExternalOrderNo: "receipt-v5-1",
ProviderCountryCode: "TR",
ProviderCurrencyCode: "PHP",
ProviderAmountMinor: 4800,
ProviderPayType: "BANK_TRANSFER",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay failed: %v", err)
}
if receipt.Verified || receipt.FailureReason != "v5pay currency mismatch" || receipt.ProviderAmountMinor != 4800 {
t.Fatalf("v5pay mismatch should return a failed snapshot, got %+v", receipt)
}
if v5Pay.queryReq.CountryCode != "TR" || v5Pay.queryReq.CurrencyCode != "PHP" {
t.Fatalf("v5pay query request should use caller supplied country/currency: %+v", v5Pay.queryReq)
}
}
func TestVerifyCoinSellerRechargeReceiptV5PayRejectsAmountMismatch(t *testing.T) {
v5Pay := &fakeV5PayClient{
queryResp: walletservice.V5PayQueryOrderResponse{
OrderID: "receipt-v5-amount",
ProviderOrderID: "tx_receipt_v5_amount",
Status: "2",
Amount: "48.00",
Currency: "TRY",
ProductType: "BANK_TRANSFER",
RawJSON: `{"status":"2"}`,
},
}
svc := walletservice.New(nil)
svc.SetV5PayClient(v5Pay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
ExternalOrderNo: "receipt-v5-amount",
ProviderCountryCode: "TR",
ProviderCurrencyCode: "TRY",
ProviderAmountMinor: 4700,
ProviderPayType: "BANK_TRANSFER",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay amount mismatch failed: %v", err)
}
if receipt.Verified || receipt.FailureReason != "v5pay amount mismatch" || receipt.ProviderAmountMinor != 4800 {
t.Fatalf("v5pay amount mismatch should return a failed snapshot, got %+v", receipt)
}
}
func TestVerifyCoinSellerRechargeReceiptV5PayRejectsInvalidAmountWhenExpected(t *testing.T) {
v5Pay := &fakeV5PayClient{
queryResp: walletservice.V5PayQueryOrderResponse{
OrderID: "receipt-v5-bad-amount",
ProviderOrderID: "tx_receipt_v5_bad_amount",
Status: "2",
Amount: "bad",
Currency: "TRY",
ProductType: "BANK_TRANSFER",
RawJSON: `{"status":"2","amount":"bad"}`,
},
}
svc := walletservice.New(nil)
svc.SetV5PayClient(v5Pay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
ExternalOrderNo: "receipt-v5-bad-amount",
ProviderCountryCode: "TR",
ProviderCurrencyCode: "TRY",
ProviderAmountMinor: 4800,
ProviderPayType: "BANK_TRANSFER",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay invalid amount failed: %v", err)
}
if receipt.Verified || receipt.FailureReason != "v5pay amount is invalid" {
t.Fatalf("v5pay invalid amount should return a failed snapshot, got %+v", receipt)
}
}
func TestVerifyCoinSellerRechargeReceiptV5PayRejectsInvalidAmountWithoutExpected(t *testing.T) {
v5Pay := &fakeV5PayClient{
queryResp: walletservice.V5PayQueryOrderResponse{
OrderID: "receipt-v5-empty-amount",
ProviderOrderID: "tx_receipt_v5_empty_amount",
Status: "2",
Amount: "",
Currency: "TRY",
ProductType: "BANK_TRANSFER",
RawJSON: `{"status":"2","amount":""}`,
},
}
svc := walletservice.New(nil)
svc.SetV5PayClient(v5Pay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
ExternalOrderNo: "receipt-v5-empty-amount",
ProviderCountryCode: "TR",
ProviderCurrencyCode: "TRY",
ProviderPayType: "BANK_TRANSFER",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay empty amount failed: %v", err)
}
if receipt.Verified || receipt.FailureReason != "v5pay amount is invalid" {
t.Fatalf("v5pay empty amount should return failed snapshot, got %+v", receipt)
}
}
func TestVerifyCoinSellerRechargeReceiptV5PayConvertsWholeMajorAmount(t *testing.T) {
v5Pay := &fakeV5PayClient{
queryResp: walletservice.V5PayQueryOrderResponse{
OrderID: "receipt-v5-whole-major",
ProviderOrderID: "tx_receipt_v5_whole_major",
Status: "2",
Amount: "100",
Currency: "TRY",
ProductType: "BANK_TRANSFER",
RawJSON: `{"status":"2","amount":"100"}`,
},
}
svc := walletservice.New(nil)
svc.SetV5PayClient(v5Pay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
ExternalOrderNo: "receipt-v5-whole-major",
ProviderCountryCode: "TR",
ProviderCurrencyCode: "TRY",
ProviderAmountMinor: 10000,
ProviderPayType: "BANK_TRANSFER",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay whole major amount failed: %v", err)
}
if !receipt.Verified || receipt.ProviderAmountMinor != 10000 {
t.Fatalf("v5pay whole major amount should verify as local minor amount, got %+v", receipt)
}
}
func TestVerifyCoinSellerRechargeReceiptV5PayWithoutCountryCurrencyUsesEnabledCandidates(t *testing.T) {
repository := &fakeReceiptRepository{channels: []ledger.ThirdPartyPaymentChannel{{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
Status: ledger.ThirdPartyPaymentStatusActive,
Methods: []ledger.ThirdPartyPaymentMethod{
{ProviderCode: ledger.PaymentProviderV5Pay, CountryCode: "TR", CurrencyCode: "TRY", Status: ledger.ThirdPartyPaymentStatusActive},
{ProviderCode: ledger.PaymentProviderV5Pay, CountryCode: "PH", CurrencyCode: "PHP", Status: ledger.ThirdPartyPaymentStatusActive},
},
}}}
v5Pay := &fakeV5PayClient{
querySeq: []fakeV5PayQueryResult{
{err: xerr.New(xerr.NotFound, "v5pay order not found")},
{resp: walletservice.V5PayQueryOrderResponse{
OrderID: "receipt-v5-no-hints",
ProviderOrderID: "tx_receipt_v5_no_hints",
Status: "2",
Amount: "48.00",
Currency: "PHP",
ProductType: "GCASH",
RawJSON: `{"status":"2"}`,
}},
},
}
svc := walletservice.New(repository)
svc.SetV5PayClient(v5Pay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
ExternalOrderNo: "receipt-v5-no-hints",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay no hints failed: %v", err)
}
if !receipt.Verified || receipt.ProviderOrderID != "tx_receipt_v5_no_hints" {
t.Fatalf("v5pay no-hints receipt snapshot mismatch: %+v", receipt)
}
if !repository.querySeen || repository.query.ProviderCode != ledger.PaymentProviderV5Pay || repository.query.IncludeDisabledMethods {
t.Fatalf("v5pay no-hints lookup should enumerate active V5Pay methods: %+v", repository.query)
}
if len(v5Pay.queryReqs) != 2 || v5Pay.queryReqs[0].CountryCode != "TR" || v5Pay.queryReqs[0].CurrencyCode != "TRY" || v5Pay.queryReqs[1].CountryCode != "PH" || v5Pay.queryReqs[1].CurrencyCode != "PHP" {
t.Fatalf("v5pay no-hints lookup should use enabled country/currency candidates: %+v", v5Pay.queryReqs)
}
}
func TestVerifyCoinSellerRechargeReceiptV5PayUsesLaluCountryCurrencyConfig(t *testing.T) {
repository := &fakeReceiptRepository{channels: []ledger.ThirdPartyPaymentChannel{{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
Status: ledger.ThirdPartyPaymentStatusActive,
Methods: []ledger.ThirdPartyPaymentMethod{{
ProviderCode: ledger.PaymentProviderV5Pay,
CountryCode: "TR",
CurrencyCode: "TRY",
Status: ledger.ThirdPartyPaymentStatusActive,
}},
}}}
v5Pay := &fakeV5PayClient{queryResp: walletservice.V5PayQueryOrderResponse{
OrderID: "receipt-v5-yumi-shared-config",
ProviderOrderID: "tx_receipt_v5_yumi_shared_config",
Status: "2",
Amount: "48.00",
Currency: "TRY",
ProductType: "BANK_TRANSFER",
RawJSON: `{"status":"2"}`,
}}
svc := walletservice.New(repository)
svc.SetV5PayClient(v5Pay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "yumi",
ProviderCode: ledger.PaymentProviderV5Pay,
ExternalOrderNo: "receipt-v5-yumi-shared-config",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay shared config failed: %v", err)
}
if !receipt.Verified || repository.query.AppCode != "lalu" {
t.Fatalf("V5Pay receipt must query Lalu country/currency config, receipt=%+v query=%+v", receipt, repository.query)
}
if len(v5Pay.queryReqs) != 1 || v5Pay.queryReqs[0].CountryCode != "TR" || v5Pay.queryReqs[0].CurrencyCode != "TRY" {
t.Fatalf("V5Pay receipt must use the Lalu candidate, got %+v", v5Pay.queryReqs)
}
}
func TestVerifyCoinSellerRechargeReceiptV5PayWithoutCountryCurrencyNotFound(t *testing.T) {
repository := &fakeReceiptRepository{channels: []ledger.ThirdPartyPaymentChannel{{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
Status: ledger.ThirdPartyPaymentStatusActive,
Methods: []ledger.ThirdPartyPaymentMethod{
{ProviderCode: ledger.PaymentProviderV5Pay, CountryCode: "TR", CurrencyCode: "TRY", Status: ledger.ThirdPartyPaymentStatusActive},
{ProviderCode: ledger.PaymentProviderV5Pay, CountryCode: "PH", CurrencyCode: "PHP", Status: ledger.ThirdPartyPaymentStatusActive},
},
}}}
v5Pay := &fakeV5PayClient{querySeq: []fakeV5PayQueryResult{
{err: xerr.New(xerr.NotFound, "v5pay order not found")},
{err: xerr.New(xerr.NotFound, "v5pay order not found")},
}}
svc := walletservice.New(repository)
svc.SetV5PayClient(v5Pay)
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderV5Pay,
ExternalOrderNo: "receipt-v5-missing",
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay not found failed: %v", err)
}
if receipt.Verified || receipt.FailureReason != "v5pay order not found for enabled country/currency candidates" {
t.Fatalf("v5pay missing order should return failed snapshot, got %+v", receipt)
}
if len(v5Pay.queryReqs) != 2 {
t.Fatalf("v5pay missing order should exhaust enabled candidates, got %+v", v5Pay.queryReqs)
}
}
func TestVerifyCoinSellerRechargeReceiptUSDTBEP20UsesReadOnlyClient(t *testing.T) {
bsc := &fakeTronUSDTClient{rawJSON: `{"receipt":true}`}
svc := walletservice.New(nil)
svc.SetBSCUSDTClient(bsc)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTBEP20Enabled: true,
USDTBEP20Address: "0xPlatformBEP20ReceiveAddress",
})
txHash := "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderUSDTBEP20,
ExternalOrderNo: txHash,
USDMinorAmount: 150,
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt usdt_bep20 failed: %v", err)
}
if !receipt.Verified || receipt.Status != "confirmed" || receipt.Chain != "bep20" || receipt.ProviderAmountMinor != 150 || receipt.ReceiveAddress != "0xPlatformBEP20ReceiveAddress" {
t.Fatalf("bep20 receipt snapshot mismatch: %+v", receipt)
}
if bsc.lastTxHash != txHash || bsc.lastToAddress != "0xPlatformBEP20ReceiveAddress" || bsc.lastAmountMinor != 150 {
t.Fatalf("bep20 client request mismatch: %+v", bsc)
}
}
func TestVerifyCoinSellerRechargeReceiptUSDTBEP20DisabledFailsClosed(t *testing.T) {
svc := walletservice.New(nil)
_, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderUSDTBEP20,
ExternalOrderNo: "0xreceiptbep20",
USDMinorAmount: 150,
})
if !xerr.IsCode(err, xerr.Unavailable) || !strings.Contains(err.Error(), "not configured") {
t.Fatalf("disabled bep20 verification should fail closed, got %v", err)
}
}
func TestVerifyCoinSellerRechargeReceiptUSDTTRC20HashUsesTronGrid(t *testing.T) {
tron := &fakeTronUSDTClient{rawJSON: `{"chain":true}`}
binance := &fakeBinanceClient{err: xerr.New(xerr.Internal, "binance should not be called")}
svc := walletservice.New(nil)
svc.SetTronUSDTClient(tron)
svc.SetBinanceClient(binance)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTTRC20Enabled: true,
USDTTRC20Address: "TPlatformReceiveAddress",
})
txHash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderUSDTTRC20,
ExternalOrderNo: txHash,
USDMinorAmount: 50000,
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt usdt_trc20 hash failed: %v", err)
}
if !receipt.Verified || receipt.Status != "confirmed" || receipt.RawJSON != `{"chain":true}` {
t.Fatalf("trc20 hash receipt snapshot mismatch: %+v", receipt)
}
if tron.lastTxHash != txHash || tron.lastToAddress != "TPlatformReceiveAddress" || tron.lastAmountMinor != 50000 {
t.Fatalf("trc20 hash should use TronGrid client: %+v", tron)
}
if binance.calls != 0 {
t.Fatalf("chain tx hash must not call Binance, calls=%d", binance.calls)
}
}
func TestVerifyCoinSellerRechargeReceiptUSDTTRC20RejectsC2COrderNumber(t *testing.T) {
svc := walletservice.New(nil)
svc.SetTronUSDTClient(&fakeTronUSDTClient{})
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTTRC20Enabled: true,
USDTTRC20Address: "TPlatformReceiveAddress",
})
_, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderUSDTTRC20,
ExternalOrderNo: "440537037787594752",
USDMinorAmount: 20000,
})
if !xerr.IsCode(err, xerr.InvalidArgument) || !strings.Contains(err.Error(), "requires transaction hash") {
t.Fatalf("TRC20 must reject C2C order number without fallback: %v", err)
}
}
func TestVerifyCoinSellerRechargeReceiptUSDTC2CUsesBinance(t *testing.T) {
tron := &fakeTronUSDTClient{err: xerr.New(xerr.Internal, "tron should not be called")}
binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{
TxID: "Off-chain transfer 388611987194",
Coin: ledger.PaidCurrencyUSDT,
Network: "TRX",
Status: 1,
Address: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
Amount: "500.00000000",
AmountMinor: 50000,
RawJSON: `{"txId":"Off-chain transfer 388611987194","status":1}`,
}}
svc := walletservice.New(nil)
svc.SetTronUSDTClient(tron)
svc.SetBinanceClient(binance)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTTRC20Enabled: true,
USDTTRC20Address: "TGlobalFallbackReceiveAddress",
USDTTRC20Addresses: map[string]string{
"aslan": "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
},
})
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "aslan",
ProviderCode: ledger.PaymentProviderUSDTC2C,
ExternalOrderNo: "388611987194",
USDMinorAmount: 50000,
OrderDateMS: 1783344000000,
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt binance offchain failed: %v", err)
}
if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "Off-chain transfer 388611987194" || receipt.ProviderAmountMinor != 50000 || receipt.ReceiveAddress != "" {
t.Fatalf("binance offchain receipt snapshot mismatch: %+v", receipt)
}
if binance.calls != 1 || binance.req.AppCode != "aslan" || binance.req.ExternalID != "388611987194" || binance.req.Network != "C2C" || binance.req.ToAddress != "" || binance.req.AmountMinor != 50000 || binance.req.OrderTimeMS != 1783344000000 {
t.Fatalf("binance offchain request mismatch: calls=%d req=%+v", binance.calls, binance.req)
}
if tron.lastTxHash != "" {
t.Fatalf("off-chain Binance id must not call TronGrid: %+v", tron)
}
}
func TestVerifyCoinSellerRechargeReceiptUSDTC2CPayKeepsNoChainAddress(t *testing.T) {
binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{
Source: "pay_history",
ID: "433527303245463552",
TxID: "P_A223BU5M4Y171116",
Coin: ledger.PaidCurrencyUSDT,
Status: 1,
Amount: "100.00000000",
AmountMinor: 10000,
RawJSON: `{"orderType":"C2C","orderId":"433527303245463552","amount":"100.00000000","currency":"USDT"}`,
}}
svc := walletservice.New(nil)
svc.SetBinanceClient(binance)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTTRC20Enabled: true,
USDTTRC20Addresses: map[string]string{
"yumi": "TYumiConfiguredChainAddress",
},
})
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "yumi",
ProviderCode: ledger.PaymentProviderUSDTC2C,
ExternalOrderNo: "433527303245463552",
USDMinorAmount: 10000,
OrderDateMS: 1779713682799,
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt Pay C2C failed: %v", err)
}
if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "P_A223BU5M4Y171116" || receipt.ProviderAmountMinor != 10000 {
t.Fatalf("Pay C2C receipt snapshot mismatch: %+v", receipt)
}
if receipt.ReceiveAddress != "" {
t.Fatalf("Pay C2C must not inherit an unrelated chain address: %+v", receipt)
}
if binance.req.OrderTimeMS != 1779713682799 {
t.Fatalf("coin seller order time must bound Pay History query: %+v", binance.req)
}
}
func TestVerifyCoinSellerRechargeReceiptUSDTC2CPayUsesBinance(t *testing.T) {
bsc := &fakeTronUSDTClient{err: xerr.New(xerr.Internal, "bsc chain client should not be called")}
binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{
Source: "pay_history",
ID: "440537037787594752",
TxID: "P_C2C_BEP20_RECEIPT",
Coin: ledger.PaidCurrencyUSDT,
Status: 1,
Amount: "200.00000000",
AmountMinor: 20000,
RawJSON: `{"orderType":"C2C","orderId":"440537037787594752","amount":"200.00000000","currency":"USDT"}`,
}}
svc := walletservice.New(nil)
svc.SetBSCUSDTClient(bsc)
svc.SetBinanceClient(binance)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTBEP20Enabled: true,
USDTBEP20Addresses: map[string]string{
"yumi": "0xYumiConfiguredBSCAddress",
},
})
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "yumi",
ProviderCode: ledger.PaymentProviderUSDTC2C,
ExternalOrderNo: "440537037787594752",
USDMinorAmount: 20000,
OrderDateMS: 1784017140000,
})
if err != nil {
t.Fatalf("VerifyCoinSellerRechargeReceipt BEP20 Pay C2C failed: %v", err)
}
if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "P_C2C_BEP20_RECEIPT" || receipt.ProviderAmountMinor != 20000 {
t.Fatalf("BEP20 Pay C2C receipt snapshot mismatch: %+v", receipt)
}
if receipt.ReceiveAddress != "" {
t.Fatalf("C2C must not persist an unrelated chain address: %+v", receipt)
}
if binance.calls != 1 || binance.req.Network != "C2C" || binance.req.ExternalID != "440537037787594752" || binance.req.AmountMinor != 20000 {
t.Fatalf("C2C must use Binance transfer lookup: calls=%d req=%+v", binance.calls, binance.req)
}
if bsc.lastTxHash != "" {
t.Fatalf("BEP20 Pay C2C order id must not call BSC chain client: %+v", bsc)
}
}
func TestVerifyCoinSellerRechargeReceiptUSDTC2CDoesNotRequireChainConfig(t *testing.T) {
binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{
Source: "pay_history",
TxID: "P_C2C_LALU_RECEIPT",
Coin: ledger.PaidCurrencyUSDT,
Status: 1,
Amount: "200.00000000",
AmountMinor: 20000,
}}
svc := walletservice.New(nil)
svc.SetBinanceClient(binance)
// Lalu 只校验 Binance C2C 收款事实时不需要开启 BSC RPC也不需要伪造一个链上收款地址。
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{})
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "lalu",
ProviderCode: ledger.PaymentProviderUSDTC2C,
ExternalOrderNo: "440537037787594752",
USDMinorAmount: 20000,
OrderDateMS: 1784017620000,
})
if err != nil {
t.Fatalf("BEP20 Pay C2C must not require chain config: %v", err)
}
if !receipt.Verified || receipt.ProviderOrderID != "P_C2C_LALU_RECEIPT" {
t.Fatalf("BEP20 Pay C2C receipt mismatch without chain config: %+v", receipt)
}
if binance.calls != 1 || binance.req.ToAddress != "" || binance.req.Network != "C2C" {
t.Fatalf("BEP20 Pay C2C lookup mismatch without chain config: calls=%d req=%+v", binance.calls, binance.req)
}
}
func TestVerifyCoinSellerRechargeReceiptUSDTC2CMissingBinanceAccount(t *testing.T) {
binance := &fakeBinanceClient{err: xerr.New(xerr.Unavailable, "binance account is not configured")}
svc := walletservice.New(nil)
svc.SetBinanceClient(binance)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTTRC20Enabled: true,
USDTTRC20Address: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
})
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
AppCode: "yumi",
ProviderCode: ledger.PaymentProviderUSDTC2C,
ExternalOrderNo: "388611987194",
USDMinorAmount: 50000,
})
if err != nil {
t.Fatalf("missing binance account should return failed snapshot, got error %v", err)
}
if receipt.Verified || !strings.Contains(receipt.FailureReason, "binance account is not configured") {
t.Fatalf("missing binance account should be explicit in snapshot, got %+v", receipt)
}
}
func TestSubmitH5RechargeTxBinanceOffchainUsesAppScopedAccount(t *testing.T) {
for index, appCode := range []string{"lalu", "yumi", "aslan"} {
t.Run(appCode, func(t *testing.T) {
order := ledger.ExternalRechargeOrder{
OrderID: "order-" + appCode,
AppCode: appCode,
TargetUserID: int64(81000 + index),
USDMinorAmount: 30000,
ProviderCode: ledger.PaymentProviderUSDTTRC20,
ReceiveAddress: "TReceiveAddress-" + appCode,
Status: ledger.ExternalRechargeStatusPending,
CreatedAtMS: 1783344000000,
}
repository := &fakeH5USDTRepository{order: order}
binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{
TxID: "Off-chain transfer 390597460031",
AmountMinor: 30000,
Address: order.ReceiveAddress,
RawJSON: `{"txId":"Off-chain transfer 390597460031","status":1}`,
}}
tron := &fakeTronUSDTClient{err: xerr.New(xerr.Internal, "tron should not be called")}
svc := walletservice.New(repository)
svc.SetBinanceClient(binance)
svc.SetTronUSDTClient(tron)
credited, err := svc.SubmitH5RechargeTx(context.Background(), ledger.SubmitExternalRechargeTxCommand{
AppCode: appCode,
OrderID: order.OrderID,
TargetUserID: order.TargetUserID,
TxHash: "390597460031",
})
if err != nil {
t.Fatalf("SubmitH5RechargeTx Binance off-chain failed: %v", err)
}
if credited.Status != ledger.ExternalRechargeStatusCredited {
t.Fatalf("Binance off-chain order was not credited: %+v", credited)
}
if binance.calls != 1 || binance.req.AppCode != appCode || binance.req.ExternalID != "390597460031" || binance.req.Coin != ledger.PaidCurrencyUSDT || binance.req.Network != "TRX" || binance.req.ToAddress != order.ReceiveAddress || binance.req.AmountMinor != 30000 || binance.req.OrderTimeMS != order.CreatedAtMS {
t.Fatalf("Binance app-scoped lookup mismatch: calls=%d req=%+v", binance.calls, binance.req)
}
if tron.lastTxHash != "" {
t.Fatalf("Binance off-chain transfer must not query TronGrid: %+v", tron)
}
if repository.attachCommand.TxHash != "390597460031" || repository.attachPayload != binance.record.RawJSON {
t.Fatalf("submitted Binance evidence was not attached: command=%+v payload=%s", repository.attachCommand, repository.attachPayload)
}
if repository.creditProviderOrderID != binance.record.TxID || repository.creditTxHash != "390597460031" || repository.creditPayload != binance.record.RawJSON {
t.Fatalf("Binance credit evidence mismatch: provider_order_id=%s tx_hash=%s payload=%s", repository.creditProviderOrderID, repository.creditTxHash, repository.creditPayload)
}
})
}
}
type fakeReceiptRepository struct {
walletservice.Repository
channels []ledger.ThirdPartyPaymentChannel
query ledger.ListThirdPartyPaymentChannelsQuery
queryErr error
querySeen bool
}
type fakeH5USDTRepository struct {
walletservice.Repository
order ledger.ExternalRechargeOrder
attachCommand ledger.SubmitExternalRechargeTxCommand
attachPayload string
creditProviderOrderID string
creditTxHash string
creditPayload string
}
func (r *fakeH5USDTRepository) GetExternalRechargeOrder(_ context.Context, appCode string, orderID string) (ledger.ExternalRechargeOrder, error) {
if appCode != r.order.AppCode || orderID != r.order.OrderID {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found")
}
return r.order, nil
}
func (r *fakeH5USDTRepository) AttachExternalRechargeTx(_ context.Context, command ledger.SubmitExternalRechargeTxCommand, payloadJSON string) (ledger.ExternalRechargeOrder, error) {
// 测试仓储保留入账前的凭证快照,用于断言上游查单结果没有在 service 层丢失。
r.attachCommand = command
r.attachPayload = payloadJSON
r.order.TxHash = command.TxHash
r.order.ProviderPayloadJSON = payloadJSON
return r.order, nil
}
func (r *fakeH5USDTRepository) CreditExternalRechargeOrder(_ context.Context, appCode string, orderID string, providerOrderID string, txHash string, payloadJSON string) (ledger.ExternalRechargeOrder, error) {
if appCode != r.order.AppCode || orderID != r.order.OrderID {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found")
}
// 只有校验通过才会进入此方法;真实 MySQL 仓储仍负责唯一凭证和原子入账约束。
r.creditProviderOrderID = providerOrderID
r.creditTxHash = txHash
r.creditPayload = payloadJSON
r.order.ProviderOrderID = providerOrderID
r.order.TxHash = txHash
r.order.ProviderPayloadJSON = payloadJSON
r.order.Status = ledger.ExternalRechargeStatusCredited
return r.order, nil
}
func (r *fakeReceiptRepository) ListThirdPartyPaymentChannels(_ context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) {
r.query = query
r.querySeen = true
if r.queryErr != nil {
return nil, r.queryErr
}
return r.channels, nil
}
func (r *fakeReceiptRepository) GetExternalRechargeOrder(context.Context, string, string) (ledger.ExternalRechargeOrder, error) {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found")
}
func sameUTCDate(left time.Time, right time.Time) bool {
left = left.UTC()
right = right.UTC()
return left.Year() == right.Year() && left.YearDay() == right.YearDay()
}
type fakeBinanceClient struct {
req walletservice.BinanceTransferLookupRequest
record walletservice.BinanceTransferRecord
err error
calls int
}
func (f *fakeBinanceClient) FindUSDTTransfer(_ context.Context, req walletservice.BinanceTransferLookupRequest) (walletservice.BinanceTransferRecord, error) {
f.calls++
f.req = req
if f.err != nil {
return walletservice.BinanceTransferRecord{}, f.err
}
return f.record, nil
}