diff --git a/server/admin/internal/modules/dashboard/aslan_coin_seller_bills.go b/server/admin/internal/modules/dashboard/aslan_coin_seller_bills.go index ff33412d..813dcef8 100644 --- a/server/admin/internal/modules/dashboard/aslan_coin_seller_bills.go +++ b/server/admin/internal/modules/dashboard/aslan_coin_seller_bills.go @@ -108,8 +108,7 @@ func (s *AslanMongoDashboardSource) coinSellerRechargeBillUnionSQL(query CoinSel goldArgs := []any{s.sysOrigin} freightWhere := []string{ "fbrw.sys_origin = ?", - "fbrw.origin = 'PURCHASE'", - "fbrw.type = 0", + "((fbrw.origin = 'PURCHASE' AND fbrw.type = 0) OR (fbrw.origin = 'DEDUCTION' AND fbrw.type = 1))", "(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)", "ubi.country_code IS NOT NULL", "ubi.country_code != ''", @@ -132,7 +131,7 @@ func (s *AslanMongoDashboardSource) coinSellerRechargeBillUnionSQL(query CoinSel if keyword := strings.TrimSpace(query.Keyword); keyword != "" { goldWhere = append(goldWhere, "(CAST(ugcr.id AS CHAR) = ? OR CONCAT('aslan-gold-coin-', ugcr.id) = ? OR CAST(ugcr.user_id AS CHAR) = ?)") goldArgs = append(goldArgs, keyword, keyword, keyword) - freightWhere = append(freightWhere, "(CAST(fbrw.id AS CHAR) = ? OR CONCAT('aslan-freight-purchase-', fbrw.id) = ? OR CAST(fbrw.user_id AS CHAR) = ?)") + freightWhere = append(freightWhere, "(CAST(fbrw.id AS CHAR) = ? OR CONCAT('aslan-freight-', LOWER(fbrw.origin), '-', fbrw.id) = ? OR CAST(fbrw.user_id AS CHAR) = ?)") freightArgs = append(freightArgs, keyword, keyword, keyword) } if countryFilter.Applied { @@ -170,11 +169,18 @@ FROM user_gold_coin_recharge ugcr INNER JOIN user_base_info ubi ON ubi.id = ugcr.user_id WHERE ` + strings.Join(goldWhere, " AND ") + ` UNION ALL -SELECT CONCAT('aslan-freight-purchase-', fbrw.id) AS transaction_id, - 'freight_purchase' AS source, +SELECT CONCAT('aslan-freight-', LOWER(fbrw.origin), '-', fbrw.id) AS transaction_id, + CONCAT('freight_', LOWER(fbrw.origin)) AS source, fbrw.user_id, UPPER(ubi.country_code) AS country_code, - CAST(CASE WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount ELSE COALESCE(fbrw.usd_quantity, 0) END AS CHAR) AS amount, + CAST(CASE + WHEN fbrw.origin = 'DEDUCTION' OR fbrw.type = 1 THEN -ABS(CASE + WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount + ELSE COALESCE(fbrw.usd_quantity, 0) + END) + WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount + ELSE COALESCE(fbrw.usd_quantity, 0) + END AS CHAR) AS amount, fbrw.create_time AS created_at FROM ` + quoteMySQLIdentifier(walletDatabase) + `.user_freight_balance_running_water fbrw INNER JOIN user_base_info ubi ON ubi.id = fbrw.user_id diff --git a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go index 2871af1c..57bd97d2 100644 --- a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go +++ b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go @@ -836,7 +836,7 @@ func (s *AslanMongoDashboardSource) loadLegacyCoinSellerRechargeStats(ctx contex if err := s.loadLegacyGoldCoinRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil { return err } - if err := s.loadLegacyFreightPurchaseRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil { + if err := s.loadLegacyFreightStockRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil { return err } return nil @@ -912,17 +912,17 @@ GROUP BY DATE_FORMAT(ugcr.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), ugc return nil } -func (s *AslanMongoDashboardSource) loadLegacyFreightPurchaseRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error { +func (s *AslanMongoDashboardSource) loadLegacyFreightStockRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error { walletDatabase := strings.TrimSpace(s.legacyWalletDatabase) if walletDatabase == "" { return nil } // 后台“金币代理 > 发货”实际是给代理进货:origin=PURCHASE/type=0,表单金额落 amount; + // “扣减”是库存/USDT 冲减:origin=DEDUCTION/type=1,amount 在源系统仍是正数,统计时必须转成负数。 // App/经销商路径可能只落 usd_quantity,因此金额优先取 amount,空值再回退 usd_quantity。 where := []string{ "fbrw.sys_origin = ?", - "fbrw.origin = 'PURCHASE'", - "fbrw.type = 0", + "((fbrw.origin = 'PURCHASE' AND fbrw.type = 0) OR (fbrw.origin = 'DEDUCTION' AND fbrw.type = 1))", "fbrw.create_time >= ?", "fbrw.create_time < ?", "(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)", @@ -956,6 +956,10 @@ SELECT DATE_FORMAT(fbrw.create_time, '%Y-%m-%d') AS stat_day, UPPER(ubi.country_code) AS country_code, fbrw.user_id, CAST(COALESCE(SUM(CASE + WHEN fbrw.origin = 'DEDUCTION' OR fbrw.type = 1 THEN -ABS(CASE + WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount + ELSE COALESCE(fbrw.usd_quantity, 0) + END) WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount ELSE COALESCE(fbrw.usd_quantity, 0) END), 0) AS CHAR) AS amount @@ -964,7 +968,7 @@ FROM `+quoteMySQLIdentifier(walletDatabase)+`.user_freight_balance_running_water WHERE `+strings.Join(where, " AND ")+` GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbrw.user_id`, args...) if err != nil { - return fmt.Errorf("query legacy freight purchase recharge: %w", err) + return fmt.Errorf("query legacy freight stock recharge: %w", err) } defer rows.Close() @@ -974,7 +978,7 @@ GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbr var userID int64 var amountText string if err := rows.Scan(&day, &countryCode, &userID, &amountText); err != nil { - return fmt.Errorf("scan legacy freight purchase recharge: %w", err) + return fmt.Errorf("scan legacy freight stock recharge: %w", err) } day = strings.TrimSpace(day) countryCode = aslanMongoCountryCode(countryCode) @@ -983,24 +987,27 @@ GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbr } amount, err := decimalTextToMinor(amountText) if err != nil { - return fmt.Errorf("parse legacy freight purchase recharge amount for %s/%s/%d: %w", day, countryCode, userID, err) + return fmt.Errorf("parse legacy freight stock recharge amount for %s/%s/%d: %w", day, countryCode, userID, err) } addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, day, countryCode, userID, amount) } if err := rows.Err(); err != nil { - return fmt.Errorf("iterate legacy freight purchase recharge: %w", err) + return fmt.Errorf("iterate legacy freight stock recharge: %w", err) } return nil } func addLegacyCoinSellerRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) { - if amount <= 0 { + if amount == 0 { return } metric := grid.at(day, countryCode) metric.RechargeUSDMinor += amount metric.CoinSellerRechargeUSDMinor += amount - addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount) + // 扣减流水按负数冲减金额,但不是一次新的付费行为,不能增加付费用户或新用户充值。 + if amount > 0 { + addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount) + } } func addLegacyStandardRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) { @@ -1748,7 +1755,7 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map } } legacySources := []map[string]any{ - {"field": "coin_seller_recharge_usd_minor", "available": true, "source": "likei legacy MySQL user_gold_coin_recharge gold_coin_source=2 recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity"}, + {"field": "coin_seller_recharge_usd_minor", "available": true, "source": "likei legacy MySQL user_gold_coin_recharge gold_coin_source=2 recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"}, } rechargeUsersSource := "likei Mongo in_app_purchase_details SUCCESS distinct acceptUserId(排除 FREIGHT_GOLD)" rechargeAmountSource := "likei Mongo in_app_purchase_details amountUsd(排除 FREIGHT_GOLD)" @@ -1760,7 +1767,7 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map } } else { rechargeUsersSource += " + legacy MySQL order_other_recharge/user_gold_coin_recharge/user_freight_balance_running_water distinct user_id" - rechargeAmountSource += " + legacy MySQL order_other_recharge amount + user_gold_coin_recharge recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity" + rechargeAmountSource += " + legacy MySQL order_other_recharge amount + user_gold_coin_recharge recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity" mifaPaySource += " + legacy MySQL order_other_recharge amount" newUserRechargeSource += "/legacy 其他充值/legacy 币商充值" } diff --git a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go index 4f54df0e..40455ae2 100644 --- a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go +++ b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go @@ -169,7 +169,7 @@ func TestAslanMongoLegacyOtherRechargeFeedsThirdPartyChannel(t *testing.T) { } } -func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testing.T) { +func TestAslanMongoLegacyFreightStockAdjustmentsFeedCoinSellerChannel(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock: %v", err) @@ -196,11 +196,12 @@ func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testin mock.ExpectQuery("FROM user_gold_coin_recharge[\\s\\S]*ugcr\\.gold_coin_source = 2"). WithArgs("ATYOU", start, end). WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"})) - mock.ExpectQuery("FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw[\\s\\S]*fbrw\\.origin = 'PURCHASE'[\\s\\S]*fbrw\\.type = 0"). + mock.ExpectQuery("FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw[\\s\\S]*fbrw\\.origin = 'PURCHASE'[\\s\\S]*fbrw\\.origin = 'DEDUCTION'"). WithArgs("ATYOU", start, end). WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}). AddRow("2026-07-05", "PH", int64(2069828597070528514), "2000.00"). - AddRow("2026-07-05", "PK", int64(2071589123112779777), "25.47")) + AddRow("2026-07-05", "PK", int64(2071589123112779777), "25.47"). + AddRow("2026-07-05", "SA", int64(2071589123112779778), "-1000.00")) if err := source.loadLegacyCoinSellerRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil { t.Fatalf("loadLegacyCoinSellerRechargeStats: %v", err) @@ -218,6 +219,12 @@ func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testin assertInt64(t, pkRow["coin_seller_recharge_usd_minor"], 2547) assertInt64(t, pkRow["recharge_usd_minor"], 2547) + saRow := grid.metrics["2026-07-05"]["SA"].toExternal(nil).toMap() + assertInt64(t, saRow["coin_seller_recharge_usd_minor"], -100000) + assertInt64(t, saRow["recharge_usd_minor"], -100000) + assertInt64(t, saRow["user_recharge_usd_minor"], 0) + assertInt64(t, saRow["paid_users"], 0) + if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } @@ -244,10 +251,11 @@ func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) { queryPattern := "FROM user_gold_coin_recharge ugcr[\\s\\S]*UNION ALL[\\s\\S]*FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw" mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*"+queryPattern). WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(2))) + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(3))) mock.ExpectQuery("SELECT transaction_id, source, user_id, country_code, amount, created_at[\\s\\S]*"+queryPattern). WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg, 10, 0). WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "source", "user_id", "country_code", "amount", "created_at"}). + AddRow("aslan-freight-deduction-100", "freight_deduction", int64(2069828597070528516), "ae", "-1000.00", createdAt.Add(time.Minute)). AddRow("aslan-freight-purchase-99", "freight_purchase", int64(2069828597070528514), "ph", "1327.90", createdAt). AddRow("aslan-gold-coin-7", "gold_coin_recharge", int64(2069828597070528515), "SA", "42.00", createdAt.Add(-time.Hour))) @@ -260,18 +268,21 @@ func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) { if err != nil { t.Fatalf("ListCoinSellerRechargeBills: %v", err) } - if total != 2 || len(items) != 2 { + if total != 3 || len(items) != 3 { t.Fatalf("unexpected result total=%d items=%+v", total, items) } - if items[0].TransactionID != "aslan-freight-purchase-99" || items[0].Source != "freight_purchase" || items[0].CountryCode != "PH" { + if items[0].TransactionID != "aslan-freight-deduction-100" || items[0].Source != "freight_deduction" || items[0].CountryCode != "AE" { t.Fatalf("unexpected first item identity: %+v", items[0]) } - if items[0].USDMinorAmount != 132790 || items[0].CreatedAtMS != createdAt.UnixMilli() { + if items[0].USDMinorAmount != -100000 || items[0].CreatedAtMS != createdAt.Add(time.Minute).UnixMilli() { t.Fatalf("unexpected first item amount/time: %+v", items[0]) } - if items[1].TransactionID != "aslan-gold-coin-7" || items[1].USDMinorAmount != 4200 { + if items[1].TransactionID != "aslan-freight-purchase-99" || items[1].USDMinorAmount != 132790 { t.Fatalf("unexpected second item: %+v", items[1]) } + if items[2].TransactionID != "aslan-gold-coin-7" || items[2].USDMinorAmount != 4200 { + t.Fatalf("unexpected third item: %+v", items[2]) + } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } @@ -300,24 +311,31 @@ func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) { } } -func TestAslanMongoLegacyRechargeSkipsNonPositiveAmount(t *testing.T) { +func TestAslanMongoLegacyRechargeHandlesZeroAndDeductionAmount(t *testing.T) { start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC) grid := newLegacyMongoGrid(start, start.AddDate(0, 0, 1)) distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{} perUser := map[string]map[int64]int64{} addLegacyStandardRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 101, 0) - addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, -100) + addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, 0) + if row := grid.metrics["2026-07-05"]["SA"]; row != nil { + t.Fatalf("zero legacy recharge should not create metric row: %#v", row) + } + addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, -100) row := grid.metrics["2026-07-05"]["SA"] - if row != nil { - t.Fatalf("non-positive legacy recharge should not create metric row: %#v", row) + if row == nil { + t.Fatalf("negative coin seller deduction should create metric row") + } + if row.RechargeUSDMinor != -100 || row.CoinSellerRechargeUSDMinor != -100 { + t.Fatalf("deduction should reduce recharge metrics: %#v", row) } if len(distinctUsers) != 0 { - t.Fatalf("non-positive legacy recharge should not create paid users: %#v", distinctUsers) + t.Fatalf("deduction should not create paid users: %#v", distinctUsers) } if len(perUser) != 0 { - t.Fatalf("non-positive legacy recharge should not create per-user recharge: %#v", perUser) + t.Fatalf("deduction should not create per-user recharge: %#v", perUser) } } diff --git a/server/admin/internal/modules/payment/legacy_bill_source.go b/server/admin/internal/modules/payment/legacy_bill_source.go index f3c7ec9b..d6df89fc 100644 --- a/server/admin/internal/modules/payment/legacy_bill_source.go +++ b/server/admin/internal/modules/payment/legacy_bill_source.go @@ -296,7 +296,7 @@ func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Contex item := rechargeBillDTO{ AppCode: s.config.AppCode, TransactionID: row.TransactionID, - RechargeType: "coin_seller_stock_purchase", + RechargeType: legacyCoinSellerBillRechargeType(row.Source), Status: "succeeded", ExternalRef: row.Source, SellerUserID: row.UserID, @@ -318,6 +318,15 @@ func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Contex return items, total, nil } +func legacyCoinSellerBillRechargeType(source string) string { + switch strings.ToLower(strings.TrimSpace(source)) { + case "freight_deduction", "coin_seller_stock_deduction": + return "coin_seller_stock_deduction" + default: + return "coin_seller_stock_purchase" + } +} + func (s *MongoRechargeBillSource) listBillsByFilter(ctx context.Context, filter bson.M, page int, pageSize int) ([]rechargeBillDTO, int64, error) { total, err := s.collection.CountDocuments(ctx, filter) if err != nil { diff --git a/server/admin/internal/modules/payment/legacy_bill_source_test.go b/server/admin/internal/modules/payment/legacy_bill_source_test.go index d7cc7130..24a21a13 100644 --- a/server/admin/internal/modules/payment/legacy_bill_source_test.go +++ b/server/admin/internal/modules/payment/legacy_bill_source_test.go @@ -237,15 +237,25 @@ func (s *fakeDashboardRechargeSource) ListCoinSellerRechargeBills(_ context.Cont 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, - }}, + billTotal: 2, + bills: []dashboard.CoinSellerRechargeBill{ + { + TransactionID: "aslan-freight-purchase-99", + Source: "freight_purchase", + UserID: 2069828597070528514, + CountryCode: "PH", + USDMinorAmount: 132790, + CreatedAtMS: 1_783_530_000_000, + }, + { + TransactionID: "aslan-freight-deduction-100", + Source: "freight_deduction", + UserID: 2069828597070528515, + CountryCode: "AE", + USDMinorAmount: -100000, + CreatedAtMS: 1_783_530_060_000, + }, + }, } source := &MongoRechargeBillSource{ config: aslanBillSourceConfig(), @@ -254,6 +264,11 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) { RegionID: 301, Name: "Philippines", Countries: []string{"PH"}, + }, { + AppCode: "aslan", + RegionID: 302, + Name: "United Arab Emirates", + Countries: []string{"AE"}, }}}}, } source.SetDashboardCoinSellerSource(dashboardSource) @@ -268,7 +283,7 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) { if err != nil { t.Fatalf("ListRechargeBills: %v", err) } - if total != 1 || len(items) != 1 { + if total != 2 || len(items) != 2 { t.Fatalf("unexpected result total=%d items=%+v", total, items) } item := items[0] @@ -284,6 +299,19 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) { if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 { t.Fatalf("paid facts mismatch: %+v", item) } + deduction := items[1] + if deduction.RechargeType != "coin_seller_stock_deduction" || deduction.ProviderCode != "coin_seller" { + t.Fatalf("unexpected deduction mapping: %+v", deduction) + } + if deduction.TransactionID != "aslan-freight-deduction-100" || deduction.SellerUserID != 2069828597070528515 || deduction.USDMinorAmount != -100000 { + t.Fatalf("unexpected deduction fields: %+v", deduction) + } + if deduction.SellerRegionID != 302 || deduction.TargetRegionID != 302 { + t.Fatalf("deduction region mapping failed: %+v", deduction) + } + if deduction.UserPaidCurrencyCode != "USD" || deduction.UserPaidAmountMicro != -1_000_000_000 { + t.Fatalf("deduction paid facts mismatch: %+v", deduction) + } if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 || dashboardSource.billQuery.StatTZ != "Asia/Shanghai" { t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery) }