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", 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 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", 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 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 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", }) receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ AppCode: "lalu", ProviderCode: ledger.PaymentProviderUSDTBEP20, ExternalOrderNo: "0xreceiptbep20", 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 != "0xreceiptbep20" || 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 TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing.T) { tron := &fakeTronUSDTClient{err: xerr.New(xerr.Internal, "tron should not be called")} binance := &fakeBinanceClient{record: walletservice.BinanceDepositRecord{ 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.PaymentProviderUSDTTRC20, ExternalOrderNo: "388611987194", USDMinorAmount: 50000, }) 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 != "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN" { t.Fatalf("binance offchain receipt snapshot mismatch: %+v", receipt) } if binance.calls != 1 || binance.req.AppCode != "aslan" || binance.req.ExternalID != "388611987194" || binance.req.Network != "TRX" || binance.req.ToAddress != "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN" || binance.req.AmountMinor != 50000 { 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 TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainMissingBinanceAccount(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.PaymentProviderUSDTTRC20, 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) } } type fakeReceiptRepository struct { walletservice.Repository channels []ledger.ThirdPartyPaymentChannel query ledger.ListThirdPartyPaymentChannelsQuery queryErr error querySeen bool } 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.BinanceDepositLookupRequest record walletservice.BinanceDepositRecord err error calls int } func (f *fakeBinanceClient) FindUSDTDeposit(_ context.Context, req walletservice.BinanceDepositLookupRequest) (walletservice.BinanceDepositRecord, error) { f.calls++ f.req = req if f.err != nil { return walletservice.BinanceDepositRecord{}, f.err } return f.record, nil }