package payment import ( "context" "testing" "time" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/modules/dashboard" "go.mongodb.org/mongo-driver/v2/bson" ) func yumiBillSourceConfig() config.FinanceBillSourceConfig { return config.FinanceBillSourceConfig{ Enabled: true, AppCode: "yumi", AppName: "Yumi", SysOrigin: "LIKEI", MongoDatabase: "test", MongoCollection: "in_app_purchase_details", RequestTimeout: time.Second, } } func aslanBillSourceConfig() config.FinanceBillSourceConfig { cfg := yumiBillSourceConfig() cfg.AppCode = "aslan" cfg.AppName = "Aslan" cfg.SysOrigin = "ATYOU" cfg.MongoDatabase = "atyou" return cfg } func TestLegacyRechargeBillDTOMapsGooglePurchase(t *testing.T) { amount, err := bson.ParseDecimal128("164.99") if err != nil { t.Fatalf("parse amount: %v", err) } amountUSD, err := bson.ParseDecimal128("2.99") if err != nil { t.Fatalf("parse amount usd: %v", err) } createTime := time.UnixMilli(1_751_000_000_000).UTC() dto := legacyRechargeBillDTO(yumiBillSourceConfig(), legacyPurchaseDocument{ ID: "175100000000012345", OrderID: "GPA.1234-5678-9012-34567", AcceptUserID: 42, CountryCode: "TR", Currency: "try", Amount: amount, AmountUSD: amountUSD, Status: "SUCCESS", Factory: legacyPurchaseFactory{Platform: "Android", FactoryCode: "GOOGLE"}, Products: []legacyPurchaseProduct{ {Name: "GOLD", Content: "560000"}, {Name: "PROPS", Content: "AVATAR_FRAME"}, }, CreateTime: createTime, }) if dto.AppCode != "yumi" || dto.TransactionID != "175100000000012345" { t.Fatalf("unexpected identity: %+v", dto) } if dto.RechargeType != "google_play_recharge" || dto.ProviderCode != "google" { t.Fatalf("unexpected channel mapping: %+v", dto) } if dto.Status != "succeeded" || dto.ExternalRef != "GPA.1234-5678-9012-34567" { t.Fatalf("unexpected status/external ref: %+v", dto) } if dto.CoinAmount != 560000 { t.Fatalf("coin amount = %d, want 560000", dto.CoinAmount) } if dto.USDMinorAmount != 299 { t.Fatalf("usd minor = %d, want 299", dto.USDMinorAmount) } if dto.UserPaidCurrencyCode != "TRY" || dto.UserPaidAmountMicro != 164_990_000 { t.Fatalf("unexpected user paid: %+v", dto) } // 谷歌账单初始必须是未同步(0),等 Orders API 实付缓存回填后才算同步完成。 if dto.CreatedAtMS != createTime.UnixMilli() || dto.PaidSyncedAtMS != 0 { t.Fatalf("unexpected timestamps: %+v", dto) } mifapayDTO := legacyRechargeBillDTO(yumiBillSourceConfig(), legacyPurchaseDocument{ ID: "175100000000054321", Factory: legacyPurchaseFactory{Platform: "H5", FactoryCode: "MIFA_PAY"}, CreateTime: createTime, }) if mifapayDTO.RechargeType != "mifapay" || mifapayDTO.PaidSyncedAtMS != mifapayDTO.CreatedAtMS { t.Fatalf("unexpected mifapay mapping: %+v", mifapayDTO) } } func TestLegacyRechargeTypeMapping(t *testing.T) { cases := map[string]string{ "GOOGLE": "google_play_recharge", "APPLE": "apple_recharge", "HUAWEI": "huawei_recharge", "TELEGRAM": "telegram_recharge", "PAYER_MAX": "web_recharge", "": "web_recharge", } for factoryCode, want := range cases { if got := legacyRechargeType(factoryCode); got != want { t.Fatalf("legacyRechargeType(%q) = %q, want %q", factoryCode, got, want) } } } func TestLegacyDecimalScaledHandlesTypes(t *testing.T) { decimal, err := bson.ParseDecimal128("6.99") if err != nil { t.Fatalf("parse decimal: %v", err) } if got := legacyDecimalToMinor(decimal); got != 699 { t.Fatalf("decimal minor = %d, want 699", got) } if got := legacyDecimalToMicro("54.99"); got != 54_990_000 { t.Fatalf("string micro = %d, want 54990000", got) } if got := legacyDecimalToMinor(int64(3)); got != 300 { t.Fatalf("int64 minor = %d, want 300", got) } if got := legacyDecimalToMinor(nil); got != 0 { t.Fatalf("nil minor = %d, want 0", got) } if got := legacyDecimalToMinor("not-a-number"); got != 0 { t.Fatalf("invalid minor = %d, want 0", got) } } type staticLegacyRegionResolver struct { codes []string handled bool } func (r staticLegacyRegionResolver) CountryCodesForRegion(context.Context, string, int64) ([]string, bool, error) { return r.codes, r.handled, nil } func TestLegacyBillFilterBuildsQuery(t *testing.T) { source := &MongoRechargeBillSource{ config: yumiBillSourceConfig(), regionResolvers: []legacyRegionCountryResolver{staticLegacyRegionResolver{codes: []string{"tr", "SA"}, handled: true}}, } filter, regionCodes, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{ Keyword: "GPA.1", RegionID: 9_000_000_000_000_000_001, StartAtMS: 1_000, EndAtMS: 2_000, }) if err != nil || !matchable { t.Fatalf("billFilter err=%v matchable=%v", err, matchable) } if filter["sysOrigin"] != "LIKEI" || filter["status"] != "SUCCESS" { t.Fatalf("unexpected base filter: %v", filter) } timeRange, ok := filter["createTime"].(bson.M) if !ok || !timeRange["$gte"].(time.Time).Equal(time.UnixMilli(1_000)) || !timeRange["$lt"].(time.Time).Equal(time.UnixMilli(2_000)) { t.Fatalf("unexpected time range: %v", filter["createTime"]) } // likei 账单不带国家码;区域条件必须以国家码清单返回,由聚合阶段按付款用户资料匹配。 if len(regionCodes) != 2 || regionCodes[0] != "TR" || regionCodes[1] != "SA" { t.Fatalf("unexpected region codes: %v", regionCodes) } if _, ok := filter["countryCode"]; ok { t.Fatalf("bill countryCode filter should not be used: %v", filter) } if _, ok := filter["$or"]; !ok { t.Fatalf("keyword filter missing: %v", filter) } } func TestLegacyBillFilterExcludesFreightGoldRecharge(t *testing.T) { source := &MongoRechargeBillSource{config: yumiBillSourceConfig()} filter, _, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{RechargeType: "third_party"}) if err != nil || !matchable { t.Fatalf("billFilter err=%v matchable=%v", err, matchable) } nor, ok := filter["$nor"].(bson.A) if !ok || len(nor) != len(legacyFreightCommodityFields) { t.Fatalf("freight exclusion missing: %v", filter) } for _, condition := range nor { item, ok := condition.(bson.M) if !ok { t.Fatalf("unexpected freight condition: %#v", condition) } for _, value := range item { typed, ok := value.(bson.M) if !ok || typed["$in"] == nil { t.Fatalf("unexpected freight condition value: %#v", value) } } } } func TestLegacyBillFilterUnresolvedRegionMatchesNothing(t *testing.T) { source := &MongoRechargeBillSource{config: yumiBillSourceConfig()} _, _, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{RegionID: 123}) if err != nil { t.Fatalf("billFilter err=%v", err) } if matchable { t.Fatalf("unresolved region should not be matchable") } } type fakeDashboardRechargeSource struct { appCode string overview map[string]any query dashboard.StatisticsQuery calls int bills []dashboard.CoinSellerRechargeBill billTotal int64 billQuery dashboard.CoinSellerRechargeBillQuery billCalls int } func (s *fakeDashboardRechargeSource) AppCode() string { return s.appCode } func (s *fakeDashboardRechargeSource) StatisticsOverview(_ context.Context, query dashboard.StatisticsQuery) (map[string]any, error) { s.query = query s.calls++ return s.overview, nil } func (s *fakeDashboardRechargeSource) ListCoinSellerRechargeBills(_ context.Context, query dashboard.CoinSellerRechargeBillQuery) ([]dashboard.CoinSellerRechargeBill, int64, error) { s.billQuery = query s.billCalls++ return s.bills, s.billTotal, nil } func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) { dashboardSource := &fakeDashboardRechargeSource{ appCode: "aslan", billTotal: 1, bills: []dashboard.CoinSellerRechargeBill{{ TransactionID: "aslan-freight-purchase-99", Source: "freight_purchase", UserID: 2069828597070528514, CountryCode: "PH", USDMinorAmount: 132790, CreatedAtMS: 1_783_530_000_000, }}, } source := &MongoRechargeBillSource{ config: aslanBillSourceConfig(), regionSources: []MoneyRegionSource{&staticMoneyRegionSource{regions: []moneyRegionDTO{{ AppCode: "aslan", RegionID: 301, Name: "Philippines", Countries: []string{"PH"}, }}}}, } source.SetDashboardCoinSellerSource(dashboardSource) items, total, err := source.ListRechargeBills(context.Background(), legacyRechargeBillQuery{ RechargeType: "coin_seller", StartAtMS: 1_783_468_800_000, EndAtMS: 1_786_060_800_000, Page: 1, PageSize: 20, }) if err != nil { t.Fatalf("ListRechargeBills: %v", err) } if total != 1 || len(items) != 1 { t.Fatalf("unexpected result total=%d items=%+v", total, items) } item := items[0] if item.RechargeType != "coin_seller_stock_purchase" || item.ProviderCode != "coin_seller" { t.Fatalf("unexpected coin seller mapping: %+v", item) } if item.TransactionID != "aslan-freight-purchase-99" || item.SellerUserID != 2069828597070528514 || item.USDMinorAmount != 132790 { t.Fatalf("unexpected bill fields: %+v", item) } if item.SellerRegionID != 301 || item.TargetRegionID != 301 { t.Fatalf("region mapping failed: %+v", item) } if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 { t.Fatalf("paid facts mismatch: %+v", item) } if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 { t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery) } } func TestLegacyCoinSellerDashboardStatsMergeSummary(t *testing.T) { dashboardSource := &fakeDashboardRechargeSource{ appCode: "Yumi", overview: map[string]any{ "coin_seller_recharge_usd_minor": int64(350_010), }, } source := &MongoRechargeBillSource{config: yumiBillSourceConfig()} source.SetDashboardCoinSellerSource(dashboardSource) shanghai, err := time.LoadLocation("Asia/Shanghai") if err != nil { t.Fatalf("load location: %v", err) } start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai) summary := rechargeBillSummaryDTO{ GooglePlay: rechargeBillSummaryBucketDTO{BillCount: 2, USDMinorAmount: 10_000}, ThirdParty: rechargeBillSummaryBucketDTO{BillCount: 3, USDMinorAmount: 20_000}, } if err := source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{ StartAtMS: start.UnixMilli(), EndAtMS: start.AddDate(0, 0, 1).UnixMilli(), }, 480, &summary); err != nil { t.Fatalf("mergeCoinSellerSummary: %v", err) } legacyRecomputeSummaryTotal(&summary) if summary.CoinSeller.USDMinorAmount != 350_010 { t.Fatalf("coin seller usd = %d, want 350010", summary.CoinSeller.USDMinorAmount) } if summary.Total.USDMinorAmount != 380_010 || summary.Total.BillCount != 5 { t.Fatalf("unexpected total after coin seller merge: %+v", summary.Total) } if dashboardSource.calls != 1 || dashboardSource.query.StatTZ != "Asia/Shanghai" { t.Fatalf("dashboard query mismatch: calls=%d query=%+v", dashboardSource.calls, dashboardSource.query) } } func TestLegacyCoinSellerDashboardStatsRejectsPartialDayRange(t *testing.T) { dashboardSource := &fakeDashboardRechargeSource{ appCode: "yumi", overview: map[string]any{"coin_seller_recharge_usd_minor": int64(100)}, } source := &MongoRechargeBillSource{config: yumiBillSourceConfig()} source.SetDashboardCoinSellerSource(dashboardSource) shanghai, err := time.LoadLocation("Asia/Shanghai") if err != nil { t.Fatalf("load location: %v", err) } start := time.Date(2026, 7, 5, 10, 0, 0, 0, shanghai) summary := rechargeBillSummaryDTO{} err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{ StartAtMS: start.UnixMilli(), EndAtMS: start.Add(2 * time.Hour).UnixMilli(), }, 480, &summary) if err == nil { t.Fatalf("partial day range should fail") } if dashboardSource.calls != 0 { t.Fatalf("dashboard source should not be called for partial day range") } err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{}, 480, &summary) if err == nil { t.Fatalf("missing day range should fail") } } func TestLegacyCoinSellerDashboardStatsRequiresMetricField(t *testing.T) { dashboardSource := &fakeDashboardRechargeSource{ appCode: "yumi", overview: map[string]any{"coin_seller_recharge_usd_minor": nil}, } source := &MongoRechargeBillSource{config: yumiBillSourceConfig()} source.SetDashboardCoinSellerSource(dashboardSource) shanghai, err := time.LoadLocation("Asia/Shanghai") if err != nil { t.Fatalf("load location: %v", err) } start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai) summary := rechargeBillSummaryDTO{} err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{ StartAtMS: start.UnixMilli(), EndAtMS: start.AddDate(0, 0, 1).UnixMilli(), }, 480, &summary) if err == nil { t.Fatalf("missing dashboard coin seller field should fail") } if dashboardSource.calls != 1 { t.Fatalf("dashboard source calls = %d, want 1", dashboardSource.calls) } } func TestLegacyCoinSellerDashboardStatsMergeOverview(t *testing.T) { dashboardSource := &fakeDashboardRechargeSource{ appCode: "yumi", overview: map[string]any{ "coin_seller_recharge_usd_minor": int64(5_000), "daily_series": []map[string]any{ {"stat_day": "2026-07-05", "coin_seller_recharge_usd_minor": int64(4_200)}, {"stat_day": "2026-07-06", "coin_seller_recharge_usd_minor": int64(800)}, }, "country_breakdown": []map[string]any{ {"country_code": "SA", "coin_seller_recharge_usd_minor": int64(4_200)}, {"country_code": "AE", "coin_seller_recharge_usd_minor": int64(800)}, }, }, } source := &MongoRechargeBillSource{ config: yumiBillSourceConfig(), regionSources: []MoneyRegionSource{&staticMoneyRegionSource{regions: []moneyRegionDTO{{ AppCode: "yumi", RegionID: 101, Name: "Gulf", Countries: []string{"SA", "AE"}, }}}}, } source.SetDashboardCoinSellerSource(dashboardSource) shanghai, err := time.LoadLocation("Asia/Shanghai") if err != nil { t.Fatalf("load location: %v", err) } start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai) overview := rechargeBillOverviewDTO{ Daily: []rechargeBillDailyBucketDTO{{Date: "2026-07-05", ThirdPartyUsdMinor: 2_000}}, Regions: []rechargeBillRegionBucketDTO{{ RegionID: 101, Name: "Gulf", UsdMinorAmount: 2_000, }}, } if err := source.mergeCoinSellerOverview(context.Background(), legacyRechargeBillQuery{ StartAtMS: start.UnixMilli(), EndAtMS: start.AddDate(0, 0, 2).UnixMilli(), }, 480, &overview); err != nil { t.Fatalf("mergeCoinSellerOverview: %v", err) } if len(overview.Daily) != 2 { t.Fatalf("daily buckets = %d, want 2: %+v", len(overview.Daily), overview.Daily) } if overview.Daily[0].Date != "2026-07-05" || overview.Daily[0].CoinSellerUsdMinor != 4_200 { t.Fatalf("existing day not merged: %+v", overview.Daily) } if overview.Daily[1].Date != "2026-07-06" || overview.Daily[1].CoinSellerUsdMinor != 800 { t.Fatalf("new day not appended: %+v", overview.Daily) } if len(overview.Regions) != 1 || overview.Regions[0].UsdMinorAmount != 7_000 { t.Fatalf("region not merged: %+v", overview.Regions) } } func TestLegacyRegionLookupStages(t *testing.T) { if stages := legacyRegionLookupStages(nil); len(stages) != 0 { t.Fatalf("empty codes should not add stages: %v", stages) } stages := legacyRegionLookupStages([]string{"TR", "SA"}) if len(stages) != 3 { t.Fatalf("expected lookup/addFields/match stages, got %d", len(stages)) } lookup, ok := stages[0][0].Value.(bson.M) if !ok || lookup["from"] != legacyUserProfileCollection || lookup["localField"] != "acceptUserId" || lookup["foreignField"] != "_id" { t.Fatalf("unexpected lookup stage: %v", stages[0]) } match, ok := stages[2][0].Value.(bson.M) if !ok { t.Fatalf("unexpected match stage: %v", stages[2]) } condition, ok := match["legacyBillCountry"].(bson.M) if !ok { t.Fatalf("match stage missing country condition: %v", match) } codes, ok := condition["$in"].([]string) if !ok || len(codes) != 2 || codes[0] != "TR" || codes[1] != "SA" { t.Fatalf("unexpected region codes in match: %v", condition["$in"]) } }