diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 0c95eb0..f6bb63c 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -60,8 +60,8 @@ func TestFactoriesIncludesExpectedModules(t *testing.T) { t.Fatalf("Build selected modules: %v", err) } for _, name := range []string{ - "gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_stats", - "coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_stats", + "gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_portfolio", + "coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_portfolio", "wc", "wc_this_week", "wc_subscribe", "wc_unsubscribe", } { if _, ok := reg.AllCommands[name]; !ok { diff --git a/internal/modules/coin/coin.go b/internal/modules/coin/coin.go index 42c9290..5d3ffa1 100644 --- a/internal/modules/coin/coin.go +++ b/internal/modules/coin/coin.go @@ -32,13 +32,13 @@ func New(deps modules.Deps) modules.Module { { Name: "coin_sell", Visibility: modules.VisibilityPublic, - Description: "Sell coin back to USD amount", + Description: "Sell coin for a USD amount", Handler: s.handleSell, }, { - Name: "coin_stats", + Name: "coin_portfolio", Visibility: modules.VisibilityPublic, - Description: "Show coin account summary with P&L", + Description: "Show coin portfolio with P&L", Handler: s.handleStats, }, }, diff --git a/internal/modules/coin/handlers_test.go b/internal/modules/coin/handlers_test.go index 3e01af6..7a05fa4 100644 --- a/internal/modules/coin/handlers_test.go +++ b/internal/modules/coin/handlers_test.go @@ -72,7 +72,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) { for _, cmd := range mod.Commands { got[cmd.Name] = true } - for _, name := range []string{"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_stats"} { + for _, name := range []string{"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_portfolio"} { if !got[name] { t.Fatalf("missing command %s", name) } @@ -297,7 +297,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) { _ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_topup 1000")) _ = s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_buy 500 BTC")) rb.Reset() - if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_stats")); err != nil { + if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_portfolio")); err != nil { t.Fatalf("handleStats: %v", err) } text := rb.LastSent().Text() @@ -308,7 +308,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) { } s.prices = fakePriceFetcher{err: ErrNoCoinPrice} rb.Reset() - if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_stats")); err != nil { + if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_portfolio")); err != nil { t.Fatalf("handleStats no price: %v", err) } rb.AssertSentText(t, "price unavailable") diff --git a/internal/modules/coin/views.go b/internal/modules/coin/views.go index 404e8a0..e26b4b7 100644 --- a/internal/modules/coin/views.go +++ b/internal/modules/coin/views.go @@ -15,7 +15,7 @@ import ( func (s *state) handleStats(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 - /coin_stats needs a sender.") + return chathelper.Reply(ctx, b, update.Message, "Cannot identify user - /coin_portfolio needs a sender.") } p, err := LoadPortfolio(ctx, s.store, userID, s.now().UnixMilli()) if err != nil { diff --git a/internal/modules/coin/views_reply_budget_test.go b/internal/modules/coin/views_reply_budget_test.go index 04366b8..d274493 100644 --- a/internal/modules/coin/views_reply_budget_test.go +++ b/internal/modules/coin/views_reply_budget_test.go @@ -11,7 +11,7 @@ import ( // blockingPriceFetcher simulates an upstream that never responds: it blocks // until the fetch context is cancelled, then returns its error. This is the -// exact failure that made /coin_stats (and /stock_stats) time out — the fetch +// exact failure that made /coin_portfolio (and /stock_portfolio) time out — the fetch // must not be allowed to consume the budget the reply needs. type blockingPriceFetcher struct{} @@ -44,7 +44,7 @@ func TestHandleStatsDeliversReplyWhenUpstreamHangs(t *testing.T) { statsCtx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() start := time.Now() - if err := s.handleStats(statsCtx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_stats")); err != nil { + if err := s.handleStats(statsCtx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_portfolio")); err != nil { t.Fatalf("handleStats returned error (reply not delivered): %v", err) } elapsed := time.Since(start) diff --git a/internal/modules/gold/gold.go b/internal/modules/gold/gold.go index a0ae97f..fbafa49 100644 --- a/internal/modules/gold/gold.go +++ b/internal/modules/gold/gold.go @@ -13,7 +13,7 @@ func New(deps modules.Deps) modules.Module { { Name: "gold_price", Visibility: modules.VisibilityPublic, - Description: "Show current gold spot price (USD & VND)", + Description: "Show current SJC gold buy/sell price", Handler: s.handlePrice, }, { @@ -35,9 +35,9 @@ func New(deps modules.Deps) modules.Module { Handler: s.handleSell, }, { - Name: "gold_stats", + Name: "gold_portfolio", Visibility: modules.VisibilityPublic, - Description: "Show gold account summary with P&L", + Description: "Show gold portfolio with P&L", Handler: s.handleStats, }, }, diff --git a/internal/modules/gold/handlers.go b/internal/modules/gold/handlers.go index 4c7aa8e..b6b8b48 100644 --- a/internal/modules/gold/handlers.go +++ b/internal/modules/gold/handlers.go @@ -171,7 +171,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda userID, ok := senderInfo(update) if !ok { return chathelper.Reply(ctx, b, update.Message, - "Cannot identify user - /gold_stats needs a sender.") + "Cannot identify user - /gold_portfolio needs a sender.") } p, err := LoadPortfolio(ctx, s.store, userID, s.now().UnixMilli()) if err != nil { diff --git a/internal/modules/gold/handlers_test.go b/internal/modules/gold/handlers_test.go index af2f8ba..e7b7753 100644 --- a/internal/modules/gold/handlers_test.go +++ b/internal/modules/gold/handlers_test.go @@ -62,7 +62,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) { for _, cmd := range mod.Commands { got[cmd.Name] = true } - for _, name := range []string{"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_stats"} { + for _, name := range []string{"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_portfolio"} { if !got[name] { t.Fatalf("missing command %s", name) } @@ -155,7 +155,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) { _ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 5000000")) _ = s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1")) rb.Reset() - if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_stats")); err != nil { + if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_portfolio")); err != nil { t.Fatalf("stats: %v", err) } text := rb.LastSent().Text() @@ -166,7 +166,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) { } s.prices = fakePriceFetcher{err: ErrNoGoldPrice} rb.Reset() - if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_stats")); err != nil { + if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_portfolio")); err != nil { t.Fatalf("stats no price: %v", err) } rb.AssertSentText(t, "Price: no price") diff --git a/internal/modules/stock/format.go b/internal/modules/stock/format.go index 24d3d8b..5e83d59 100644 --- a/internal/modules/stock/format.go +++ b/internal/modules/stock/format.go @@ -1,7 +1,5 @@ -// Package stock is a paper-stock module for VN stocks. Per-user -// portfolio + buy/sell at market price + stats with P&L. SQL-based trade -// history and a retention cron are out of scope today; the current -// implementation keeps only the live portfolio in KV. +// Package stock is a paper-stock module for VN stocks. It keeps a per-user +// live portfolio in KV and prices positions at the current market price. package stock import ( diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go index c2f44f1..08c4468 100644 --- a/internal/modules/stock/handlers.go +++ b/internal/modules/stock/handlers.go @@ -19,11 +19,10 @@ import ( // prefixes/partitions). PriceClient is reused across calls; nowFn allows // tests to inject a deterministic clock for portfolio CreatedAt. type state struct { - store Store - prices *PriceClient - locks keylock.Map - nowFn func() time.Time - comingSoonMessage string // exposed for tests / future i18n + store Store + prices *PriceClient + locks keylock.Map + nowFn func() time.Time } func (s *state) now() time.Time { @@ -36,9 +35,8 @@ func (s *state) now() time.Time { // newState builds the default state used by the module factory. func newState(store Store) *state { return &state{ - store: store, - prices: &PriceClient{}, - comingSoonMessage: "Crypto, gold & currency exchange coming soon!", + store: store, + prices: &PriceClient{}, } } @@ -69,6 +67,31 @@ func argsAfterCommand(text string) []string { return parts[1:] } +func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error { + args := argsAfterCommand(update.Message.Text) + if len(args) != 1 { + return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_price \nExample: /stock_price TCB") + } + symbol, err := normalizeStockSymbol(args[0]) + if err != nil { + if errors.Is(err, ErrUnknownTicker) { + return chathelper.Reply(ctx, b, update.Message, "Unknown stock ticker \""+strings.ToUpper(args[0])+"\".") + } + return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.") + } + fetchCtx, cancel := chathelper.FetchContext(ctx) + defer cancel() + price, err := s.prices.FetchPrice(fetchCtx, symbol) + if err != nil { + if errors.Is(err, ErrNoPrice) { + return chathelper.Reply(ctx, b, update.Message, "No price available for "+symbol+".") + } + log.Error("stock_fetch_price", "ticker", symbol, "err", err) + return chathelper.Reply(ctx, b, update.Message, "Could not fetch price. Try again later.") + } + return chathelper.Reply(ctx, b, update.Message, symbol+" price: "+FormatVND(price)) +} + func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Update) error { userID, ok := senderInfo(update) if !ok { @@ -120,7 +143,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update if err != nil { if errors.Is(err, ErrUnknownTicker) { return chathelper.Reply(ctx, b, update.Message, - "Unknown stock ticker \""+strings.ToUpper(args[1])+"\".\n"+s.comingSoonMessage) + "Unknown stock ticker \""+strings.ToUpper(args[1])+"\".") } return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.") } @@ -217,7 +240,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat "\nRemaining: "+FormatVND(p.Currency["VND"])) } -func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (s *state) handleBonus(ctx context.Context, b *bot.Bot, update *models.Update) error { userID, ok := senderInfo(update) if !ok { return chathelper.Reply(ctx, b, update.Message, @@ -226,7 +249,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model args := argsAfterCommand(update.Message.Text) if len(args) < 2 { return chathelper.Reply(ctx, b, update.Message, - "Usage: /stock_income_stock \nExample: /stock_income_stock 200 TCX") + "Usage: /stock_bonus \nExample: /stock_bonus 200 TCX") } qty, err := strconv.ParseInt(args[0], 10, 64) if err != nil || qty <= 0 { @@ -252,7 +275,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model held := p.Assets[symbol] if held == 0 { return chathelper.Reply(ctx, b, update.Message, - "You don't hold any "+symbol+" to receive a stock dividend.") + "You don't hold any "+symbol+" to receive bonus shares.") } p.AddAsset(symbol, qty) if err := SavePortfolio(ctx, s.store, userID, p); err != nil { @@ -260,11 +283,11 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.") } return chathelper.Reply(ctx, b, update.Message, - "Stock dividend: +"+FormatStock(float64(qty))+" "+symbol+ + "Bonus shares: +"+FormatStock(float64(qty))+" "+symbol+ "\nHolding: "+FormatStock(float64(held))+" → "+FormatStock(float64(p.Assets[symbol]))) } -func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.Update) error { +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, @@ -273,7 +296,7 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models. args := argsAfterCommand(update.Message.Text) if len(args) < 2 { return chathelper.Reply(ctx, b, update.Message, - "Usage: /stock_income_vnd \nExample: /stock_income_vnd 1500 TCX") + "Usage: /stock_dividend \nExample: /stock_dividend 1500 TCX") } amountPerShare, err := strconv.ParseFloat(args[0], 64) if err != nil || amountPerShare <= 0 { @@ -313,21 +336,13 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models. "\nRemaining: "+FormatVND(p.Currency["VND"])) } -func (s *state) handleConvert(ctx context.Context, b *bot.Bot, update *models.Update) error { - if update.Message == nil { - return nil - } - return chathelper.Reply(ctx, b, update.Message, - "Currency exchange is not available yet.\n"+s.comingSoonMessage) -} - // handleStats fetches current prices for held tickers and renders the // portfolio. Read-only; no portfolio mutation, so no keylock. func (s *state) handleStats(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_stats needs a sender.") + "Cannot identify user — /stock_portfolio needs a sender.") } p, err := LoadPortfolio(ctx, s.store, userID, s.now().UnixMilli()) if err != nil { diff --git a/internal/modules/stock/handlers_test.go b/internal/modules/stock/handlers_test.go new file mode 100644 index 0000000..3b9b3a1 --- /dev/null +++ b/internal/modules/stock/handlers_test.go @@ -0,0 +1,76 @@ +package stock + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +func TestModuleRegistersExpectedCommands(t *testing.T) { + mod := New(modDepsForTest()) + got := map[string]bool{} + for _, cmd := range mod.Commands { + got[cmd.Name] = true + } + for _, name := range []string{ + "stock_price", + "stock_topup", + "stock_buy", + "stock_sell", + "stock_bonus", + "stock_dividend", + "stock_portfolio", + } { + if !got[name] { + t.Fatalf("missing command %s", name) + } + } +} + +func TestHandlePrice(t *testing.T) { + priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/stock/TCB" { + t.Errorf("path = %q, want /stock/TCB", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"matchedPrice":30000}}`)) + })) + t.Cleanup(priceSrv.Close) + + s := &state{ + store: newStockStore(), + prices: &PriceClient{HTTP: priceSrv.Client(), URL: priceSrv.URL}, + nowFn: func() time.Time { return time.UnixMilli(123) }, + } + rb := testutil.NewRecordingBot(t) + if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_price tcb")); err != nil { + t.Fatalf("handlePrice: %v", err) + } + + if got := rb.LastSent().Text(); !strings.Contains(got, "TCB price: 30.000 VND") { + t.Fatalf("price reply = %q", got) + } +} + +func TestHandlePriceUsage(t *testing.T) { + s := &state{prices: &PriceClient{}} + rb := testutil.NewRecordingBot(t) + if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_price")); err != nil { + t.Fatalf("handlePrice: %v", err) + } + rb.AssertSentText(t, "Usage: /stock_price ") +} + +func modDepsForTest() modules.Deps { + return modules.Deps{Store: storage.NewMemoryProvider().Collection("stock")} +} diff --git a/internal/modules/stock/stats_test.go b/internal/modules/stock/stats_test.go index 16fcf1a..92ae8a6 100644 --- a/internal/modules/stock/stats_test.go +++ b/internal/modules/stock/stats_test.go @@ -53,7 +53,7 @@ func TestHandleStats_UsesSSIBatchPrices(t *testing.T) { nowFn: func() time.Time { return now }, } rb := testutil.NewRecordingBot(t) - if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_stats")); err != nil { + if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil { t.Fatalf("handleStats: %v", err) } diff --git a/internal/modules/stock/stock.go b/internal/modules/stock/stock.go index 24c1340..b43c69e 100644 --- a/internal/modules/stock/stock.go +++ b/internal/modules/stock/stock.go @@ -5,13 +5,17 @@ import ( "github.com/tiennm99/miti99bot/internal/storage" ) -// New is the stock module Factory. Five user-facing commands; no crons. -// (Original miti99bot only has a SQL retention cron, which our KV-only port -// does not implement — keeping commits paper-ledger-only is acceptable.) +// New is the stock module Factory. Seven user-facing commands. func New(deps modules.Deps) modules.Module { s := newState(storage.Typed[Portfolio](deps.Store)) return modules.Module{ Commands: []modules.Command{ + { + Name: "stock_price", + Visibility: modules.VisibilityPublic, + Description: "Show current VN stock price", + Handler: s.handlePrice, + }, { Name: "stock_topup", Visibility: modules.VisibilityPublic, @@ -31,27 +35,21 @@ func New(deps modules.Deps) modules.Module { Handler: s.handleSell, }, { - Name: "stock_income_stock", + Name: "stock_bonus", Visibility: modules.VisibilityPublic, - Description: "Record stock dividend (bonus shares)", - Handler: s.handleIncomeStock, + Description: "Record bonus shares", + Handler: s.handleBonus, }, { - Name: "stock_income_vnd", + Name: "stock_dividend", Visibility: modules.VisibilityPublic, Description: "Record cash dividend (VND per share)", - Handler: s.handleIncomeVND, + Handler: s.handleDividend, }, { - Name: "stock_convert", + Name: "stock_portfolio", Visibility: modules.VisibilityPublic, - Description: "Currency exchange (coming soon)", - Handler: s.handleConvert, - }, - { - Name: "stock_stats", - Visibility: modules.VisibilityPublic, - Description: "Show portfolio summary with P&L", + Description: "Show stock portfolio with P&L", Handler: s.handleStats, }, }, diff --git a/telegram-commands.json b/telegram-commands.json index 642c180..8a8717a 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -72,6 +72,10 @@ "command": "wc_unsubscribe", "description": "Stop the daily World Cup schedule digest" }, + { + "command": "stock_price", + "description": "Show current VN stock price" + }, { "command": "stock_topup", "description": "Top up VND to your stock account" @@ -85,24 +89,20 @@ "description": "Sell VN stock back to VND" }, { - "command": "stock_income_stock", - "description": "Record stock dividend bonus shares" + "command": "stock_bonus", + "description": "Record bonus shares" }, { - "command": "stock_income_vnd", + "command": "stock_dividend", "description": "Record cash dividend per share" }, { - "command": "stock_convert", - "description": "Currency exchange" - }, - { - "command": "stock_stats", - "description": "Show portfolio summary with P&L" + "command": "stock_portfolio", + "description": "Show stock portfolio with P&L" }, { "command": "gold_price", - "description": "Show current gold spot price (USD & VND)" + "description": "Show current SJC gold buy/sell price" }, { "command": "gold_topup", @@ -117,8 +117,8 @@ "description": "Sell gold back to VND (luong)" }, { - "command": "gold_stats", - "description": "Show gold account summary with P&L" + "command": "gold_portfolio", + "description": "Show gold portfolio with P&L" }, { "command": "coin_price", @@ -134,11 +134,11 @@ }, { "command": "coin_sell", - "description": "Sell coin back to USD amount" + "description": "Sell coin for a USD amount" }, { - "command": "coin_stats", - "description": "Show coin account summary with P&L" + "command": "coin_portfolio", + "description": "Show coin portfolio with P&L" }, { "command": "stats",