package teamsalarysettlement import ( "context" "database/sql" "fmt" "os" "strings" "testing" "time" _ "github.com/go-sql-driver/mysql" mysqlDriver "github.com/go-sql-driver/mysql" ) func TestRealMySQLTeamSalaryFullFlow(t *testing.T) { baseDSN := strings.TrimSpace(os.Getenv("TEAM_SALARY_SETTLEMENT_MYSQL_TEST_DSN")) if baseDSN == "" { t.Skip("set TEAM_SALARY_SETTLEMENT_MYSQL_TEST_DSN to run the real MySQL BD/Admin salary flow") } ctx := context.Background() adminDB := newTeamSalaryTestDB(t, baseDSN, "admin") userDB := newTeamSalaryTestDB(t, baseDSN, "user") walletDB := newTeamSalaryTestDB(t, baseDSN, "wallet") execAll(t, adminDB, teamSalaryAdminDDL()...) execAll(t, userDB, teamSalaryUserDDL()...) execAll(t, walletDB, teamSalaryWalletDDL()...) seedTeamSalaryFlow(t, adminDB, userDB, walletDB) service := NewService(adminDB, walletDB, userDB) pendingBD, totalBD, err := service.ListPending(ctx, "lalu", pendingQuery{ PolicyType: policyTypeBD, TriggerMode: triggerAutomatic, CycleKey: "2026-01", }) if err != nil { t.Fatalf("list bd pending failed: %v", err) } if totalBD != 1 || len(pendingBD) != 1 { t.Fatalf("bd pending count mismatch: total=%d len=%d", totalBD, len(pendingBD)) } if pendingBD[0].IncomeUSDMinor != 1_000_000 || pendingBD[0].SalaryUSDMinorDelta != 100_000 { t.Fatalf("bd pending amount mismatch: income=%d salary_delta=%d", pendingBD[0].IncomeUSDMinor, pendingBD[0].SalaryUSDMinorDelta) } firstRun, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 5, 8, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("first automatic settlement failed: %v", err) } if !firstRun.Due || firstRun.CycleKey != "2026-01" { t.Fatalf("automatic due state mismatch: due=%v cycle=%s", firstRun.Due, firstRun.CycleKey) } if firstRun.Results[policyTypeBD].SuccessCount != 1 || firstRun.Results[policyTypeAdmin].SuccessCount != 1 { t.Fatalf("first settlement success mismatch: bd=%+v admin=%+v", firstRun.Results[policyTypeBD], firstRun.Results[policyTypeAdmin]) } secondRun, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 5, 9, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("second automatic settlement failed: %v", err) } if secondRun.Results[policyTypeBD].SuccessCount != 0 || secondRun.Results[policyTypeBD].SkippedCount != 1 { t.Fatalf("bd duplicate guard mismatch: %+v", secondRun.Results[policyTypeBD]) } if secondRun.Results[policyTypeAdmin].SuccessCount != 0 || secondRun.Results[policyTypeAdmin].SkippedCount != 1 { t.Fatalf("admin duplicate guard mismatch: %+v", secondRun.Results[policyTypeAdmin]) } records, totalRecords, err := service.ListRecords(ctx, "lalu", recordQuery{CycleKey: "2026-01"}) if err != nil { t.Fatalf("list team records failed: %v", err) } if totalRecords != 2 || len(records) != 2 { t.Fatalf("record count mismatch: total=%d len=%d records=%+v", totalRecords, len(records), records) } assertWalletBalance(t, walletDB, 4001, assetBDSalaryUSD, 100_000) assertWalletBalance(t, walletDB, 5001, assetAdminSalaryUSD, 20_000) assertScalar(t, walletDB, `SELECT COUNT(*) FROM team_salary_settlement_records`, int64(2)) assertScalar(t, walletDB, `SELECT COUNT(*) FROM wallet_transactions WHERE biz_type = 'team_salary_settlement'`, int64(2)) notDue, err := service.RunAutomaticDue(ctx, "lalu", time.Date(2026, time.February, 4, 8, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("non-due automatic check failed: %v", err) } if notDue.Due { t.Fatalf("non-due day should not run settlement") } } func newTeamSalaryTestDB(t *testing.T, baseDSN string, suffix string) *sql.DB { t.Helper() cfg, err := mysqlDriver.ParseDSN(baseDSN) if err != nil { t.Fatalf("parse test dsn failed: %v", err) } dbName := fmt.Sprintf("hy_team_salary_%s_%d", suffix, time.Now().UnixNano()) adminCfg := cfg.Clone() adminCfg.DBName = "" adminDB, err := sql.Open("mysql", adminCfg.FormatDSN()) if err != nil { t.Fatalf("open mysql admin connection failed: %v", err) } if _, err := adminDB.Exec("CREATE DATABASE " + quoteTeamSalaryIdentifier(dbName) + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { _ = adminDB.Close() t.Fatalf("create test database failed: %v", err) } testCfg := cfg.Clone() testCfg.DBName = dbName db, err := sql.Open("mysql", testCfg.FormatDSN()) if err != nil { _ = adminDB.Close() t.Fatalf("open test database failed: %v", err) } t.Cleanup(func() { _ = db.Close() _, _ = adminDB.Exec("DROP DATABASE IF EXISTS " + quoteTeamSalaryIdentifier(dbName)) _ = adminDB.Close() }) return db } func seedTeamSalaryFlow(t *testing.T, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB) { t.Helper() nowMS := int64(1_769_971_200_000) execAll(t, adminDB, `INSERT INTO admin_team_salary_policies (id, app_code, policy_type, name, region_id, status, settlement_trigger_mode, effective_from_ms, effective_to_ms, description, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms) VALUES (7001, 'lalu', 'bd', 'BD automatic policy', 686, 'active', 'automatic', 0, 0, '', 1, 1, 1, 1), (8001, 'lalu', 'admin', 'Admin automatic policy', 686, 'active', 'automatic', 0, 0, '', 1, 1, 1, 1)`, `INSERT INTO admin_team_salary_policy_levels (policy_id, level_no, threshold_usd, rate_percent, salary_usd, status, sort_order, created_at_ms, updated_at_ms) VALUES (7001, 1, 100.00, 8.0000, 8.00, 'active', 1, 1, 1), (7001, 2, 200.00, 8.0000, 16.00, 'active', 2, 1, 1), (7001, 3, 500.00, 8.0000, 40.00, 'active', 3, 1, 1), (7001, 4, 1200.00, 8.0000, 96.00, 'active', 4, 1, 1), (7001, 5, 2000.00, 8.0000, 160.00, 'active', 5, 1, 1), (7001, 6, 5000.00, 9.0000, 450.00, 'active', 6, 1, 1), (7001, 7, 10000.00, 10.0000, 1000.00, 'active', 7, 1, 1), (8001, 1, 500.00, 20.0000, 100.00, 'active', 1, 1, 1), (8001, 2, 1000.00, 20.0000, 200.00, 'active', 2, 1, 1), (8001, 3, 3000.00, 20.0000, 600.00, 'active', 3, 1, 1), (8001, 4, 5000.00, 20.0000, 1000.00, 'active', 4, 1, 1), (8001, 5, 10000.00, 20.0000, 2000.00, 'active', 5, 1, 1), (8001, 6, 20000.00, 20.0000, 4000.00, 'active', 6, 1, 1)`, ) execAll(t, userDB, `INSERT INTO regions (app_code, region_id, name) VALUES ('lalu', 686, 'Middle East')`, `INSERT INTO countries (app_code, country_id, country_code, name) VALUES ('lalu', 971, 'AE', 'United Arab Emirates')`, `INSERT INTO region_countries (app_code, region_id, country_code, status) VALUES ('lalu', 686, 'AE', 'active')`, `INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar, region_id) VALUES ('lalu', 3001, 'AG3001', 'Agency One', '', 686), ('lalu', 3002, 'AG3002', 'Agency Two', '', 686), ('lalu', 4001, 'BD4001', 'BD One', '', 686), ('lalu', 5001, 'AD5001', 'Admin One', '', 686)`, `INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, status) VALUES ('lalu', 3001, 4001, 'active'), ('lalu', 3002, 4001, 'active')`, `INSERT INTO bd_profiles (app_code, user_id, role, parent_leader_user_id, status) VALUES ('lalu', 4001, 'bd', 5001, 'active')`, `INSERT INTO bd_leader_profiles (app_code, user_id, status) VALUES ('lalu', 5001, 'active')`, ) execAll(t, walletDB, fmt.Sprintf(`INSERT INTO host_salary_settlement_records (app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id, cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta, host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta, status, reason, created_at_ms) VALUES ('lalu', 'hs_1', 'hcmd_1', 'htx_1', 'month_end', 1001, 3001, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), ('lalu', 'hs_2', 'hcmd_2', 'htx_2', 'month_end', 1002, 3001, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), ('lalu', 'hs_3', 'hcmd_3', 'htx_3', 'month_end', 1003, 3001, '2026-01', 1, 1, 1, 100000, 0, 0, 0, 'succeeded', '', %d), ('lalu', 'hs_4', 'hcmd_4', 'htx_4', 'month_end', 1004, 3002, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), ('lalu', 'hs_5', 'hcmd_5', 'htx_5', 'month_end', 1005, 3002, '2026-01', 1, 1, 1, 200000, 0, 0, 0, 'succeeded', '', %d), ('lalu', 'hs_6', 'hcmd_6', 'htx_6', 'month_end', 1006, 3002, '2026-01', 1, 1, 1, 100000, 0, 0, 0, 'succeeded', '', %d)`, nowMS, nowMS, nowMS, nowMS, nowMS, nowMS)) } func teamSalaryAdminDDL() []string { return []string{ `CREATE TABLE admin_team_salary_policies ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', policy_type VARCHAR(24) NOT NULL, name VARCHAR(120) NOT NULL, region_id BIGINT NOT NULL, status VARCHAR(24) NOT NULL DEFAULT 'disabled', settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic', effective_from_ms BIGINT NOT NULL DEFAULT 0, effective_to_ms BIGINT NOT NULL DEFAULT 0, description VARCHAR(255) NOT NULL DEFAULT '', created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, INDEX idx_policy_scope (app_code, policy_type, region_id, status, effective_from_ms, effective_to_ms) )`, `CREATE TABLE admin_team_salary_policy_levels ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, policy_id BIGINT UNSIGNED NOT NULL, level_no INT NOT NULL, threshold_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00, rate_percent DECIMAL(9,4) NOT NULL DEFAULT 0.0000, salary_usd DECIMAL(12,2) NOT NULL DEFAULT 0.00, status VARCHAR(24) NOT NULL DEFAULT 'active', sort_order INT NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, INDEX idx_policy_level (policy_id, threshold_usd) )`, } } func teamSalaryUserDDL() []string { return []string{ `CREATE TABLE users ( app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, current_display_user_id VARCHAR(64) NOT NULL DEFAULT '', username VARCHAR(128) NULL, avatar VARCHAR(512) NULL, region_id BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (app_code, user_id) )`, `CREATE TABLE regions ( app_code VARCHAR(32) NOT NULL, region_id BIGINT NOT NULL, name VARCHAR(128) NOT NULL, PRIMARY KEY (app_code, region_id) )`, `CREATE TABLE countries ( app_code VARCHAR(32) NOT NULL, country_id BIGINT NOT NULL, country_code VARCHAR(16) NOT NULL, name VARCHAR(128) NOT NULL, PRIMARY KEY (app_code, country_id) )`, `CREATE TABLE region_countries ( app_code VARCHAR(32) NOT NULL, region_id BIGINT NOT NULL, country_code VARCHAR(16) NOT NULL, status VARCHAR(24) NOT NULL, PRIMARY KEY (app_code, region_id, country_code) )`, `CREATE TABLE agencies ( app_code VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL, parent_bd_user_id BIGINT NOT NULL DEFAULT 0, status VARCHAR(24) NOT NULL, PRIMARY KEY (app_code, owner_user_id) )`, `CREATE TABLE bd_profiles ( app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, role VARCHAR(24) NOT NULL, parent_leader_user_id BIGINT NOT NULL DEFAULT 0, status VARCHAR(24) NOT NULL, PRIMARY KEY (app_code, user_id, role) )`, `CREATE TABLE bd_leader_profiles ( app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, status VARCHAR(24) NOT NULL, PRIMARY KEY (app_code, user_id) )`, } } func teamSalaryWalletDDL() []string { return []string{ `CREATE TABLE wallet_accounts ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', user_id BIGINT NOT NULL, asset_type VARCHAR(32) NOT NULL, available_amount BIGINT NOT NULL, frozen_amount BIGINT NOT NULL, version BIGINT NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, user_id, asset_type) )`, `CREATE TABLE wallet_transactions ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', transaction_id VARCHAR(96) NOT NULL, command_id VARCHAR(128) NOT NULL, biz_type VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, request_hash VARCHAR(128) NOT NULL, external_ref VARCHAR(128) NOT NULL DEFAULT '', metadata_json JSON NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, transaction_id), UNIQUE KEY uk_wallet_tx_command (app_code, command_id) )`, `CREATE TABLE wallet_entries ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, transaction_id VARCHAR(96) NOT NULL, user_id BIGINT NOT NULL, asset_type VARCHAR(32) NOT NULL, available_delta BIGINT NOT NULL, frozen_delta BIGINT NOT NULL, available_after BIGINT NOT NULL, frozen_after BIGINT NOT NULL, counterparty_user_id BIGINT NOT NULL DEFAULT 0, room_id VARCHAR(96) NOT NULL DEFAULT '', created_at_ms BIGINT NOT NULL )`, `CREATE TABLE wallet_outbox ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', event_id VARCHAR(128) NOT NULL, event_type VARCHAR(64) NOT NULL, transaction_id VARCHAR(96) NOT NULL, command_id VARCHAR(128) NOT NULL, user_id BIGINT NOT NULL, asset_type VARCHAR(32) NOT NULL, available_delta BIGINT NOT NULL, frozen_delta BIGINT NOT NULL, payload JSON NOT NULL, status VARCHAR(32) NOT NULL, worker_id VARCHAR(128) NOT NULL DEFAULT '', lock_until_ms BIGINT NULL, retry_count INT NOT NULL DEFAULT 0, next_retry_at_ms BIGINT NULL, last_error TEXT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, event_id) )`, `CREATE TABLE host_salary_settlement_records ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', settlement_id VARCHAR(128) NOT NULL, command_id VARCHAR(128) NOT NULL, transaction_id VARCHAR(96) NOT NULL, settlement_type VARCHAR(24) NOT NULL, user_id BIGINT NOT NULL, agency_owner_user_id BIGINT NOT NULL DEFAULT 0, cycle_key VARCHAR(16) NOT NULL, policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0, level_no INT NOT NULL DEFAULT 0, total_diamonds BIGINT NOT NULL DEFAULT 0, host_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0, host_coin_reward_delta BIGINT NOT NULL DEFAULT 0, agency_salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0, residual_usd_minor_delta BIGINT NOT NULL DEFAULT 0, status VARCHAR(24) NOT NULL DEFAULT 'succeeded', reason VARCHAR(255) NOT NULL DEFAULT '', created_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, settlement_id), UNIQUE KEY uk_host_command (app_code, command_id) )`, } } func execAll(t *testing.T, db *sql.DB, statements ...string) { t.Helper() for _, statement := range statements { if _, err := db.Exec(statement); err != nil { t.Fatalf("exec statement failed: %v\n%s", err, statement) } } } func assertWalletBalance(t *testing.T, db *sql.DB, userID int64, assetType string, expected int64) { t.Helper() var amount int64 if err := db.QueryRow(`SELECT available_amount FROM wallet_accounts WHERE app_code = 'lalu' AND user_id = ? AND asset_type = ?`, userID, assetType).Scan(&amount); err != nil { t.Fatalf("query wallet balance failed: %v", err) } if amount != expected { t.Fatalf("wallet balance mismatch for user %d asset %s: got=%d want=%d", userID, assetType, amount, expected) } } func assertScalar(t *testing.T, db *sql.DB, query string, expected int64) { t.Helper() var got int64 if err := db.QueryRow(query).Scan(&got); err != nil { t.Fatalf("query scalar failed: %v", err) } if got != expected { t.Fatalf("scalar mismatch for %s: got=%d want=%d", query, got, expected) } } func quoteTeamSalaryIdentifier(value string) string { return "`" + strings.ReplaceAll(value, "`", "``") + "`" }