fix(stock): retire dividend command and migrate stats

This commit is contained in:
2026-07-22 18:55:57 +07:00
parent 20e8a6b126
commit 81443fd080
10 changed files with 331 additions and 194 deletions
+4 -2
View File
@@ -75,7 +75,6 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"stock_sell": "<quantity> <ticker>",
"stock_cash_dividend": "<vnd_per_share> <ticker>",
"stock_share_dividend": "<ratio(owned:new)> <ticker>",
"stock_dividend": "<vnd_per_share> <ratio(owned:new)> <ticker>",
"trongtruonghop": "[target...]",
"tth": "[target...]",
"wheelofnames": "<option,...>",
@@ -125,11 +124,14 @@ func TestBotCommandMenu_StockDividendContracts(t *testing.T) {
t.Fatalf("description for %s exceeds Telegram limit", command.Command)
}
}
for _, name := range []string{"stock_cash_dividend", "stock_share_dividend", "stock_dividend"} {
for _, name := range []string{"stock_cash_dividend", "stock_share_dividend"} {
if commands[name] == "" {
t.Fatalf("stock menu missing %s: %v", name, commands)
}
}
if _, exists := commands["stock_dividend"]; exists {
t.Fatalf("retired stock_dividend remains in public menu: %v", commands)
}
if _, exists := commands["stock_bonus"]; exists {
t.Fatalf("stock_bonus remains in public menu: %v", commands)
}
+15 -1
View File
@@ -118,7 +118,7 @@ func main() {
}
defer closeProvider()
if err := stats.InitStore(rootCtx, provider.Collection("stats")); err != nil {
if err := initStatsStore(rootCtx, provider); err != nil {
log.Fatal("stats storage init failed", "err", err)
}
if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil {
@@ -224,6 +224,20 @@ func initStockStore(ctx context.Context, provider storage.Provider) error {
return initStockStoreWith(ctx, provider, stock.InitStore)
}
func initStatsStore(ctx context.Context, provider storage.Provider) error {
return initStatsStoreWith(ctx, provider, stats.InitStore)
}
type statsStoreInitializer func(context.Context, storage.Collection, storage.Collection) error
func initStatsStoreWith(ctx context.Context, provider storage.Provider, init statsStoreInitializer) error {
return init(
ctx,
provider.Collection("stats"),
provider.Collection(systemstate.CollectionName),
)
}
type stockStoreInitializer func(context.Context, storage.Collection, storage.Collection) error
func initStockStoreWith(ctx context.Context, provider storage.Provider, init stockStoreInitializer) error {
+22
View File
@@ -51,6 +51,18 @@ func TestInitStockStoreRunsStartupMigration(t *testing.T) {
}
}
func TestInitStatsStoreRunsStartupMigration(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
if err := initStatsStore(ctx, provider); err != nil {
t.Fatalf("initStatsStore: %v", err)
}
marker, exists, err := systemstate.New(provider.Collection(systemstate.CollectionName)).Get(ctx, "migration:stats-delete-stock-dividend-v1")
if err != nil || !exists || marker.Status != "completed" {
t.Fatalf("marker=%+v exists=%v err=%v", marker, exists, err)
}
}
func TestInitStockStorePropagatesMigrationError(t *testing.T) {
want := errors.New("migration failed")
err := initStockStoreWith(context.Background(), storage.NewMemoryProvider(), func(context.Context, storage.Collection, storage.Collection) error {
@@ -61,6 +73,16 @@ func TestInitStockStorePropagatesMigrationError(t *testing.T) {
}
}
func TestInitStatsStorePropagatesMigrationError(t *testing.T) {
want := errors.New("migration failed")
err := initStatsStoreWith(context.Background(), storage.NewMemoryProvider(), func(context.Context, storage.Collection, storage.Collection) error {
return want
})
if !errors.Is(err, want) {
t.Fatalf("initStatsStoreWith error=%v, want %v", err, want)
}
}
func TestShortCommitSHA(t *testing.T) {
if got := shortCommitSHA(" 0123456789abcdef "); got != "0123456" {
t.Errorf("full revision: got %q, want %q", got, "0123456")
+106 -6
View File
@@ -2,32 +2,132 @@ package stats
import (
"context"
"errors"
"fmt"
"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"
statsCommandUsersIndexName = "stats_cmd_n_user"
statsUserCommandsIndexName = "stats_uid_n_cmd"
statsUsernameLookupIndexName = "stats_user_uid"
deletedStockDividendMarkerKey = "migration:stats-delete-stock-dividend-v1"
deletedStockDividendCommand = "stock_dividend"
deletedCommandMigrationRetries = 5
)
// InitStore performs stats collection startup maintenance. It is safe to call
// every boot because MongoDB index creation is idempotent.
func InitStore(ctx context.Context, statsColl storage.Collection) error {
// InitStore performs stats collection startup maintenance. MongoDB index
// creation and the legacy-command migration are both safe to run every boot.
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
}
}
return markDeletedCommand(ctx, statsColl, systemColl, deletedStockDividendCommand, deletedStockDividendMarkerKey)
}
func markDeletedCommand(ctx context.Context, statsColl, systemColl storage.Collection, command, markerKey string) error {
system := systemstate.New(systemColl)
marker, exists, err := system.Get(ctx, markerKey)
if err != nil {
return fmt.Errorf("stats deleted-command migration: read marker: %w", err)
}
var matched int64
if mongoColl, ok := storage.MongoCollection(statsColl); ok {
matched, err = markMongoUsageEntriesDeleted(ctx, mongoColl, command)
} else {
matched, err = markDocUsageEntriesDeleted(ctx, storage.Typed[usageEntry](statsColl), command)
}
if err != nil {
return err
}
now := time.Now().UnixMilli()
if !exists {
marker = systemstate.Record{Kind: "migration", Name: "stats delete " + command + " v1"}
}
if marker.CompletedAt == 0 {
marker.CompletedAt = now
}
marker.Status = "completed"
marker.Count = matched
marker.UpdatedAt = now
if err := system.Put(ctx, markerKey, marker); err != nil {
return fmt.Errorf("stats deleted-command migration: write marker: %w", err)
}
return nil
}
func markMongoUsageEntriesDeleted(ctx context.Context, coll *mongo.Collection, command string) (int64, error) {
filter := bson.M{"cmd": command}
if _, err := coll.UpdateMany(ctx,
bson.M{"cmd": command, "deleted": bson.M{"$ne": true}},
bson.M{
"$set": bson.M{"deleted": true},
"$inc": bson.M{"version": int64(1)},
"$currentDate": bson.M{"updatedAt": true},
},
); err != nil {
return 0, fmt.Errorf("stats deleted-command migration: update %s: %w", command, err)
}
count, err := coll.CountDocuments(ctx, filter)
if err != nil {
return 0, fmt.Errorf("stats deleted-command migration: count %s: %w", command, err)
}
return count, nil
}
func markDocUsageEntriesDeleted(ctx context.Context, docs storage.DocStore[usageEntry], command string) (int64, error) {
keys, err := docs.List(ctx, command)
if err != nil {
return 0, fmt.Errorf("stats deleted-command migration: list %s: %w", command, err)
}
var matched int64
for _, key := range keys {
isMatch, err := markUsageEntryDeleted(ctx, docs, key, command)
if err != nil {
return 0, err
}
if isMatch {
matched++
}
}
return matched, nil
}
func markUsageEntryDeleted(ctx context.Context, docs storage.DocStore[usageEntry], key, command string) (bool, error) {
for attempt := 0; attempt < deletedCommandMigrationRetries; attempt++ {
entry, version, err := docs.Get(ctx, key)
if errors.Is(err, storage.ErrNotFound) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("stats deleted-command migration: read %s: %w", key, err)
}
if entry.Cmd != command {
return false, nil
}
if entry.Deleted {
return true, nil
}
entry.Deleted = true
if err := docs.PutVersioned(ctx, key, version, entry); err == nil {
return true, nil
} else if !errors.Is(err, storage.ErrConflict) {
return false, fmt.Errorf("stats deleted-command migration: write %s: %w", key, err)
}
}
return false, fmt.Errorf("stats deleted-command migration: write %s: %w", key, storage.ErrConflict)
}
func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error {
models := []mongo.IndexModel{
{
+61 -5
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/systemstate"
"github.com/tiennm99/miti99bot/internal/testutil/mongotest"
)
@@ -18,19 +19,74 @@ func TestMain(m *testing.M) {
}
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 {
docs := storage.Typed[usageEntry](statsColl)
if err := docs.Put(ctx, "stock_dividend", usageEntry{Cmd: "stock_dividend", N: 9}); err != nil {
t.Fatalf("seed anonymous stats: %v", err)
}
if err := docs.Put(ctx, "stock_dividend:7", usageEntry{Cmd: "stock_dividend", UserID: 7, Username: "alice", N: 4}); err != nil {
t.Fatalf("seed user stats: %v", err)
}
if err := docs.Put(ctx, "stock_dividend_extra", usageEntry{Cmd: "stock_dividend_extra", N: 3}); err != nil {
t.Fatalf("seed prefix stats: %v", err)
}
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)
}
for _, key := range []string{"stock_dividend", "stock_dividend:7"} {
entry, _, err := docs.Get(ctx, key)
if err != nil || !entry.Deleted {
t.Fatalf("retained %s = %+v, err=%v", key, entry, err)
}
}
prefixEntry, _, err := docs.Get(ctx, "stock_dividend_extra")
if err != nil || prefixEntry.Deleted {
t.Fatalf("prefix entry = %+v, err=%v", prefixEntry, err)
}
marker, exists, err := systemstate.New(systemColl).Get(ctx, deletedStockDividendMarkerKey)
if err != nil || !exists || marker.Status != "completed" || marker.Count != 2 {
t.Fatalf("marker=%+v exists=%v err=%v", marker, exists, err)
}
legacy, _, err := docs.Get(ctx, "stock_dividend:7")
if err != nil {
t.Fatal(err)
}
legacy.Deleted = false
legacy.N++
if err := docs.Put(ctx, "stock_dividend:7", legacy); err != nil {
t.Fatal(err)
}
store := newUsageStore(statsColl)
if err := store.Increment(ctx, "stock_dividend", usageUser{ID: 7, Username: "alice"}, true); err != nil {
t.Fatalf("retired Increment: %v", err)
}
if rows, err := store.TopCommands(ctx, 10); err != nil || len(rows) != 1 || rows[0].display != "/stock_dividend_extra" || rows[0].n != 3 {
t.Fatalf("top commands after legacy write = %+v, err=%v", rows, err)
}
if rows, err := store.TopUsers(ctx, 10); err != nil || len(rows) != 0 {
t.Fatalf("retired top users = %+v, err=%v", rows, err)
}
if rows, err := store.UsersByCommand(ctx, "stock_dividend", 10); err != nil || len(rows) != 0 {
t.Fatalf("retired users = %+v, err=%v", rows, err)
}
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore reconciliation: %v", err)
}
legacy, _, err = docs.Get(ctx, "stock_dividend:7")
if err != nil || !legacy.Deleted || legacy.N != 5 {
t.Fatalf("reconciled legacy entry = %+v, err=%v", legacy, err)
}
cur, err := rawStatsColl.Indexes().List(ctx)
if err != nil {
@@ -58,7 +114,7 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) {
}
}
func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection) {
func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection, storage.Collection) {
t.Helper()
uri := mongoTests.URI(t)
@@ -80,5 +136,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(systemstate.CollectionName)
}
+98
View File
@@ -0,0 +1,98 @@
package stats
import (
"context"
"testing"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/systemstate"
)
func TestInitStoreMarksStockDividendStatsDeletedAndRetainsHistory(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
docs := storage.Typed[usageEntry](statsColl)
seed := map[string]usageEntry{
"stock_dividend": {Cmd: "stock_dividend", N: 12},
"stock_dividend:7": {Cmd: "stock_dividend", UserID: 7, Username: "alice", N: 5, Deleted: true},
"stock_dividend_extra": {Cmd: "stock_dividend_extra", N: 3},
"stock_cash_dividend:7": {Cmd: "stock_cash_dividend", UserID: 7, Username: "alice", N: 2},
}
for key, entry := range seed {
if err := docs.Put(ctx, key, entry); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
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)
}
for _, key := range []string{"stock_dividend", "stock_dividend:7"} {
entry, _, err := docs.Get(ctx, key)
if err != nil {
t.Fatalf("get retained %s: %v", key, err)
}
if !entry.Deleted || entry.N != seed[key].N || entry.Username != seed[key].Username {
t.Fatalf("retained %s = %+v, want deleted history %+v", key, entry, seed[key])
}
}
for _, key := range []string{"stock_dividend_extra", "stock_cash_dividend:7"} {
entry, _, err := docs.Get(ctx, key)
if err != nil || entry.Deleted {
t.Fatalf("unrelated %s = %+v, err=%v", key, entry, err)
}
}
marker, exists, err := systemstate.New(systemColl).Get(ctx, deletedStockDividendMarkerKey)
if err != nil || !exists || marker.Status != "completed" || marker.Count != 2 {
t.Fatalf("marker=%+v exists=%v err=%v", marker, exists, err)
}
rows, err := newUsageStore(statsColl).TopCommands(ctx, 10)
if err != nil {
t.Fatalf("TopCommands: %v", err)
}
for _, row := range rows {
if row.display == "/stock_dividend" {
t.Fatalf("retired command remains visible: %+v", rows)
}
}
// Simulate a legacy instance writing after the first startup completed.
legacy, _, err := docs.Get(ctx, "stock_dividend:7")
if err != nil {
t.Fatal(err)
}
legacy.Deleted = false
legacy.N++
if err := docs.Put(ctx, "stock_dividend:7", legacy); err != nil {
t.Fatal(err)
}
store := newUsageStore(statsColl)
if err := store.Increment(ctx, "stock_dividend", usageUser{ID: 7, Username: "alice"}, true); err != nil {
t.Fatalf("retired Increment: %v", err)
}
if rows, err := store.UsersByCommand(ctx, "stock_dividend", 10); err != nil || len(rows) != 0 {
t.Fatalf("retired users = %+v, err=%v", rows, err)
}
if rows, err := store.TopUsers(ctx, 10); err != nil || len(rows) != 1 || rows[0].display != "@alice" || rows[0].n != 2 {
t.Fatalf("top users after legacy write = %+v, err=%v", rows, err)
}
if rows, found, err := store.CommandsByUser(ctx, "alice", 10); err != nil || !found || len(rows) != 1 || rows[0].display != "/stock_cash_dividend" {
t.Fatalf("commands after legacy write = %+v, found=%v err=%v", rows, found, err)
}
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore reconciliation: %v", err)
}
legacy, _, err = docs.Get(ctx, "stock_dividend:7")
if err != nil || !legacy.Deleted || legacy.N != 6 {
t.Fatalf("reconciled legacy entry = %+v, err=%v", legacy, err)
}
}
+24 -6
View File
@@ -61,6 +61,9 @@ type docUsageStore struct {
}
func (s *docUsageStore) Increment(ctx context.Context, cmd string, user usageUser, hasUser bool) error {
if isRetiredCommand(cmd) {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -123,7 +126,7 @@ func (s *docUsageStore) TopCommands(ctx context.Context, limit int) ([]row, erro
}
totals := make(map[string]int64)
for _, e := range entries {
if e.Deleted {
if e.Deleted || isRetiredCommand(e.Cmd) {
continue
}
totals[e.Cmd] += e.N
@@ -150,7 +153,7 @@ func (s *docUsageStore) TopUsers(ctx context.Context, limit int) ([]row, error)
}
totals := make(map[int64]total)
for _, e := range entries {
if e.Deleted || e.UserID == 0 || e.Username == "" {
if e.Deleted || isRetiredCommand(e.Cmd) || e.UserID == 0 || e.Username == "" {
continue
}
t := totals[e.UserID]
@@ -179,7 +182,7 @@ func (s *docUsageStore) CommandsByUser(ctx context.Context, username string, lim
found bool
)
for _, e := range entries {
if !e.Deleted && e.UserID != 0 && e.Username == username {
if !e.Deleted && !isRetiredCommand(e.Cmd) && e.UserID != 0 && e.Username == username {
userID = e.UserID
found = true
break
@@ -191,7 +194,7 @@ func (s *docUsageStore) CommandsByUser(ctx context.Context, username string, lim
rows := make([]row, 0)
for _, e := range entries {
if !e.Deleted && e.UserID == userID {
if !e.Deleted && !isRetiredCommand(e.Cmd) && e.UserID == userID {
rows = append(rows, row{display: "/" + e.Cmd, n: e.N})
}
}
@@ -200,6 +203,9 @@ func (s *docUsageStore) CommandsByUser(ctx context.Context, username string, lim
}
func (s *docUsageStore) UsersByCommand(ctx context.Context, cmd string, limit int) ([]row, error) {
if isRetiredCommand(cmd) {
return nil, nil
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -241,6 +247,9 @@ type mongoUsageStore struct {
}
func (s *mongoUsageStore) Increment(ctx context.Context, cmd string, user usageUser, hasUser bool) error {
if isRetiredCommand(cmd) {
return nil
}
userID := int64(0)
if hasUser {
userID = user.ID
@@ -289,7 +298,7 @@ func (s *mongoUsageStore) Increment(ctx context.Context, cmd string, user usageU
func (s *mongoUsageStore) TopCommands(ctx context.Context, limit int) ([]row, error) {
pipeline := mongo.Pipeline{
bson.D{bsonField("$match", bson.D{
bsonField("cmd", bson.D{bsonField("$type", "string")}),
bsonField("cmd", bson.D{bsonField("$type", "string"), bsonField("$ne", deletedStockDividendCommand)}),
bsonField("deleted", bson.D{bsonField("$ne", true)}),
})},
bson.D{bsonField("$group", bson.D{bsonField("_id", "$cmd"), bsonField("n", bson.D{bsonField("$sum", "$n")})})},
@@ -323,6 +332,7 @@ func (s *mongoUsageStore) TopCommands(ctx context.Context, limit int) ([]row, er
func (s *mongoUsageStore) TopUsers(ctx context.Context, limit int) ([]row, error) {
pipeline := mongo.Pipeline{
bson.D{bsonField("$match", bson.D{
bsonField("cmd", bson.D{bsonField("$ne", deletedStockDividendCommand)}),
bsonField("uid", bson.D{bsonField("$gt", int64(0))}),
bsonField("user", bson.D{bsonField("$type", "string"), bsonField("$ne", "")}),
bsonField("deleted", bson.D{bsonField("$ne", true)}),
@@ -374,7 +384,7 @@ func (s *mongoUsageStore) CommandsByUser(ctx context.Context, username string, l
}
cur, err := s.coll.Find(ctx, bson.M{
"uid": userID,
"cmd": bson.M{"$type": "string"},
"cmd": bson.M{"$type": "string", "$ne": deletedStockDividendCommand},
"deleted": bson.M{"$ne": true},
}, opts)
if err != nil {
@@ -397,6 +407,9 @@ func (s *mongoUsageStore) CommandsByUser(ctx context.Context, username string, l
}
func (s *mongoUsageStore) UsersByCommand(ctx context.Context, cmd string, limit int) ([]row, error) {
if isRetiredCommand(cmd) {
return nil, nil
}
opts := options.Find().
SetProjection(bson.M{"user": 1, "n": 1}).
SetSort(bson.D{bsonField("n", -1), bsonField("user", 1)})
@@ -434,6 +447,7 @@ func (s *mongoUsageStore) userIDByUsername(ctx context.Context, username string)
}
err := s.coll.FindOne(ctx,
bson.M{
"cmd": bson.M{"$ne": deletedStockDividendCommand},
"uid": bson.M{"$gt": int64(0)},
"user": username,
"deleted": bson.M{"$ne": true},
@@ -450,6 +464,10 @@ func (s *mongoUsageStore) userIDByUsername(ctx context.Context, username string)
return doc.UserID, true, nil
}
func isRetiredCommand(cmd string) bool {
return cmd == deletedStockDividendCommand
}
func withLimit(pipeline mongo.Pipeline, limit int) mongo.Pipeline {
if limit <= 0 {
return pipeline
-72
View File
@@ -385,78 +385,6 @@ func (s *state) handleShareDividend(ctx context.Context, b *bot.Bot, update *mod
"\nHolding: "+formatShareQuantity(held)+" → "+formatShareQuantity(finalHolding))
}
func (s *state) handleDividend(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) != 3 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /stock_dividend <vnd_per_share> <ratio(owned:new)> <ticker>\nEg: /stock_dividend 1500 100:10 TCB")
}
vndPerShare, ok := parsePositiveWhole(args[0])
if !ok {
return chathelper.Reply(ctx, b, update.Message, "VND per share must be a positive whole number.")
}
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[2])+"\".")
}
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].Quantity
if held <= 0 {
return chathelper.Reply(ctx, b, update.Message,
"You don't hold any "+symbol+" to receive a dividend.")
}
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.VND, total)
if err != nil {
return chathelper.Reply(ctx, b, update.Message, "Dividend amount is too large.")
}
if err := p.ApplyDividend(symbol, finalHolding, balance, s.now().UnixMilli()); err != nil {
return chathelper.Reply(ctx, b, update.Message, "Could not apply this dividend. Try again later.")
}
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,
"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 renders the portfolio first, then synchronizes dividend history
// for each held ticker. Network calls never hold the user lock, while history
// merging and notification state updates reload under it.
-94
View File
@@ -2,7 +2,6 @@ package stock
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -30,7 +29,6 @@ func TestModuleRegistersExpectedCommands(t *testing.T) {
"stock_sell",
"stock_cash_dividend",
"stock_share_dividend",
"stock_dividend",
"stock_portfolio",
} {
if !got[name] {
@@ -165,14 +163,6 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) {
},
want: "Usage: /stock_share_dividend <ratio(owned:new)> <ticker>",
},
{
name: "dividend",
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 <vnd_per_share> <ratio(owned:new)> <ticker>",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -218,12 +208,6 @@ func TestDividendHandlersRejectInvalidNumbers(t *testing.T) {
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 {
@@ -363,84 +347,6 @@ func TestHandleShareDividendFormatsExactLargeMinimum(t *testing.T) {
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"].Quantity != 152 || p.VND != 209500 || p.Assets["TCB"].Base != 139*30_000 || p.Assets["TCB"].OpenedAt != 100 {
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"].Quantity != 1 || p.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"].Quantity != 9 || p.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"].Quantity != 139 || p.VND != 1000 {
t.Fatalf("stored portfolio changed: %+v", p)
}
rb.AssertSentText(t, "Could not save portfolio")
}
func modDepsForTest() modules.Deps {
return modules.Deps{Store: storage.NewMemoryProvider().Collection("stock")}
}
+1 -8
View File
@@ -5,7 +5,7 @@ import (
"github.com/tiennm99/miti99bot/internal/storage"
)
// New is the stock module Factory. Eight user-facing commands.
// New is the stock module Factory. Seven user-facing commands.
func New(deps modules.Deps) modules.Module {
s := newState(
storage.Typed[Portfolio](deps.Store),
@@ -56,13 +56,6 @@ func New(deps modules.Deps) modules.Module {
Parameters: "<ratio(owned:new)> <ticker>",
Handler: s.handleShareDividend,
},
{
Name: "stock_dividend",
Visibility: modules.VisibilityPublic,
Description: "Record cash and share dividend",
Parameters: "<vnd_per_share> <ratio(owned:new)> <ticker>",
Handler: s.handleDividend,
},
{
Name: "stock_portfolio",
Visibility: modules.VisibilityPublic,