diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 0bac296..4f13022 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -8,6 +8,8 @@ import ( "github.com/go-telegram/bot/models" "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/modules/stock" + "github.com/tiennm99/miti99bot/internal/storage" "github.com/tiennm99/miti99bot/internal/testutil" ) @@ -46,6 +48,28 @@ func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) { } } +func TestBotCommandMenu_StockDividendContracts(t *testing.T) { + mod := stock.New(modules.Deps{Store: storage.NewMemoryProvider().Collection("stock")}) + mod.Name = "stock" + got := botCommandMenu(&modules.Registry{Modules: []modules.Module{mod}}) + + commands := make(map[string]string, len(got)) + for _, command := range got { + commands[command.Command] = command.Description + if len(command.Description) > 256 { + t.Fatalf("description for %s exceeds Telegram limit", command.Command) + } + } + for _, name := range []string{"stock_cash_dividend", "stock_share_dividend", "stock_dividend"} { + if commands[name] == "" { + t.Fatalf("stock menu missing %s: %v", name, commands) + } + } + if _, exists := commands["stock_bonus"]; exists { + t.Fatalf("stock_bonus remains in public menu: %v", commands) + } +} + func TestRegisterCommandMenu_CallsTelegramSetMyCommands(t *testing.T) { reg := &modules.Registry{ Modules: []modules.Module{{ diff --git a/cmd/server/main.go b/cmd/server/main.go index cbe1f3b..5c7cf23 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -28,6 +28,7 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/wordle" "github.com/tiennm99/miti99bot/internal/server" "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/systemstate" "github.com/tiennm99/miti99bot/internal/telegram" ) @@ -92,7 +93,7 @@ func main() { } defer closeProvider() - if err := stats.InitStore(rootCtx, provider.Collection("stats")); err != nil { + if err := stats.InitStore(rootCtx, provider.Collection("stats"), provider.Collection(systemstate.CollectionName)); err != nil { log.Fatal("stats storage init failed", "err", err) } if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil { diff --git a/internal/modules/stats/startup.go b/internal/modules/stats/startup.go index 45fc2bd..a6ab657 100644 --- a/internal/modules/stats/startup.go +++ b/internal/modules/stats/startup.go @@ -2,32 +2,253 @@ package stats import ( "context" + "errors" "fmt" + "math" + "strconv" + "strings" + "time" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/systemstate" ) const ( statsCommandUsersIndexName = "stats_cmd_n_user" statsUserCommandsIndexName = "stats_uid_n_cmd" statsUsernameLookupIndexName = "stats_user_uid" + + dividendStatsMigrationKey = "migration:stock-dividend-command-stats-v1" + dividendStatsRowMarkerPrefix = dividendStatsMigrationKey + ":row:" + migrationStatusPrepared = "prepared" + migrationStatusCompleted = "completed" ) +type commandRename struct { + from string + to string +} + +var dividendCommandRenames = []commandRename{ + {from: "stock_dividend", to: "stock_cash_dividend"}, + {from: "stock_bonus", to: "stock_share_dividend"}, +} + // InitStore performs stats collection startup maintenance. It is safe to call -// every boot: MongoDB indexes are created idempotently. -func InitStore(ctx context.Context, statsColl storage.Collection) error { +// every boot: MongoDB indexes and the guarded stats migration are idempotent. +func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) error { if mongoColl, ok := storage.MongoCollection(statsColl); ok { if err := ensureUsageIndexes(ctx, mongoColl); err != nil { return err } } + if err := migrateDividendCommandStats( + ctx, + storage.Typed[usageEntry](statsColl), + storage.Typed[systemstate.Record](systemColl), + ); err != nil { + return fmt.Errorf("stats dividend command migration: %w", err) + } return nil } +func migrateDividendCommandStats( + ctx context.Context, + statsDocs storage.DocStore[usageEntry], + systemDocs storage.DocStore[systemstate.Record], +) error { + global, exists, err := getSystemRecord(ctx, systemDocs, dividendStatsMigrationKey) + if err != nil { + return fmt.Errorf("read global marker: %w", err) + } + if exists && global.Status == migrationStatusCompleted { + return nil + } + + for _, rename := range dividendCommandRenames { + keys, err := statsDocs.List(ctx, rename.from) + if err != nil { + return fmt.Errorf("list %s rows: %w", rename.from, err) + } + for _, key := range keys { + userID, ok := usageUserIDForCommandKey(key, rename.from) + if !ok { + continue + } + if err := migrateDividendStatsRow(ctx, statsDocs, systemDocs, rename, userID); err != nil { + return err + } + } + } + + markerKeys, err := systemDocs.List(ctx, dividendStatsRowMarkerPrefix) + if err != nil { + return fmt.Errorf("list row checkpoints: %w", err) + } + for _, markerKey := range markerKeys { + rename, userID, ok := parseDividendStatsRowMarkerKey(markerKey) + if !ok { + continue + } + marker, exists, err := getSystemRecord(ctx, systemDocs, markerKey) + if err != nil { + return fmt.Errorf("read row checkpoint %s: %w", markerKey, err) + } + if !exists || marker.Status != migrationStatusPrepared { + continue + } + if err := migrateDividendStatsRow(ctx, statsDocs, systemDocs, rename, userID); err != nil { + return err + } + } + + now := time.Now().UnixMilli() + return systemDocs.Put(ctx, dividendStatsMigrationKey, systemstate.Record{ + Kind: "migration", + Name: "stock dividend command stats v1", + Status: migrationStatusCompleted, + CompletedAt: now, + UpdatedAt: now, + }) +} + +func migrateDividendStatsRow( + ctx context.Context, + statsDocs storage.DocStore[usageEntry], + systemDocs storage.DocStore[systemstate.Record], + rename commandRename, + userID int64, +) error { + markerKey := dividendStatsRowMarkerKey(rename.from, userID) + marker, markerExists, err := getSystemRecord(ctx, systemDocs, markerKey) + if err != nil { + return fmt.Errorf("read %s checkpoint for user %d: %w", rename.from, userID, err) + } + if markerExists && marker.Status == migrationStatusCompleted { + return nil + } + + sourceKey := usageKey(rename.from, userID) + targetKey := usageKey(rename.to, userID) + source, sourceExists, err := getUsageEntry(ctx, statsDocs, sourceKey) + if err != nil { + return fmt.Errorf("read source %s: %w", sourceKey, err) + } + target, targetExists, err := getUsageEntry(ctx, statsDocs, targetKey) + if err != nil { + return fmt.Errorf("read target %s: %w", targetKey, err) + } + + if !markerExists { + if !sourceExists { + return nil + } + if source.N < 0 || target.N < 0 || source.N > math.MaxInt64-target.N { + return fmt.Errorf("count overflow for %s user %d", rename.from, userID) + } + now := time.Now().UnixMilli() + marker = systemstate.Record{ + Kind: "migration-row", + Name: rename.from + " -> " + rename.to, + Status: migrationStatusPrepared, + Count: target.N + source.N, + UpdatedAt: now, + } + if err := systemDocs.Put(ctx, markerKey, marker); err != nil { + return fmt.Errorf("prepare %s user %d: %w", rename.from, userID, err) + } + } + + if !targetExists { + target = usageEntry{UserID: userID} + } + target.Cmd = rename.to + target.UserID = userID + target.N = marker.Count + target.Deleted = false + if sourceExists && source.Username != "" { + target.Username = source.Username + } + if userID == 0 { + target.Username = "" + } + if err := statsDocs.Put(ctx, targetKey, target); err != nil { + return fmt.Errorf("write target %s: %w", targetKey, err) + } + if sourceExists { + if err := statsDocs.Delete(ctx, sourceKey); err != nil { + return fmt.Errorf("delete source %s: %w", sourceKey, err) + } + } + + now := time.Now().UnixMilli() + marker.Status = migrationStatusCompleted + marker.CompletedAt = now + marker.UpdatedAt = now + if err := systemDocs.Put(ctx, markerKey, marker); err != nil { + return fmt.Errorf("complete %s user %d: %w", rename.from, userID, err) + } + return nil +} + +func getUsageEntry(ctx context.Context, docs storage.DocStore[usageEntry], key string) (usageEntry, bool, error) { + entry, _, err := docs.Get(ctx, key) + if errors.Is(err, storage.ErrNotFound) { + return usageEntry{}, false, nil + } + return entry, err == nil, err +} + +func getSystemRecord(ctx context.Context, docs storage.DocStore[systemstate.Record], key string) (systemstate.Record, bool, error) { + record, _, err := docs.Get(ctx, key) + if errors.Is(err, storage.ErrNotFound) { + return systemstate.Record{}, false, nil + } + return record, err == nil, err +} + +func usageUserIDForCommandKey(key, command string) (int64, bool) { + if key == command { + return 0, true + } + prefix := command + ":" + if !strings.HasPrefix(key, prefix) { + return 0, false + } + userID, err := strconv.ParseInt(strings.TrimPrefix(key, prefix), 10, 64) + return userID, err == nil && userID != 0 +} + +func dividendStatsRowMarkerKey(sourceCommand string, userID int64) string { + return dividendStatsRowMarkerPrefix + sourceCommand + ":" + strconv.FormatInt(userID, 10) +} + +func parseDividendStatsRowMarkerKey(key string) (commandRename, int64, bool) { + remainder := strings.TrimPrefix(key, dividendStatsRowMarkerPrefix) + if remainder == key { + return commandRename{}, 0, false + } + separator := strings.LastIndexByte(remainder, ':') + if separator < 1 { + return commandRename{}, 0, false + } + sourceCommand := remainder[:separator] + userID, err := strconv.ParseInt(remainder[separator+1:], 10, 64) + if err != nil { + return commandRename{}, 0, false + } + for _, rename := range dividendCommandRenames { + if rename.from == sourceCommand { + return rename, userID, true + } + } + return commandRename{}, 0, false +} + func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error { models := []mongo.IndexModel{ { diff --git a/internal/modules/stats/startup_mongo_test.go b/internal/modules/stats/startup_mongo_test.go index b46189a..a13e748 100644 --- a/internal/modules/stats/startup_mongo_test.go +++ b/internal/modules/stats/startup_mongo_test.go @@ -11,17 +11,17 @@ import ( ) func TestInitStore_MongoCreatesIndexes(t *testing.T) { - ctx, statsColl := setupMongoStatsTest(t) + ctx, statsColl, systemColl := setupMongoStatsTest(t) rawStatsColl, ok := storage.MongoCollection(statsColl) if !ok { t.Fatal("stats collection is not Mongo-backed") } - if err := InitStore(ctx, statsColl); err != nil { + if err := InitStore(ctx, statsColl, systemColl); err != nil { t.Fatalf("InitStore: %v", err) } - if err := InitStore(ctx, statsColl); err != nil { + if err := InitStore(ctx, statsColl, systemColl); err != nil { t.Fatalf("InitStore second run: %v", err) } @@ -51,7 +51,25 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) { } } -func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection) { +func TestInitStore_MongoMigratesDividendStatsIdempotently(t *testing.T) { + ctx, statsColl, systemColl := setupMongoStatsTest(t) + docs := storage.Typed[usageEntry](statsColl) + seedUsageEntries(t, docs, map[string]usageEntry{ + usageKey("stock_bonus", 0): {Cmd: "stock_bonus", N: 4}, + usageKey("stock_bonus", 7): {Cmd: "stock_bonus", UserID: 7, Username: "alice", N: 5}, + usageKey("stock_share_dividend", 7): {Cmd: "stock_share_dividend", UserID: 7, Username: "alice", N: 2}, + }) + if err := InitStore(ctx, statsColl, systemColl); err != nil { + t.Fatalf("InitStore: %v", err) + } + if err := InitStore(ctx, statsColl, systemColl); err != nil { + t.Fatalf("InitStore second run: %v", err) + } + assertUsageEntry(t, docs, usageKey("stock_share_dividend", 0), 4, "") + assertUsageEntry(t, docs, usageKey("stock_share_dividend", 7), 7, "alice") +} + +func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection, storage.Collection) { t.Helper() uri := os.Getenv("MONGODB_TEST_URL") @@ -76,5 +94,5 @@ func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection) { }) provider := storage.NewMongoProvider(db) - return ctx, provider.Collection("stats") + return ctx, provider.Collection("stats"), provider.Collection("system") } diff --git a/internal/modules/stats/startup_test.go b/internal/modules/stats/startup_test.go new file mode 100644 index 0000000..dd1110f --- /dev/null +++ b/internal/modules/stats/startup_test.go @@ -0,0 +1,186 @@ +package stats + +import ( + "context" + "errors" + "testing" + + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/systemstate" +) + +func TestInitStoreMigratesDividendCommandStats(t *testing.T) { + ctx := context.Background() + provider := storage.NewMemoryProvider() + statsColl := provider.Collection("stats") + systemColl := provider.Collection(systemstate.CollectionName) + statsDocs := storage.Typed[usageEntry](statsColl) + systemDocs := storage.Typed[systemstate.Record](systemColl) + + seedUsageEntries(t, statsDocs, map[string]usageEntry{ + usageKey("stock_dividend", 0): {Cmd: "stock_dividend", N: 10}, + usageKey("stock_cash_dividend", 0): {Cmd: "stock_cash_dividend", N: 3}, + usageKey("stock_dividend", 7): {Cmd: "stock_dividend", UserID: 7, Username: "alice", N: 4}, + usageKey("stock_cash_dividend", 7): {Cmd: "stock_cash_dividend", UserID: 7, Username: "old-alice", N: 2}, + usageKey("stock_bonus", 0): {Cmd: "stock_bonus", N: 8}, + usageKey("stock_bonus", 9): {Cmd: "stock_bonus", UserID: 9, Username: "bob", N: 5}, + usageKey("stock_share_dividend", 9): {Cmd: "stock_share_dividend", UserID: 9, Username: "previous", N: 1}, + }) + + if err := InitStore(ctx, statsColl, systemColl); err != nil { + t.Fatalf("InitStore: %v", err) + } + assertUsageEntry(t, statsDocs, usageKey("stock_cash_dividend", 0), 13, "") + assertUsageEntry(t, statsDocs, usageKey("stock_cash_dividend", 7), 6, "alice") + assertUsageEntry(t, statsDocs, usageKey("stock_share_dividend", 0), 8, "") + assertUsageEntry(t, statsDocs, usageKey("stock_share_dividend", 9), 6, "bob") + for _, key := range []string{ + usageKey("stock_dividend", 0), usageKey("stock_dividend", 7), + usageKey("stock_bonus", 0), usageKey("stock_bonus", 9), + } { + if _, _, err := statsDocs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) { + t.Fatalf("source %s still exists or get failed: %v", key, err) + } + } + global, _, err := systemDocs.Get(ctx, dividendStatsMigrationKey) + if err != nil || global.Status != migrationStatusCompleted { + t.Fatalf("global marker = %+v, %v", global, err) + } + markerKeys, err := systemDocs.List(ctx, dividendStatsRowMarkerPrefix) + if err != nil || len(markerKeys) != 4 { + t.Fatalf("row markers = %v, %v", markerKeys, err) + } + + if err := InitStore(ctx, statsColl, systemColl); err != nil { + t.Fatalf("InitStore second run: %v", err) + } + assertUsageEntry(t, statsDocs, usageKey("stock_cash_dividend", 0), 13, "") + assertUsageEntry(t, statsDocs, usageKey("stock_share_dividend", 9), 6, "bob") +} + +func TestDividendStatsMigrationResumesPreparedCheckpointWithoutSource(t *testing.T) { + ctx := context.Background() + provider := storage.NewMemoryProvider() + statsDocs := storage.Typed[usageEntry](provider.Collection("stats")) + systemDocs := storage.Typed[systemstate.Record](provider.Collection(systemstate.CollectionName)) + markerKey := dividendStatsRowMarkerKey("stock_dividend", 7) + + if err := statsDocs.Put(ctx, usageKey("stock_cash_dividend", 7), usageEntry{ + Cmd: "stock_cash_dividend", UserID: 7, Username: "alice", N: 12, + }); err != nil { + t.Fatalf("seed target: %v", err) + } + if err := systemDocs.Put(ctx, markerKey, systemstate.Record{ + Kind: "migration-row", Name: "stock_dividend -> stock_cash_dividend", Status: migrationStatusPrepared, Count: 12, + }); err != nil { + t.Fatalf("seed marker: %v", err) + } + + if err := migrateDividendCommandStats(ctx, statsDocs, systemDocs); err != nil { + t.Fatalf("migrateDividendCommandStats: %v", err) + } + assertUsageEntry(t, statsDocs, usageKey("stock_cash_dividend", 7), 12, "alice") + marker, _, err := systemDocs.Get(ctx, markerKey) + if err != nil || marker.Status != migrationStatusCompleted { + t.Fatalf("row marker = %+v, %v", marker, err) + } +} + +type faultDocStore[T any] struct { + storage.DocStore[T] + fail func(op, id string, value T) error +} + +func (s *faultDocStore[T]) Put(ctx context.Context, id string, value T) error { + if s.fail != nil { + if err := s.fail("put", id, value); err != nil { + return err + } + } + return s.DocStore.Put(ctx, id, value) +} + +func (s *faultDocStore[T]) Delete(ctx context.Context, id string) error { + var zero T + if s.fail != nil { + if err := s.fail("delete", id, zero); err != nil { + return err + } + } + return s.DocStore.Delete(ctx, id) +} + +func TestDividendStatsMigrationRetriesEveryWriteBoundaryWithoutDuplication(t *testing.T) { + for _, boundary := range []string{"prepare", "target", "delete", "complete"} { + t.Run(boundary, func(t *testing.T) { + ctx := context.Background() + provider := storage.NewMemoryProvider() + baseStats := storage.Typed[usageEntry](provider.Collection("stats")) + baseSystem := storage.Typed[systemstate.Record](provider.Collection(systemstate.CollectionName)) + seedUsageEntries(t, baseStats, map[string]usageEntry{ + usageKey("stock_dividend", 7): {Cmd: "stock_dividend", UserID: 7, Username: "alice", N: 5}, + usageKey("stock_cash_dividend", 7): {Cmd: "stock_cash_dividend", UserID: 7, Username: "alice", N: 7}, + }) + + failed := false + statsDocs := &faultDocStore[usageEntry]{DocStore: baseStats} + systemDocs := &faultDocStore[systemstate.Record]{DocStore: baseSystem} + forced := errors.New("forced boundary failure") + statsDocs.fail = func(op, id string, _ usageEntry) error { + if failed { + return nil + } + if boundary == "target" && op == "put" && id == usageKey("stock_cash_dividend", 7) || + boundary == "delete" && op == "delete" && id == usageKey("stock_dividend", 7) { + failed = true + return forced + } + return nil + } + systemDocs.fail = func(op, id string, record systemstate.Record) error { + if failed || op != "put" || id != dividendStatsRowMarkerKey("stock_dividend", 7) { + return nil + } + if boundary == "prepare" && record.Status == migrationStatusPrepared || + boundary == "complete" && record.Status == migrationStatusCompleted { + failed = true + return forced + } + return nil + } + + if err := migrateDividendCommandStats(ctx, statsDocs, systemDocs); err == nil { + t.Fatal("migration unexpectedly succeeded before injected failure") + } + statsDocs.fail = nil + systemDocs.fail = nil + if err := migrateDividendCommandStats(ctx, statsDocs, systemDocs); err != nil { + t.Fatalf("retry migration: %v", err) + } + assertUsageEntry(t, baseStats, usageKey("stock_cash_dividend", 7), 12, "alice") + if _, _, err := baseStats.Get(ctx, usageKey("stock_dividend", 7)); !errors.Is(err, storage.ErrNotFound) { + t.Fatalf("source remains after retry: %v", err) + } + }) + } +} + +func seedUsageEntries(t *testing.T, docs storage.DocStore[usageEntry], entries map[string]usageEntry) { + t.Helper() + for key, entry := range entries { + if err := docs.Put(context.Background(), key, entry); err != nil { + t.Fatalf("seed %s: %v", key, err) + } + } +} + +func assertUsageEntry(t *testing.T, docs storage.DocStore[usageEntry], key string, wantN int64, wantUsername string) { + t.Helper() + entry, _, err := docs.Get(context.Background(), key) + if err != nil { + t.Fatalf("get %s: %v", key, err) + } + if entry.N != wantN || entry.Username != wantUsername || entry.Deleted { + t.Fatalf("entry %s = %+v, want n=%d username=%q deleted=false", key, entry, wantN, wantUsername) + } +} diff --git a/internal/modules/stock/dividends.go b/internal/modules/stock/dividends.go new file mode 100644 index 0000000..01b2f46 --- /dev/null +++ b/internal/modules/stock/dividends.go @@ -0,0 +1,103 @@ +package stock + +import ( + "errors" + "math" + "math/big" + "strconv" + "strings" +) + +var errDividendOverflow = errors.New("dividend calculation overflows") + +const maxExactFloatInteger = int64(1 << 53) + +type shareRatio struct { + owned int64 + new int64 + raw string +} + +func parsePositiveWhole(raw string) (int64, bool) { + if raw == "" { + return 0, false + } + for _, r := range raw { + if r < '0' || r > '9' { + return 0, false + } + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n <= 0 { + return 0, false + } + return n, true +} + +func parseShareRatio(raw string) (shareRatio, bool) { + parts := strings.Split(raw, ":") + if len(parts) != 2 { + return shareRatio{}, false + } + owned, ok := parsePositiveWhole(parts[0]) + if !ok { + return shareRatio{}, false + } + newShares, ok := parsePositiveWhole(parts[1]) + if !ok { + return shareRatio{}, false + } + return shareRatio{owned: owned, new: newShares, raw: raw}, true +} + +func shareDividendEntitlement(held int64, ratio shareRatio) (int64, error) { + if held <= 0 || ratio.owned <= 0 || ratio.new <= 0 { + return 0, errors.New("invalid dividend inputs") + } + product := new(big.Int).Mul(big.NewInt(held), big.NewInt(ratio.new)) + result := product.Quo(product, big.NewInt(ratio.owned)) + if !result.IsInt64() { + return 0, errDividendOverflow + } + return result.Int64(), nil +} + +func minimumHoldingForShare(ratio shareRatio) int64 { + return (ratio.owned-1)/ratio.new + 1 +} + +func checkedHoldingAfterDividend(held, newShares int64) (int64, error) { + if newShares < 0 || held > math.MaxInt64-newShares { + return 0, errDividendOverflow + } + return held + newShares, nil +} + +func cashDividendTotal(held, vndPerShare int64) (int64, error) { + if held <= 0 || vndPerShare <= 0 || held > math.MaxInt64/vndPerShare { + return 0, errDividendOverflow + } + total := held * vndPerShare + if total > maxExactFloatInteger { + return 0, errDividendOverflow + } + return total, nil +} + +func checkedVNDBalance(balance float64, credit int64) (float64, error) { + if credit <= 0 || math.IsNaN(balance) || math.IsInf(balance, 0) { + return 0, errDividendOverflow + } + result := balance + float64(credit) + if math.IsNaN(result) || math.IsInf(result, 0) || result > float64(maxExactFloatInteger) { + return 0, errDividendOverflow + } + + exactResult := new(big.Rat).SetFloat64(balance) + exactResult.Add(exactResult, new(big.Rat).SetInt64(credit)) + representedResult := new(big.Rat).SetFloat64(result) + if exactResult.Cmp(representedResult) != 0 { + return 0, errDividendOverflow + } + return result, nil +} diff --git a/internal/modules/stock/dividends_test.go b/internal/modules/stock/dividends_test.go new file mode 100644 index 0000000..05b8ce5 --- /dev/null +++ b/internal/modules/stock/dividends_test.go @@ -0,0 +1,104 @@ +package stock + +import ( + "math" + "testing" +) + +func TestParsePositiveWhole(t *testing.T) { + for _, tc := range []struct { + raw string + want int64 + ok bool + }{ + {"1500", 1500, true}, + {"0010", 10, true}, + {"", 0, false}, + {"0", 0, false}, + {"+1", 0, false}, + {"-1", 0, false}, + {"1.5", 0, false}, + {"NaN", 0, false}, + {"9223372036854775808", 0, false}, + } { + t.Run(tc.raw, func(t *testing.T) { + got, ok := parsePositiveWhole(tc.raw) + if got != tc.want || ok != tc.ok { + t.Fatalf("parsePositiveWhole(%q) = (%d, %v), want (%d, %v)", tc.raw, got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestParseShareRatio(t *testing.T) { + for _, raw := range []string{"4:1", "100:10", "004:02"} { + ratio, ok := parseShareRatio(raw) + if !ok || ratio.raw != raw { + t.Fatalf("parseShareRatio(%q) = %+v, %v", raw, ratio, ok) + } + } + for _, raw := range []string{"", "4", "4:1:1", "0:1", "1:0", "+4:1", "4:-1", "4.0:1", "a:b", "9223372036854775808:1"} { + if _, ok := parseShareRatio(raw); ok { + t.Fatalf("parseShareRatio(%q) unexpectedly succeeded", raw) + } + } +} + +func TestShareDividendEntitlement(t *testing.T) { + for _, tc := range []struct { + held int64 + ratio shareRatio + want int64 + }{ + {139, shareRatio{owned: 100, new: 10}, 13}, + {2026, shareRatio{owned: 4, new: 1}, 506}, + {8, shareRatio{owned: 4, new: 1}, 2}, + {8, shareRatio{owned: 100, new: 25}, 2}, + {math.MaxInt64, shareRatio{owned: math.MaxInt64, new: math.MaxInt64}, math.MaxInt64}, + } { + got, err := shareDividendEntitlement(tc.held, tc.ratio) + if err != nil || got != tc.want { + t.Fatalf("shareDividendEntitlement(%d, %+v) = %d, %v; want %d", tc.held, tc.ratio, got, err, tc.want) + } + } + if _, err := shareDividendEntitlement(math.MaxInt64, shareRatio{owned: 1, new: 2}); err == nil { + t.Fatal("overflowing entitlement unexpectedly succeeded") + } +} + +func TestMinimumHoldingForShare(t *testing.T) { + for _, tc := range []struct { + ratio shareRatio + want int64 + }{ + {shareRatio{owned: 100, new: 10}, 10}, + {shareRatio{owned: 4, new: 1}, 4}, + {shareRatio{owned: 3, new: 2}, 2}, + {shareRatio{owned: 1, new: math.MaxInt64}, 1}, + } { + if got := minimumHoldingForShare(tc.ratio); got != tc.want { + t.Fatalf("minimumHoldingForShare(%+v) = %d, want %d", tc.ratio, got, tc.want) + } + } +} + +func TestCheckedDividendTotals(t *testing.T) { + if got, err := cashDividendTotal(139, 1500); err != nil || got != 208500 { + t.Fatalf("cashDividendTotal = %d, %v", got, err) + } + if _, err := cashDividendTotal(math.MaxInt64, 2); err == nil { + t.Fatal("overflowing cash total unexpectedly succeeded") + } + if _, err := checkedHoldingAfterDividend(math.MaxInt64, 1); err == nil { + t.Fatal("overflowing holding unexpectedly succeeded") + } +} + +func TestCheckedVNDBalanceRequiresExactSum(t *testing.T) { + if got, err := checkedVNDBalance(1, maxExactFloatInteger-1); err != nil || got != float64(maxExactFloatInteger) { + t.Fatalf("exact boundary sum = %v, %v", got, err) + } + if _, err := checkedVNDBalance(1, maxExactFloatInteger); err == nil { + t.Fatal("inexact boundary sum unexpectedly succeeded") + } +} diff --git a/internal/modules/stock/format.go b/internal/modules/stock/format.go index 5e83d59..c9b692f 100644 --- a/internal/modules/stock/format.go +++ b/internal/modules/stock/format.go @@ -33,6 +33,29 @@ func FormatStock(n float64) string { return strconv.FormatInt(int64(math.Floor(n)), 10) } +// formatShareQuantity renders an exact whole-share quantity with Vietnamese +// dot-thousands separators, without converting the int64 value through float64. +func formatShareQuantity(n int64) string { + raw := strconv.FormatInt(n, 10) + digitStart := 0 + if raw[0] == '-' { + digitStart = 1 + } + + var sb strings.Builder + sb.Grow(len(raw) + (len(raw)-digitStart-1)/3) + if digitStart == 1 { + sb.WriteByte('-') + } + for i := digitStart; i < len(raw); i++ { + if i > digitStart && (len(raw)-i)%3 == 0 { + sb.WriteByte('.') + } + sb.WriteByte(raw[i]) + } + return sb.String() +} + // FormatPnL renders a signed VND delta + percentage line, e.g. // "+1.234 VND (+12.34%)" or "-500.000 VND (-5.00%)". When invested is zero // the percentage is reported as 0.00 to avoid division-by-zero. diff --git a/internal/modules/stock/format_test.go b/internal/modules/stock/format_test.go index dc6b0dc..5ccabba 100644 --- a/internal/modules/stock/format_test.go +++ b/internal/modules/stock/format_test.go @@ -43,6 +43,23 @@ func TestFormatStock(t *testing.T) { } } +func TestFormatShareQuantity(t *testing.T) { + cases := []struct { + in int64 + want string + }{ + {0, "0"}, + {999, "999"}, + {1000, "1.000"}, + {9_007_199_254_740_993, "9.007.199.254.740.993"}, + } + for _, c := range cases { + if got := formatShareQuantity(c.in); got != c.want { + t.Errorf("formatShareQuantity(%d): got %q, want %q", c.in, got, c.want) + } + } +} + func TestFormatPnL(t *testing.T) { cases := []struct { current, invested float64 diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go index 058346a..15b8c91 100644 --- a/internal/modules/stock/handlers.go +++ b/internal/modules/stock/handlers.go @@ -249,7 +249,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat "\nRemaining: "+FormatVND(p.Currency["VND"])) } -func (s *state) handleBonus(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (s *state) handleCashDividend(ctx context.Context, b *bot.Bot, update *models.Update) error { userID, ok := senderInfo(update) if !ok { return chathelper.Reply(ctx, b, update.Message, @@ -258,11 +258,11 @@ func (s *state) handleBonus(ctx context.Context, b *bot.Bot, update *models.Upda args := argsAfterCommand(update.Message.Text) if len(args) != 2 { return chathelper.Reply(ctx, b, update.Message, - "Usage: /stock_bonus \nExample: /stock_bonus 200 TCB") + "Usage: /stock_cash_dividend \nExample: /stock_cash_dividend 1500 TCB") } - qty, err := strconv.ParseInt(args[0], 10, 64) - if err != nil || qty <= 0 { - return chathelper.Reply(ctx, b, update.Message, "Quantity must be a positive whole number.") + vndPerShare, ok := parsePositiveWhole(args[0]) + if !ok { + return chathelper.Reply(ctx, b, update.Message, "VND per share must be a positive whole number.") } symbol, err := normalizeStockSymbol(args[1]) @@ -282,18 +282,87 @@ func (s *state) handleBonus(ctx context.Context, b *bot.Bot, update *models.Upda return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.") } held := p.Assets[symbol] - if held == 0 { + if held <= 0 { return chathelper.Reply(ctx, b, update.Message, - "You don't hold any "+symbol+" to receive bonus shares.") + "You don't hold any "+symbol+" to receive a cash dividend.") } - p.AddAsset(symbol, qty) + total, err := cashDividendTotal(held, vndPerShare) + if err != nil { + return chathelper.Reply(ctx, b, update.Message, "Dividend amount is too large.") + } + balance, err := checkedVNDBalance(p.Currency["VND"], total) + if err != nil { + return chathelper.Reply(ctx, b, update.Message, "Dividend amount is too large.") + } + p.Currency["VND"] = balance if err := SavePortfolio(ctx, s.store, userID, p); err != nil { log.Error("stock_save_portfolio", "user", userID, "err", err) return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.") } return chathelper.Reply(ctx, b, update.Message, - "Bonus shares: +"+FormatStock(float64(qty))+" "+symbol+ - "\nHolding: "+FormatStock(float64(held))+" → "+FormatStock(float64(p.Assets[symbol]))) + "Cash dividend: "+FormatVND(float64(vndPerShare))+" × "+formatShareQuantity(held)+" "+symbol+ + " = "+FormatVND(float64(total))+ + "\nBalance: "+FormatVND(balance)) +} + +func (s *state) handleShareDividend(ctx context.Context, b *bot.Bot, update *models.Update) error { + userID, ok := senderInfo(update) + if !ok { + return chathelper.Reply(ctx, b, update.Message, + "Cannot identify user — stock only works in private/group chats with a sender.") + } + args := argsAfterCommand(update.Message.Text) + if len(args) != 2 { + return chathelper.Reply(ctx, b, update.Message, + "Usage: /stock_share_dividend \nExample: /stock_share_dividend 100:10 TCB") + } + ratio, ok := parseShareRatio(args[0]) + if !ok { + return chathelper.Reply(ctx, b, update.Message, "Share ratio must use positive whole numbers in owned:new form.") + } + + symbol, err := normalizeStockSymbol(args[1]) + if err != nil { + if errors.Is(err, ErrUnknownTicker) { + return chathelper.Reply(ctx, b, update.Message, + "Unknown stock ticker \""+strings.ToUpper(args[1])+"\".") + } + return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.") + } + + defer s.locks.Acquire(strconv.FormatInt(userID, 10))() + + p, err := LoadPortfolio(ctx, s.store, userID, s.now().UnixMilli()) + if err != nil { + log.Error("stock_load_portfolio", "user", userID, "err", err) + return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.") + } + held := p.Assets[symbol] + if held <= 0 { + return chathelper.Reply(ctx, b, update.Message, + "You don't hold any "+symbol+" to receive a share dividend.") + } + newShares, err := shareDividendEntitlement(held, ratio) + if err != nil { + return chathelper.Reply(ctx, b, update.Message, "Share dividend is too large.") + } + if newShares == 0 { + minimum := minimumHoldingForShare(ratio) + return chathelper.Reply(ctx, b, update.Message, + "Share dividend "+ratio.raw+" rounds down to 0 for "+formatShareQuantity(held)+" "+symbol+". Minimum holding: "+formatShareQuantity(minimum)+".") + } + finalHolding, err := checkedHoldingAfterDividend(held, newShares) + if err != nil { + return chathelper.Reply(ctx, b, update.Message, "Share dividend is too large.") + } + p.Assets[symbol] = finalHolding + if err := SavePortfolio(ctx, s.store, userID, p); err != nil { + log.Error("stock_save_portfolio", "user", userID, "err", err) + return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.") + } + return chathelper.Reply(ctx, b, update.Message, + "Share dividend ("+ratio.raw+"): +"+formatShareQuantity(newShares)+" "+symbol+ + "\nHolding: "+formatShareQuantity(held)+" → "+formatShareQuantity(finalHolding)) } func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.Update) error { @@ -303,20 +372,23 @@ func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.U "Cannot identify user — stock only works in private/group chats with a sender.") } args := argsAfterCommand(update.Message.Text) - if len(args) != 2 { + if len(args) != 3 { return chathelper.Reply(ctx, b, update.Message, - "Usage: /stock_dividend \nExample: /stock_dividend 1500 TCB") + "Usage: /stock_dividend \nExample: /stock_dividend 1500 100:10 TCB") } - amountPerShare, ok := parsePositiveFinite(args[0]) + vndPerShare, ok := parsePositiveWhole(args[0]) if !ok { - return chathelper.Reply(ctx, b, update.Message, "Amount per share must be a positive finite number.") + return chathelper.Reply(ctx, b, update.Message, "VND per share must be a positive whole number.") } - - symbol, err := normalizeStockSymbol(args[1]) + ratio, ok := parseShareRatio(args[1]) + if !ok { + return chathelper.Reply(ctx, b, update.Message, "Share ratio must use positive whole numbers in owned:new form.") + } + symbol, err := normalizeStockSymbol(args[2]) if err != nil { if errors.Is(err, ErrUnknownTicker) { return chathelper.Reply(ctx, b, update.Message, - "Unknown stock ticker \""+strings.ToUpper(args[1])+"\".") + "Unknown stock ticker \""+strings.ToUpper(args[2])+"\".") } return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.") } @@ -329,20 +401,39 @@ func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.U return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.") } held := p.Assets[symbol] - if held == 0 { + if held <= 0 { return chathelper.Reply(ctx, b, update.Message, - "You don't hold any "+symbol+" to receive a cash dividend.") + "You don't hold any "+symbol+" to receive a dividend.") } - total := amountPerShare * float64(held) - p.AddCurrency("VND", total) + total, err := cashDividendTotal(held, vndPerShare) + if err != nil { + return chathelper.Reply(ctx, b, update.Message, "Dividend amount is too large.") + } + newShares, err := shareDividendEntitlement(held, ratio) + if err != nil { + return chathelper.Reply(ctx, b, update.Message, "Share dividend is too large.") + } + finalHolding, err := checkedHoldingAfterDividend(held, newShares) + if err != nil { + return chathelper.Reply(ctx, b, update.Message, "Share dividend is too large.") + } + balance, err := checkedVNDBalance(p.Currency["VND"], total) + if err != nil { + return chathelper.Reply(ctx, b, update.Message, "Dividend amount is too large.") + } + + p.Currency["VND"] = balance + p.Assets[symbol] = finalHolding if err := SavePortfolio(ctx, s.store, userID, p); err != nil { log.Error("stock_save_portfolio", "user", userID, "err", err) return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.") } return chathelper.Reply(ctx, b, update.Message, - "Cash dividend: "+FormatVND(amountPerShare)+" × "+FormatStock(float64(held))+" "+symbol+ - " = "+FormatVND(total)+ - "\nRemaining: "+FormatVND(p.Currency["VND"])) + "Dividend for "+symbol+" ("+ratio.raw+")"+ + "\nCash: "+FormatVND(float64(vndPerShare))+" × "+formatShareQuantity(held)+" = "+FormatVND(float64(total))+ + "\nShares: +"+formatShareQuantity(newShares)+ + "\nHolding: "+formatShareQuantity(held)+" → "+formatShareQuantity(finalHolding)+ + "\nBalance: "+FormatVND(balance)) } // handleStats fetches current prices for held tickers and renders the diff --git a/internal/modules/stock/handlers_test.go b/internal/modules/stock/handlers_test.go index 191a30e..a81a274 100644 --- a/internal/modules/stock/handlers_test.go +++ b/internal/modules/stock/handlers_test.go @@ -2,6 +2,7 @@ package stock import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -26,7 +27,8 @@ func TestModuleRegistersExpectedCommands(t *testing.T) { "stock_topup", "stock_buy", "stock_sell", - "stock_bonus", + "stock_cash_dividend", + "stock_share_dividend", "stock_dividend", "stock_portfolio", } { @@ -111,20 +113,28 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) { want: "Usage: /stock_sell ", }, { - name: "bonus", - text: "/stock_bonus 100 TCB extra", + name: "cash dividend", + text: "/stock_cash_dividend 1500 TCB extra", run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error { - return s.handleBonus(ctx, rb.Bot, upd) + return s.handleCashDividend(ctx, rb.Bot, upd) }, - want: "Usage: /stock_bonus ", + want: "Usage: /stock_cash_dividend ", + }, + { + name: "share dividend", + text: "/stock_share_dividend 100:10 TCB extra", + run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error { + return s.handleShareDividend(ctx, rb.Bot, upd) + }, + want: "Usage: /stock_share_dividend ", }, { name: "dividend", - text: "/stock_dividend 1500 TCB extra", + text: "/stock_dividend 1500 100:10 TCB extra", run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error { return s.handleDividend(ctx, rb.Bot, upd) }, - want: "Usage: /stock_dividend ", + want: "Usage: /stock_dividend ", }, } for _, tc := range cases { @@ -146,7 +156,7 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) { } } -func TestMutableHandlersRejectNonFiniteVND(t *testing.T) { +func TestDividendHandlersRejectInvalidNumbers(t *testing.T) { ctx := context.Background() s := &state{ store: newStockStore(), @@ -161,10 +171,231 @@ func TestMutableHandlersRejectNonFiniteVND(t *testing.T) { rb.AssertSentText(t, "positive finite") rb.Reset() - if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend Inf TCB")); err != nil { - t.Fatalf("dividend: %v", err) + if err := s.handleCashDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_cash_dividend 1.5 TCB")); err != nil { + t.Fatalf("cash dividend: %v", err) } - rb.AssertSentText(t, "positive finite") + rb.AssertSentText(t, "positive whole number") + + rb.Reset() + if err := s.handleShareDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_share_dividend 4.0:1 TCB")); err != nil { + t.Fatalf("share dividend: %v", err) + } + rb.AssertSentText(t, "owned:new") + + rb.Reset() + if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend Inf 4:1 TCB")); err != nil { + t.Fatalf("combined dividend: %v", err) + } + rb.AssertSentText(t, "positive whole number") +} + +type countingPortfolioStore struct { + Store + puts int + putErr error +} + +func (s *countingPortfolioStore) Put(ctx context.Context, id string, p Portfolio) error { + s.puts++ + if s.putErr != nil { + return s.putErr + } + return s.Store.Put(ctx, id, p) +} + +func seedStockPortfolio(t *testing.T, store Store, userID int64, held int64, balance float64) { + t.Helper() + p := NewPortfolio(123) + p.Assets["TCB"] = held + p.Currency["VND"] = balance + if err := SavePortfolio(context.Background(), store, userID, p); err != nil { + t.Fatalf("seed portfolio: %v", err) + } +} + +func TestHandleCashDividendAllowsRepeatedManualAdjustments(t *testing.T) { + ctx := context.Background() + store := newStockStore() + seedStockPortfolio(t, store, 7, 139, 1000) + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + for i := 0; i < 2; i++ { + if err := s.handleCashDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_cash_dividend 1500 TCB")); err != nil { + t.Fatalf("handleCashDividend call %d: %v", i+1, err) + } + } + p, err := LoadPortfolio(ctx, store, 7, 999) + if err != nil { + t.Fatalf("load portfolio: %v", err) + } + if got, want := p.Currency["VND"], float64(418000); got != want { + t.Fatalf("balance = %v, want %v", got, want) + } +} + +func TestHandleCashDividendRejectsInexactBalanceSum(t *testing.T) { + ctx := context.Background() + base := newStockStore() + seedStockPortfolio(t, base, 7, 1, 1) + store := &countingPortfolioStore{Store: base} + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + if err := s.handleCashDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_cash_dividend 9007199254740992 TCB")); err != nil { + t.Fatalf("handleCashDividend: %v", err) + } + if store.puts != 0 { + t.Fatalf("store writes = %d, want 0", store.puts) + } + p, _ := LoadPortfolio(ctx, base, 7, 999) + if p.Assets["TCB"] != 1 || p.Currency["VND"] != 1 { + t.Fatalf("portfolio changed: %+v", p) + } + rb.AssertSentText(t, "Dividend amount is too large.") +} + +func TestHandleShareDividendPreservesRatioAndFloors(t *testing.T) { + ctx := context.Background() + store := newStockStore() + seedStockPortfolio(t, store, 7, 139, 0) + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + if err := s.handleShareDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_share_dividend 100:10 TCB")); err != nil { + t.Fatalf("handleShareDividend: %v", err) + } + p, _ := LoadPortfolio(ctx, store, 7, 999) + if got, want := p.Assets["TCB"], int64(152); got != want { + t.Fatalf("holding = %d, want %d", got, want) + } + rb.AssertSentText(t, "Share dividend (100:10): +13 TCB") +} + +func TestHandleShareDividendFormatsExactLargeQuantities(t *testing.T) { + ctx := context.Background() + store := newStockStore() + seedStockPortfolio(t, store, 7, 9_007_199_254_740_993, 0) + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + if err := s.handleShareDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_share_dividend 1:1 TCB")); err != nil { + t.Fatalf("handleShareDividend: %v", err) + } + rb.AssertSentText(t, "Share dividend (1:1): +9.007.199.254.740.993 TCB") + rb.AssertSentText(t, "Holding: 9.007.199.254.740.993 → 18.014.398.509.481.986") +} + +func TestHandleShareDividendRejectsZeroEntitlement(t *testing.T) { + ctx := context.Background() + base := newStockStore() + seedStockPortfolio(t, base, 7, 9, 0) + store := &countingPortfolioStore{Store: base} + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + if err := s.handleShareDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_share_dividend 100:10 TCB")); err != nil { + t.Fatalf("handleShareDividend: %v", err) + } + if store.puts != 0 { + t.Fatalf("store writes = %d, want 0", store.puts) + } + p, _ := LoadPortfolio(ctx, base, 7, 999) + if p.Assets["TCB"] != 9 { + t.Fatalf("holding changed to %d", p.Assets["TCB"]) + } + rb.AssertSentText(t, "Minimum holding: 10") +} + +func TestHandleShareDividendFormatsExactLargeMinimum(t *testing.T) { + ctx := context.Background() + store := newStockStore() + seedStockPortfolio(t, store, 7, 1, 0) + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + if err := s.handleShareDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_share_dividend 9007199254740993:1 TCB")); err != nil { + t.Fatalf("handleShareDividend: %v", err) + } + rb.AssertSentText(t, "Minimum holding: 9.007.199.254.740.993.") +} + +func TestHandleCombinedDividendUsesPreEventHoldingAndOneSave(t *testing.T) { + ctx := context.Background() + base := newStockStore() + seedStockPortfolio(t, base, 7, 139, 1000) + store := &countingPortfolioStore{Store: base} + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend 1500 100:10 TCB")); err != nil { + t.Fatalf("handleDividend: %v", err) + } + if store.puts != 1 { + t.Fatalf("store writes = %d, want 1", store.puts) + } + p, _ := LoadPortfolio(ctx, base, 7, 999) + if p.Assets["TCB"] != 152 || p.Currency["VND"] != 209500 { + t.Fatalf("portfolio = %+v", p) + } + rb.AssertSentText(t, "Dividend for TCB (100:10)") + rb.AssertSentText(t, "Cash: 1.500 VND × 139 = 208.500 VND") +} + +func TestHandleCombinedDividendRejectsInexactBalanceSum(t *testing.T) { + ctx := context.Background() + base := newStockStore() + seedStockPortfolio(t, base, 7, 1, 1) + store := &countingPortfolioStore{Store: base} + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend 9007199254740992 1:1 TCB")); err != nil { + t.Fatalf("handleDividend: %v", err) + } + if store.puts != 0 { + t.Fatalf("store writes = %d, want 0", store.puts) + } + p, _ := LoadPortfolio(ctx, base, 7, 999) + if p.Assets["TCB"] != 1 || p.Currency["VND"] != 1 { + t.Fatalf("portfolio changed: %+v", p) + } + rb.AssertSentText(t, "Dividend amount is too large.") +} + +func TestHandleCombinedDividendCreditsCashWhenSharesRoundToZero(t *testing.T) { + ctx := context.Background() + store := newStockStore() + seedStockPortfolio(t, store, 7, 9, 100) + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend 1500 100:10 TCB")); err != nil { + t.Fatalf("handleDividend: %v", err) + } + p, _ := LoadPortfolio(ctx, store, 7, 999) + if p.Assets["TCB"] != 9 || p.Currency["VND"] != 13600 { + t.Fatalf("portfolio = %+v", p) + } + rb.AssertSentText(t, "Shares: +0") +} + +func TestDividendSaveFailureLeavesStoredPortfolioUnchanged(t *testing.T) { + ctx := context.Background() + base := newStockStore() + seedStockPortfolio(t, base, 7, 139, 1000) + store := &countingPortfolioStore{Store: base, putErr: errors.New("forced write failure")} + s := &state{store: store, nowFn: func() time.Time { return time.UnixMilli(123) }} + rb := testutil.NewRecordingBot(t) + + if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend 1500 100:10 TCB")); err != nil { + t.Fatalf("handleDividend: %v", err) + } + p, _ := LoadPortfolio(ctx, base, 7, 999) + if p.Assets["TCB"] != 139 || p.Currency["VND"] != 1000 { + t.Fatalf("stored portfolio changed: %+v", p) + } + rb.AssertSentText(t, "Could not save portfolio") } func modDepsForTest() modules.Deps { diff --git a/internal/modules/stock/stock.go b/internal/modules/stock/stock.go index b43c69e..782b693 100644 --- a/internal/modules/stock/stock.go +++ b/internal/modules/stock/stock.go @@ -5,7 +5,7 @@ import ( "github.com/tiennm99/miti99bot/internal/storage" ) -// New is the stock module Factory. Seven user-facing commands. +// New is the stock module Factory. Eight user-facing commands. func New(deps modules.Deps) modules.Module { s := newState(storage.Typed[Portfolio](deps.Store)) return modules.Module{ @@ -35,15 +35,21 @@ func New(deps modules.Deps) modules.Module { Handler: s.handleSell, }, { - Name: "stock_bonus", + Name: "stock_cash_dividend", Visibility: modules.VisibilityPublic, - Description: "Record bonus shares", - Handler: s.handleBonus, + Description: "Record cash dividend (VND/share TICKER)", + Handler: s.handleCashDividend, + }, + { + Name: "stock_share_dividend", + Visibility: modules.VisibilityPublic, + Description: "Record share dividend (owned:new TICKER)", + Handler: s.handleShareDividend, }, { Name: "stock_dividend", Visibility: modules.VisibilityPublic, - Description: "Record cash dividend (VND per share)", + Description: "Record cash and share dividend", Handler: s.handleDividend, }, { diff --git a/telegram-commands.json b/telegram-commands.json index 658140e..b90f320 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -101,12 +101,16 @@ "description": "Sell VN stock back to VND" }, { - "command": "stock_bonus", - "description": "Record bonus shares" + "command": "stock_cash_dividend", + "description": "Record cash dividend (VND/share TICKER)" + }, + { + "command": "stock_share_dividend", + "description": "Record share dividend (owned:new TICKER)" }, { "command": "stock_dividend", - "description": "Record cash dividend per share" + "description": "Record cash and share dividend" }, { "command": "stock_portfolio",