382 lines
15 KiB
Go
382 lines
15 KiB
Go
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, 100_000)
|
|
assertWalletBalance(t, walletDB, 5001, 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) VALUES
|
|
('lalu', 3001, 'AG3001', 'Agency One', ''),
|
|
('lalu', 3002, 'AG3002', 'Agency Two', ''),
|
|
('lalu', 4001, 'BD4001', 'BD One', ''),
|
|
('lalu', 5001, 'AD5001', 'Admin One', '')`,
|
|
`INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, region_id, status) VALUES
|
|
('lalu', 3001, 4001, 686, 'active'),
|
|
('lalu', 3002, 4001, 686, 'active')`,
|
|
`INSERT INTO bd_profiles (app_code, user_id, role, region_id, parent_leader_user_id, status) VALUES
|
|
('lalu', 4001, 'bd', 686, 5001, 'active'),
|
|
('lalu', 5001, 'bd_leader', 686, 0, '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,
|
|
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,
|
|
region_id BIGINT NOT NULL,
|
|
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,
|
|
region_id BIGINT NOT NULL,
|
|
parent_leader_user_id BIGINT NOT NULL DEFAULT 0,
|
|
status VARCHAR(24) NOT NULL,
|
|
PRIMARY KEY (app_code, user_id, role)
|
|
)`,
|
|
}
|
|
}
|
|
|
|
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, 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 = 'USD_BALANCE'`, userID).Scan(&amount); err != nil {
|
|
t.Fatalf("query wallet balance failed: %v", err)
|
|
}
|
|
if amount != expected {
|
|
t.Fatalf("wallet balance mismatch for user %d: got=%d want=%d", userID, 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, "`", "``") + "`"
|
|
}
|