mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-03 02:20:16 +00:00
feat(stock): add ratio dividend commands
This commit is contained in:
@@ -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{{
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <qty> <TICKER>\nExample: /stock_bonus 200 TCB")
|
||||
"Usage: /stock_cash_dividend <vnd_per_share> <TICKER>\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 <owned:new> <TICKER>\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 <amount_per_share> <TICKER>\nExample: /stock_dividend 1500 TCB")
|
||||
"Usage: /stock_dividend <vnd_per_share> <owned:new> <TICKER>\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
|
||||
|
||||
@@ -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 <qty> <TICKER>",
|
||||
},
|
||||
{
|
||||
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 <qty> <TICKER>",
|
||||
want: "Usage: /stock_cash_dividend <vnd_per_share> <TICKER>",
|
||||
},
|
||||
{
|
||||
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 <owned:new> <TICKER>",
|
||||
},
|
||||
{
|
||||
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 <amount_per_share> <TICKER>",
|
||||
want: "Usage: /stock_dividend <vnd_per_share> <owned:new> <TICKER>",
|
||||
},
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user