hyapp-server/server/admin/internal/modules/payment/legacy_bill_source_test.go
2026-07-06 11:10:19 +08:00

397 lines
14 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 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 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
}
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 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"])
}
}