mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-04 08:19:46 +00:00
feat(stock): persist per-user dividend history
This commit is contained in:
@@ -32,6 +32,19 @@ func removeDividendButton(ctx context.Context, b *bot.Bot, chatID int64, message
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *state) invalidateDividendAction(ctx context.Context, b *bot.Bot, actionKey string, action PendingDividendAction) (error, error) {
|
||||
var deleteErr error
|
||||
if s.pending != nil {
|
||||
deleteErr = s.pending.Delete(ctx, actionKey)
|
||||
}
|
||||
return deleteErr, removeDividendButton(ctx, b, action.ChatID, action.MessageID)
|
||||
}
|
||||
|
||||
func (s *state) rejectDividendAction(ctx context.Context, b *bot.Bot, queryID, actionKey string, action PendingDividendAction, text string) error {
|
||||
_, _ = s.invalidateDividendAction(ctx, b, actionKey, action)
|
||||
return answerDividendCallback(ctx, b, queryID, text, true)
|
||||
}
|
||||
|
||||
func (s *state) handleDividendCallback(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
if update == nil || update.CallbackQuery == nil {
|
||||
return nil
|
||||
@@ -73,9 +86,7 @@ func (s *state) resolveDividendCallback(ctx context.Context, b *bot.Bot, query *
|
||||
}
|
||||
now := s.now().UnixMilli()
|
||||
if action.ExpiresAt <= now {
|
||||
_ = s.pending.Delete(ctx, actionKey)
|
||||
_ = removeDividendButton(ctx, b, action.ChatID, action.MessageID)
|
||||
err = answerDividendCallback(ctx, b, query.ID, "This suggestion expired. Run /stock_portfolio again.", true)
|
||||
err = s.rejectDividendAction(ctx, b, query.ID, actionKey, action, "This suggestion expired. Run /stock_portfolio again.")
|
||||
return PendingDividendAction{}, nil, "", true, err
|
||||
}
|
||||
return action, msg, actionKey, false, nil
|
||||
@@ -97,45 +108,47 @@ func (s *state) consumeDividendAction(ctx context.Context, b *bot.Bot, query *mo
|
||||
log.Error("stock_load_portfolio", "user", action.OwnerUserID, "err", err)
|
||||
return answerDividendCallback(ctx, b, query.ID, "Could not load your portfolio. Try again.", true)
|
||||
}
|
||||
eventKey := dividendLedgerKey(action.ProviderEventID)
|
||||
if _, applied := p.AppliedDividendEvents[eventKey]; applied {
|
||||
_ = s.pending.Delete(ctx, actionKey)
|
||||
_ = removeDividendButton(ctx, b, action.ChatID, action.MessageID)
|
||||
return answerDividendCallback(ctx, b, query.ID, "This dividend was already applied.", true)
|
||||
record, exists := p.dividendRecord(action.Symbol, action.ProviderEventID)
|
||||
if !exists {
|
||||
return s.rejectDividendAction(ctx, b, query.ID, actionKey, action, "This dividend is no longer available.")
|
||||
}
|
||||
if record.Processed {
|
||||
return s.rejectDividendAction(ctx, b, query.ID, actionKey, action, "This dividend was already applied.")
|
||||
}
|
||||
if !dividendRecordDue(record, s.now()) {
|
||||
return s.rejectDividendAction(ctx, b, query.ID, actionKey, action, "This dividend is not available before Record date.")
|
||||
}
|
||||
position, held := p.Assets[action.Symbol]
|
||||
if !held || position.Quantity <= 0 {
|
||||
_ = s.pending.Delete(ctx, actionKey)
|
||||
_ = removeDividendButton(ctx, b, action.ChatID, action.MessageID)
|
||||
return answerDividendCallback(ctx, b, query.ID, "You no longer hold "+action.Symbol+". Dividend not applied.", true)
|
||||
return s.rejectDividendAction(ctx, b, query.ID, actionKey, action, "You no longer hold "+action.Symbol+". Dividend not applied.")
|
||||
}
|
||||
if position.OpenedAt != action.PositionOpenedAt {
|
||||
_ = s.pending.Delete(ctx, actionKey)
|
||||
_ = removeDividendButton(ctx, b, action.ChatID, action.MessageID)
|
||||
return answerDividendCallback(ctx, b, query.ID, "This "+action.Symbol+" position was closed after the suggestion. Run /stock_portfolio again.", true)
|
||||
return s.rejectDividendAction(ctx, b, query.ID, actionKey, action, "This "+action.Symbol+" position was closed after the suggestion. Run /stock_portfolio again.")
|
||||
}
|
||||
if !positionOpenedByRecordDate(position, record) {
|
||||
return s.rejectDividendAction(ctx, b, query.ID, actionKey, action, "This "+action.Symbol+" position was opened after Record date. Dividend not applied.")
|
||||
}
|
||||
|
||||
result, err := applySuggestedDividend(&p, action, position.Quantity, now)
|
||||
result, err := applySuggestedDividend(&p, action.Symbol, record, position.Quantity, now)
|
||||
if err != nil {
|
||||
log.Error("stock_apply_suggested_dividend", "user", action.OwnerUserID, "ticker", action.Symbol, "event", action.ProviderEventID, "err", err)
|
||||
return answerDividendCallback(ctx, b, query.ID, "Could not safely apply this dividend.", true)
|
||||
}
|
||||
if p.AppliedDividendEvents == nil {
|
||||
p.AppliedDividendEvents = map[string]int64{}
|
||||
}
|
||||
p.AppliedDividendEvents[eventKey] = now
|
||||
record.Processed = true
|
||||
p.setDividendRecord(action.Symbol, action.ProviderEventID, record)
|
||||
if err := SavePortfolio(ctx, s.store, action.OwnerUserID, p); err != nil {
|
||||
log.Error("stock_save_portfolio", "user", action.OwnerUserID, "err", err)
|
||||
return answerDividendCallback(ctx, b, query.ID, "Could not save your portfolio. Try again.", true)
|
||||
}
|
||||
|
||||
// The portfolio ledger is the source of truth for idempotency. Cleanup and
|
||||
// The portfolio history is the source of truth for idempotency. Cleanup and
|
||||
// Telegram UI updates are best-effort after the atomic portfolio save.
|
||||
if err := s.pending.Delete(ctx, actionKey); err != nil {
|
||||
log.Error("stock_delete_dividend_action", "err", err)
|
||||
deleteErr, removeErr := s.invalidateDividendAction(ctx, b, actionKey, action)
|
||||
if deleteErr != nil {
|
||||
log.Error("stock_delete_dividend_action", "err", deleteErr)
|
||||
}
|
||||
if err := removeDividendButton(ctx, b, action.ChatID, action.MessageID); err != nil {
|
||||
log.Error("stock_remove_dividend_button", "user", action.OwnerUserID, "ticker", action.Symbol, "err", err)
|
||||
if removeErr != nil {
|
||||
log.Error("stock_remove_dividend_button", "user", action.OwnerUserID, "ticker", action.Symbol, "err", removeErr)
|
||||
}
|
||||
if err := answerDividendCallback(ctx, b, query.ID, "Dividend applied.", false); err != nil {
|
||||
log.Error("stock_answer_dividend_callback", "user", action.OwnerUserID, "ticker", action.Symbol, "err", err)
|
||||
@@ -143,11 +156,10 @@ func (s *state) consumeDividendAction(ctx context.Context, b *bot.Bot, query *mo
|
||||
return chathelper.Reply(ctx, b, msg, result)
|
||||
}
|
||||
|
||||
func applySuggestedDividend(p *Portfolio, action PendingDividendAction, held, now int64) (string, error) {
|
||||
preservedCursor := max(p.Assets[action.Symbol].DividendCheckedAt, action.CheckThrough)
|
||||
switch action.Kind {
|
||||
func applySuggestedDividend(p *Portfolio, symbol string, record DividendRecord, held, now int64) (string, error) {
|
||||
switch record.Kind {
|
||||
case DividendKindCash:
|
||||
total, err := cashDividendTotal(held, action.VNDPerShare)
|
||||
total, err := cashDividendTotal(held, record.VNDPerShare)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -155,17 +167,16 @@ func applySuggestedDividend(p *Portfolio, action PendingDividendAction, held, no
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := p.ApplyDividend(action.Symbol, held, balance, now); err != nil {
|
||||
if err := p.ApplyDividend(symbol, held, balance, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
preserveDividendCursor(p, action.Symbol, preservedCursor)
|
||||
return "Applied cash dividend for " + action.Symbol + ": " + FormatVND(float64(action.VNDPerShare)) +
|
||||
return "Applied cash dividend for " + symbol + ": " + FormatVND(float64(record.VNDPerShare)) +
|
||||
" × " + formatShareQuantity(held) + " = " + FormatVND(float64(total)) +
|
||||
"\nBalance: " + FormatVND(balance), nil
|
||||
|
||||
case DividendKindShares:
|
||||
ratio := shareRatio{owned: action.OwnedShares, new: action.NewShares,
|
||||
raw: strconv.FormatInt(action.OwnedShares, 10) + ":" + strconv.FormatInt(action.NewShares, 10)}
|
||||
ratio := shareRatio{owned: record.OwnedShares, new: record.NewShares,
|
||||
raw: strconv.FormatInt(record.OwnedShares, 10) + ":" + strconv.FormatInt(record.NewShares, 10)}
|
||||
newShares, err := shareDividendEntitlement(held, ratio)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -177,20 +188,13 @@ func applySuggestedDividend(p *Portfolio, action PendingDividendAction, held, no
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := p.ApplyDividend(action.Symbol, finalHolding, p.VND, now); err != nil {
|
||||
if err := p.ApplyDividend(symbol, finalHolding, p.VND, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
preserveDividendCursor(p, action.Symbol, preservedCursor)
|
||||
return "Applied share dividend for " + action.Symbol + " (" + ratio.raw + "): +" +
|
||||
return "Applied share dividend for " + symbol + " (" + ratio.raw + "): +" +
|
||||
formatShareQuantity(newShares) + "\nHolding: " + formatShareQuantity(held) + " → " +
|
||||
formatShareQuantity(finalHolding), nil
|
||||
default:
|
||||
return "", errors.New("unsupported dividend kind")
|
||||
}
|
||||
}
|
||||
|
||||
func preserveDividendCursor(p *Portfolio, symbol string, cursor int64) {
|
||||
position := p.Assets[symbol]
|
||||
position.DividendCheckedAt = cursor
|
||||
p.Assets[symbol] = position
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package stock
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -15,72 +12,36 @@ import (
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
applog "github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
type dividendProviderCall struct {
|
||||
symbol string
|
||||
after, through time.Time
|
||||
}
|
||||
|
||||
type fakeDividendProvider struct {
|
||||
mu sync.Mutex
|
||||
events []DividendEvent
|
||||
err error
|
||||
calls int
|
||||
calls []dividendProviderCall
|
||||
}
|
||||
|
||||
type blockingDividendProvider struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
events []DividendEvent
|
||||
}
|
||||
|
||||
func (p *blockingDividendProvider) FetchDividendEvents(_ context.Context, _ string, _, _ time.Time) ([]DividendEvent, error) {
|
||||
close(p.started)
|
||||
<-p.release
|
||||
return append([]DividendEvent(nil), p.events...), nil
|
||||
}
|
||||
|
||||
func (f *fakeDividendProvider) FetchDividendEvents(_ context.Context, _ string, _, _ time.Time) ([]DividendEvent, error) {
|
||||
f.calls++
|
||||
func (f *fakeDividendProvider) FetchDividendEvents(_ context.Context, symbol string, after, through time.Time) ([]DividendEvent, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, dividendProviderCall{symbol: symbol, after: after, through: through})
|
||||
return append([]DividendEvent(nil), f.events...), f.err
|
||||
}
|
||||
|
||||
func TestPortfolioDividendCheckLogsNoEvents(t *testing.T) {
|
||||
s, store, _, rb, now := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
p, err := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
previous := applog.Default()
|
||||
applog.SetDefault(slog.New(slog.NewJSONHandler(&output, nil)))
|
||||
t.Cleanup(func() { applog.SetDefault(previous) })
|
||||
|
||||
if err := s.notifyDividendEvents(
|
||||
context.Background(),
|
||||
rb.Bot,
|
||||
testutil.NewPrivateMessage(7, "/stock_portfolio").Message,
|
||||
7,
|
||||
p,
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &record); err != nil {
|
||||
t.Fatalf("decode dividend check log %q: %v", output.String(), err)
|
||||
}
|
||||
if record["msg"] != "stock_dividend_events_checked" ||
|
||||
record["user"] != float64(7) ||
|
||||
record["ticker"] != "TCB" ||
|
||||
record["events"] != float64(0) ||
|
||||
record["status"] != "success" {
|
||||
t.Fatalf("unexpected dividend check log: %#v", record)
|
||||
}
|
||||
func (f *fakeDividendProvider) snapshotCalls() []dividendProviderCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]dividendProviderCall(nil), f.calls...)
|
||||
}
|
||||
|
||||
func newDividendFlowState(t *testing.T, events []DividendEvent) (*state, Store, PendingDividendStore, *testutil.RecordingBot, time.Time) {
|
||||
func newDividendFlowState(t *testing.T, events []DividendEvent) (*state, Store, PendingDividendStore, *testutil.RecordingBot, time.Time, *fakeDividendProvider) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
priceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -93,18 +54,19 @@ func newDividendFlowState(t *testing.T, events []DividendEvent) (*state, Store,
|
||||
collection := provider.Collection(CollectionName)
|
||||
store := storage.Typed[Portfolio](collection)
|
||||
pending := storage.Typed[PendingDividendAction](collection)
|
||||
fake := &fakeDividendProvider{events: events}
|
||||
tokenIndex := 0
|
||||
s := &state{
|
||||
store: store,
|
||||
pending: pending,
|
||||
store: store, pending: pending,
|
||||
prices: &PriceClient{HTTP: priceServer.Client(), URL: priceServer.URL},
|
||||
dividends: &fakeDividendProvider{events: events},
|
||||
nowFn: func() time.Time { return now },
|
||||
dividends: fake, nowFn: func() time.Time { return now },
|
||||
newDividendToken: func() (string, error) {
|
||||
return "abcdefghijklmnopqrstuv", nil
|
||||
token := "abcdefghijklmnopqrstu" + string(rune('v'+tokenIndex))
|
||||
tokenIndex++
|
||||
return token, nil
|
||||
},
|
||||
}
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
return s, store, pending, rb, now
|
||||
return s, store, pending, testutil.NewRecordingBot(t), now, fake
|
||||
}
|
||||
|
||||
func seedDividendFlowPortfolio(t *testing.T, store Store, now time.Time, quantity int64) {
|
||||
@@ -120,242 +82,365 @@ func seedDividendFlowPortfolio(t *testing.T, store Store, now time.Time, quantit
|
||||
}
|
||||
}
|
||||
|
||||
func TestStockPortfolio_NoDividendEventsSendsOnlyPortfolio(t *testing.T) {
|
||||
s, store, _, rb, now := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := rb.Sent()
|
||||
if len(calls) != 1 || !strings.Contains(calls[0].Text(), "Stock Portfolio") {
|
||||
t.Fatalf("calls = %#v, want only portfolio message", calls)
|
||||
}
|
||||
p, err := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := p.Assets["TCB"].DividendCheckedAt; got != now.UnixMilli() {
|
||||
t.Fatalf("dividend cursor = %d, want %d", got, now.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStockPortfolio_DividendEventFollowsPortfolioWithOpaqueButton(t *testing.T) {
|
||||
event := DividendEvent{
|
||||
func cashDividendEvent(now, recordDate time.Time) DividendEvent {
|
||||
return DividendEvent{
|
||||
ProviderID: "2612974", Symbol: "TCB", Kind: DividendKindCash,
|
||||
PublishedAt: time.Date(2026, 6, 24, 8, 0, 0, 0, saigonLocation),
|
||||
ExDate: time.Date(2026, 6, 27, 0, 0, 0, 0, saigonLocation),
|
||||
PublishedAt: now.Add(-24 * time.Hour), RecordDate: recordDate,
|
||||
ExDate: now.AddDate(0, 0, 1), PaymentDate: now.AddDate(0, 0, 10),
|
||||
VNDPerShare: 1500, Title: "Cash dividend",
|
||||
}
|
||||
s, store, pending, rb, now := newDividendFlowState(t, []DividendEvent{event})
|
||||
}
|
||||
|
||||
func TestStockPortfolioNoEventsUsesExactRecentWindow(t *testing.T) {
|
||||
s, store, _, rb, now, provider := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls := rb.Sent(); len(calls) != 1 || !strings.Contains(calls[0].Text(), "Stock Portfolio") {
|
||||
t.Fatalf("messages = %#v", calls)
|
||||
}
|
||||
providerCalls := provider.snapshotCalls()
|
||||
if len(providerCalls) != 1 || !providerCalls[0].after.Equal(now.Add(-30*24*time.Hour)) || !providerCalls[0].through.Equal(now) {
|
||||
t.Fatalf("provider calls = %+v", providerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFutureDividendNotifiesOnEveryPortfolioWithoutButton(t *testing.T) {
|
||||
event := cashDividendEvent(time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation), time.Date(2026, 6, 27, 0, 0, 0, 0, saigonLocation))
|
||||
s, store, pending, rb, now, _ := newDividendFlowState(t, []DividendEvent{event})
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
|
||||
for range 2 {
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
calls := rb.Sent()
|
||||
if len(calls) != 4 || !strings.Contains(calls[1].Text(), "Upcoming dividend event") || !strings.Contains(calls[3].Text(), "Upcoming dividend event") || calls[1].Form["reply_markup"] != "" || calls[3].Form["reply_markup"] != "" {
|
||||
t.Fatalf("messages = %#v", calls)
|
||||
}
|
||||
keys, err := pending.List(context.Background(), pendingDividendPrefix)
|
||||
if err != nil || len(keys) != 0 {
|
||||
t.Fatalf("pending = %v, err=%v", keys, err)
|
||||
}
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
record := p.Dividends["TCB"][event.ProviderID]
|
||||
if record.Processed || record.VNDPerShare != 1500 {
|
||||
t.Fatalf("stored event = %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingRecordDateRemainsInformational(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
event := cashDividendEvent(now, time.Time{})
|
||||
s, store, pending, rb, _, _ := newDividendFlowState(t, []DividendEvent{event})
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := rb.Sent()
|
||||
if len(calls) != 2 || !strings.Contains(calls[0].Text(), "Stock Portfolio") || !strings.Contains(calls[1].Text(), "Recent dividend event") {
|
||||
t.Fatalf("unexpected message order: %#v", calls)
|
||||
}
|
||||
markup := calls[1].Form["reply_markup"]
|
||||
if !strings.Contains(markup, dividendCallbackPrefix+"abcdefghijklmnopqrstuv") || strings.Contains(markup, "1500") || strings.Contains(markup, "2612974") {
|
||||
t.Fatalf("callback markup is not opaque: %q", markup)
|
||||
if len(calls) != 2 || !strings.Contains(calls[1].Text(), "awaiting SSI update") || calls[1].Form["reply_markup"] != "" {
|
||||
t.Fatalf("messages = %#v", calls)
|
||||
}
|
||||
keys, err := pending.List(context.Background(), pendingDividendPrefix)
|
||||
if err != nil || len(keys) != 1 {
|
||||
t.Fatalf("pending keys = %v, err=%v", keys, err)
|
||||
}
|
||||
action, _, err := pending.Get(context.Background(), keys[0])
|
||||
if err != nil || action.OwnerUserID != 7 || action.ChatID != 7 || action.MessageID != 1 {
|
||||
t.Fatalf("pending action = %+v, err=%v", action, err)
|
||||
if err != nil || len(keys) != 0 {
|
||||
t.Fatalf("pending = %v, err=%v", keys, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividendCallback_OnlyOwnerCanApplyAndUsesCurrentHolding(t *testing.T) {
|
||||
s, store, pending, rb, now := newDividendFlowState(t, nil)
|
||||
func TestMissingRecordDateIsRefetchedFromOriginalPublicationDay(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
oldPublished := now.AddDate(0, 0, -45)
|
||||
event := cashDividendEvent(now, startOfSaigonDay(now))
|
||||
event.PublishedAt = oldPublished
|
||||
s, store, pending, rb, _, provider := newDividendFlowState(t, []DividendEvent{event})
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
action := PendingDividendAction{
|
||||
OwnerUserID: 7, ChatID: -100, MessageID: 99, ProviderEventID: "2612974",
|
||||
Symbol: "TCB", Kind: DividendKindCash, VNDPerShare: 1500,
|
||||
ObservedHolding: 50, PositionOpenedAt: now.Add(-48 * time.Hour).UnixMilli(), CheckThrough: now.UnixMilli(), CreatedAt: now.UnixMilli(), ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
}
|
||||
key := pendingDividendKey("abcdefghijklmnopqrstuv")
|
||||
if err := pending.Put(context.Background(), key, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
callback := func(userID int64) *models.Update {
|
||||
return &models.Update{CallbackQuery: &models.CallbackQuery{
|
||||
ID: "query", From: models.User{ID: userID}, Data: dividendCallbackPrefix + "abcdefghijklmnopqrstuv",
|
||||
Message: models.MaybeInaccessibleMessage{Type: models.MaybeInaccessibleMessageTypeMessage, Message: &models.Message{
|
||||
ID: 99, Chat: models.Chat{ID: -100, Type: models.ChatTypeGroup}, MessageThreadID: 3,
|
||||
}},
|
||||
}}
|
||||
}
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, callback(8)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if err != nil || p.VND != 100_000 {
|
||||
t.Fatalf("unauthorized click changed portfolio: %+v, err=%v", p, err)
|
||||
}
|
||||
if _, _, err := pending.Get(context.Background(), key); err != nil {
|
||||
t.Fatalf("unauthorized click consumed action: %v", err)
|
||||
}
|
||||
|
||||
s.nowFn = func() time.Time { return now.Add(30 * time.Minute) }
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, callback(7)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.VND != 250_000 || p.Assets["TCB"].Quantity != 100 || p.Assets["TCB"].DividendCheckedAt != now.UnixMilli() || len(p.AppliedDividendEvents) != 1 {
|
||||
t.Fatalf("applied portfolio = %+v", p)
|
||||
}
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, callback(7)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.VND != 250_000 {
|
||||
t.Fatalf("repeated click credited twice: %v", p.VND)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividendCallback_AppliesShareDividendOnce(t *testing.T) {
|
||||
s, store, pending, rb, now := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 139)
|
||||
action := PendingDividendAction{
|
||||
OwnerUserID: 7, ChatID: 7, MessageID: 1, ProviderEventID: "2612975",
|
||||
Symbol: "TCB", Kind: DividendKindShares, OwnedShares: 100, NewShares: 10,
|
||||
ObservedHolding: 139, PositionOpenedAt: now.Add(-48 * time.Hour).UnixMilli(), CheckThrough: now.UnixMilli(), CreatedAt: now.UnixMilli(), ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := pending.Put(context.Background(), pendingDividendKey("abcdefghijklmnopqrstuv"), action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
update := &models.Update{CallbackQuery: &models.CallbackQuery{
|
||||
ID: "query", From: models.User{ID: 7}, Data: dividendCallbackPrefix + "abcdefghijklmnopqrstuv",
|
||||
Message: models.MaybeInaccessibleMessage{Type: models.MaybeInaccessibleMessageTypeMessage, Message: &models.Message{
|
||||
ID: 1, Chat: models.Chat{ID: 7, Type: models.ChatTypePrivate},
|
||||
}},
|
||||
}}
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, update); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Assets["TCB"].Quantity != 152 || p.Assets["TCB"].Base != 139*30_000 {
|
||||
t.Fatalf("share dividend result = %+v", p.Assets["TCB"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividendCallback_RejectsSoldAndReopenedPosition(t *testing.T) {
|
||||
s, store, pending, rb, now := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
action := PendingDividendAction{
|
||||
OwnerUserID: 7, ChatID: 7, MessageID: 1, ProviderEventID: "2612974",
|
||||
Symbol: "TCB", Kind: DividendKindCash, VNDPerShare: 1500,
|
||||
PositionOpenedAt: now.Add(-48 * time.Hour).UnixMilli(), CheckThrough: now.UnixMilli(), CreatedAt: now.UnixMilli(), ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
}
|
||||
key := pendingDividendKey("abcdefghijklmnopqrstuv")
|
||||
if err := pending.Put(context.Background(), key, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if _, _, ok, err := p.SellTicker("TCB", 100); err != nil || !ok {
|
||||
t.Fatalf("sell before reopen: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := p.BuyTicker("TCB", 50, 2_000_000, now.Add(time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatal(err)
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
event.ProviderID: {
|
||||
Kind: DividendKindCash, PublishedAt: oldPublished.UnixMilli(),
|
||||
VNDPerShare: 1000,
|
||||
},
|
||||
}
|
||||
if err := SavePortfolio(context.Background(), store, 7, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
update := dividendCallbackUpdate(7, 7, 1)
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, update); err != nil {
|
||||
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.VND != 100_000 || p.Assets["TCB"].Quantity != 50 {
|
||||
t.Fatalf("stale action changed reopened position: %+v", p)
|
||||
providerCalls := provider.snapshotCalls()
|
||||
foundHistoricalWindow := false
|
||||
for _, call := range providerCalls {
|
||||
if startOfSaigonDay(call.after).Equal(startOfSaigonDay(oldPublished)) {
|
||||
foundHistoricalWindow = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, _, err := pending.Get(context.Background(), key); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Fatalf("stale action was not invalidated: %v", err)
|
||||
if len(providerCalls) != 2 || !foundHistoricalWindow {
|
||||
t.Fatalf("provider calls = %+v", providerCalls)
|
||||
}
|
||||
keys, err := pending.List(context.Background(), pendingDividendPrefix)
|
||||
if err != nil || len(keys) != 1 {
|
||||
t.Fatalf("pending = %v, err=%v", keys, err)
|
||||
}
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.Dividends["TCB"][event.ProviderID].RecordDate == 0 || p.Dividends["TCB"][event.ProviderID].VNDPerShare != 1500 {
|
||||
t.Fatalf("refreshed event = %+v", p.Dividends["TCB"][event.ProviderID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividendCallback_ExpiredActionDoesNotApply(t *testing.T) {
|
||||
s, store, pending, rb, now := newDividendFlowState(t, nil)
|
||||
func TestExpiredIncompleteEventIsNotRestoredByHistoricalRefresh(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
oldPublished := now.AddDate(0, 0, -90)
|
||||
event := cashDividendEvent(now, time.Time{})
|
||||
event.PublishedAt = oldPublished
|
||||
s, store, _, rb, _, _ := newDividendFlowState(t, []DividendEvent{event})
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
action := PendingDividendAction{
|
||||
OwnerUserID: 7, ChatID: 7, MessageID: 1, ProviderEventID: "2612974",
|
||||
Symbol: "TCB", Kind: DividendKindCash, VNDPerShare: 1500,
|
||||
PositionOpenedAt: now.Add(-48 * time.Hour).UnixMilli(), CheckThrough: now.UnixMilli(), CreatedAt: now.Add(-2 * time.Hour).UnixMilli(), ExpiresAt: now.Add(-time.Hour).UnixMilli(),
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
event.ProviderID: {Kind: DividendKindCash, PublishedAt: oldPublished.UnixMilli(), VNDPerShare: 1500},
|
||||
}
|
||||
key := pendingDividendKey("abcdefghijklmnopqrstuv")
|
||||
if err := pending.Put(context.Background(), key, action); err != nil {
|
||||
if err := SavePortfolio(context.Background(), store, 7, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, dividendCallbackUpdate(7, 7, 1)); err != nil {
|
||||
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if _, exists := p.Dividends["TCB"]; exists {
|
||||
t.Fatalf("expired event was restored: %+v", p.Dividends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDateCreatesSeparateActionableMessages(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
cash := cashDividendEvent(now, startOfSaigonDay(now))
|
||||
shares := DividendEvent{
|
||||
ProviderID: "2612975", Symbol: "TCB", Kind: DividendKindShares,
|
||||
PublishedAt: now.Add(-12 * time.Hour), RecordDate: startOfSaigonDay(now),
|
||||
OwnedShares: 100, NewShares: 10, Title: "Share dividend",
|
||||
}
|
||||
s, store, pending, rb, _, _ := newDividendFlowState(t, []DividendEvent{cash, shares})
|
||||
seedDividendFlowPortfolio(t, store, now, 139)
|
||||
|
||||
for range 2 {
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
calls := rb.Sent()
|
||||
if len(calls) != 6 || !strings.Contains(calls[1].Text(), "Dividend ready to apply") || !strings.Contains(calls[2].Text(), "Dividend ready to apply") || !strings.Contains(calls[4].Text(), "Dividend ready to apply") || !strings.Contains(calls[5].Text(), "Dividend ready to apply") {
|
||||
t.Fatalf("messages = %#v", calls)
|
||||
}
|
||||
keys, err := pending.List(context.Background(), pendingDividendPrefix)
|
||||
if err != nil || len(keys) != 4 {
|
||||
t.Fatalf("pending = %v, err=%v", keys, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedFutureNoticeKeepsStoredHistoryForRetry(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
event := cashDividendEvent(now, now.AddDate(0, 0, 2))
|
||||
s, store, _, rb, _, _ := newDividendFlowState(t, []DividendEvent{event})
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
snapshot, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
rb.FailMethod("sendMessage", http.StatusInternalServerError, "")
|
||||
_ = s.notifyDividendEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio").Message, 7, snapshot, now)
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.Dividends["TCB"][event.ProviderID].VNDPerShare != 1500 {
|
||||
t.Fatal("failed future notice removed stored history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividendCallbackUsesStoredEventAndCurrentHoldingOnce(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
event := cashDividendEvent(now, startOfSaigonDay(now))
|
||||
s, store, pending, rb, _, _ := newDividendFlowState(t, []DividendEvent{event})
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
for range 2 {
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
keys, err := pending.List(context.Background(), pendingDividendPrefix)
|
||||
if err != nil || len(keys) != 2 {
|
||||
t.Fatalf("pending keys = %v, err=%v", keys, err)
|
||||
}
|
||||
key := keys[0]
|
||||
token := strings.TrimPrefix(key, pendingDividendPrefix)
|
||||
action, _, _ := pending.Get(context.Background(), key)
|
||||
if action.ProviderEventID != event.ProviderID || action.Symbol != "TCB" {
|
||||
t.Fatalf("pending action = %+v", action)
|
||||
}
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, dividendCallbackUpdate(8, 7, action.MessageID, token)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.VND != 100_000 {
|
||||
t.Fatalf("expired action changed balance: %v", p.VND)
|
||||
t.Fatalf("unauthorized click changed balance: %v", p.VND)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividendSuggestionFailureLeavesCursorUnchanged(t *testing.T) {
|
||||
event := DividendEvent{
|
||||
ProviderID: "2612974", Symbol: "TCB", Kind: DividendKindCash,
|
||||
PublishedAt: time.Date(2026, 6, 24, 8, 0, 0, 0, saigonLocation), VNDPerShare: 1500,
|
||||
}
|
||||
s, store, _, rb, now := newDividendFlowState(t, []DividendEvent{event})
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
before := p.Assets["TCB"].DividendCheckedAt
|
||||
rb.FailMethod("sendMessage", http.StatusInternalServerError, "")
|
||||
_ = s.notifyDividendEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio").Message, 7, p, now)
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if got := p.Assets["TCB"].DividendCheckedAt; got != before {
|
||||
t.Fatalf("failed delivery advanced cursor: got %d want %d", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceDividendCursorPreservesConcurrentTrade(t *testing.T) {
|
||||
s, store, _, _, now := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if err := p.BuyTicker("TCB", 25, 1_000_000, now.UnixMilli()); err != nil {
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, dividendCallbackUpdate(7, 7, action.MessageID, token)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.VND != 250_000 || !p.Dividends["TCB"][event.ProviderID].Processed {
|
||||
t.Fatalf("processed portfolio = %+v", p)
|
||||
}
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, dividendCallbackUpdate(7, 7, action.MessageID, token)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondAction, _, _ := pending.Get(context.Background(), keys[1])
|
||||
secondToken := strings.TrimPrefix(keys[1], pendingDividendPrefix)
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, dividendCallbackUpdate(7, 7, secondAction.MessageID, secondToken)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.VND != 250_000 {
|
||||
t.Fatalf("second valid button credited twice: %v", p.VND)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredDueEventStillNotifiesWhenSSIOmitsIt(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
s, store, pending, rb, _, _ := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"2612974": {
|
||||
Kind: DividendKindCash, PublishedAt: now.AddDate(0, 0, -45).UnixMilli(),
|
||||
RecordDate: startOfSaigonDay(now).UnixMilli(), VNDPerShare: 1500,
|
||||
},
|
||||
}
|
||||
if err := SavePortfolio(context.Background(), store, 7, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.advanceDividendCursors(context.Background(), 7, []string{"TCB"}, now.UnixMilli()); err != nil {
|
||||
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.Assets["TCB"].Quantity != 125 || p.Assets["TCB"].Base != 4_000_000 {
|
||||
t.Fatalf("cursor merge lost trade: %+v", p.Assets["TCB"])
|
||||
if calls := rb.Sent(); len(calls) != 2 || !strings.Contains(calls[1].Text(), "Dividend ready to apply") {
|
||||
t.Fatalf("messages = %#v", calls)
|
||||
}
|
||||
keys, err := pending.List(context.Background(), pendingDividendPrefix)
|
||||
if err != nil || len(keys) != 1 {
|
||||
t.Fatalf("pending = %v, err=%v", keys, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentDividendSuggestionsCreateOneButton(t *testing.T) {
|
||||
s, store, pending, rb, now := newDividendFlowState(t, nil)
|
||||
func TestStoredFutureEventStillNotifiesWhenSSIOmitsIt(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
s, store, pending, rb, _, _ := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
event := DividendEvent{ProviderID: "2612974", Symbol: "TCB", Kind: DividendKindCash, VNDPerShare: 1500}
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"2612974": {
|
||||
Kind: DividendKindCash, PublishedAt: now.Add(-24 * time.Hour).UnixMilli(),
|
||||
RecordDate: now.AddDate(0, 0, 5).UnixMilli(), VNDPerShare: 1500,
|
||||
},
|
||||
}
|
||||
if err := SavePortfolio(context.Background(), store, 7, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls := rb.Sent(); len(calls) != 2 || !strings.Contains(calls[1].Text(), "Upcoming dividend event") {
|
||||
t.Fatalf("messages = %#v", calls)
|
||||
}
|
||||
keys, err := pending.List(context.Background(), pendingDividendPrefix)
|
||||
if err != nil || len(keys) != 0 {
|
||||
t.Fatalf("pending = %v, err=%v", keys, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividendCallbackAppliesShareEventFromStoredHistory(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
s, store, pending, rb, _, _ := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 139)
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"2612975": {
|
||||
Kind: DividendKindShares, PublishedAt: now.Add(-24 * time.Hour).UnixMilli(),
|
||||
RecordDate: startOfSaigonDay(now).UnixMilli(), OwnedShares: 100, NewShares: 10,
|
||||
},
|
||||
}
|
||||
if err := SavePortfolio(context.Background(), store, 7, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
action := PendingDividendAction{
|
||||
OwnerUserID: 7, ChatID: 7, MessageID: 1, ProviderEventID: "2612975", Symbol: "TCB",
|
||||
PositionOpenedAt: p.Assets["TCB"].OpenedAt, CreatedAt: now.UnixMilli(), ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := pending.Put(context.Background(), pendingDividendKey("abcdefghijklmnopqrstuv"), action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, dividendCallbackUpdate(7, 7, 1, "abcdefghijklmnopqrstuv")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.Assets["TCB"].Quantity != 152 || p.Assets["TCB"].Base != 139*30_000 || !p.Dividends["TCB"]["2612975"].Processed {
|
||||
t.Fatalf("share dividend result = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredDividendActionDoesNotApply(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
s, store, pending, rb, _, _ := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"2612974": {Kind: DividendKindCash, PublishedAt: now.Add(-24 * time.Hour).UnixMilli(), RecordDate: startOfSaigonDay(now).UnixMilli(), VNDPerShare: 1500},
|
||||
}
|
||||
if err := SavePortfolio(context.Background(), store, 7, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
action := PendingDividendAction{
|
||||
OwnerUserID: 7, ChatID: 7, MessageID: 1, ProviderEventID: "2612974", Symbol: "TCB",
|
||||
PositionOpenedAt: p.Assets["TCB"].OpenedAt, CreatedAt: now.Add(-2 * time.Hour).UnixMilli(), ExpiresAt: now.Add(-time.Hour).UnixMilli(),
|
||||
}
|
||||
key := pendingDividendKey("abcdefghijklmnopqrstuv")
|
||||
if err := pending.Put(context.Background(), key, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, dividendCallbackUpdate(7, 7, 1, "abcdefghijklmnopqrstuv")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.VND != 100_000 || p.Dividends["TCB"]["2612974"].Processed {
|
||||
t.Fatalf("expired action changed portfolio: %+v", p)
|
||||
}
|
||||
if _, _, err := pending.Get(context.Background(), key); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Fatalf("expired action was not deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentPortfolioRequestsCreateSeparatePendingActions(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
s, store, pending, rb, _, _ := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"2612974": {Kind: DividendKindCash, PublishedAt: now.Add(-24 * time.Hour).UnixMilli(), RecordDate: startOfSaigonDay(now).UnixMilli(), VNDPerShare: 1500},
|
||||
}
|
||||
if err := SavePortfolio(context.Background(), store, 7, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := testutil.NewPrivateMessage(7, "/stock_portfolio").Message
|
||||
ref := dividendRef{symbol: "TCB", eventID: "2612974"}
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
for range 2 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- s.sendDividendSuggestion(context.Background(), rb.Bot, msg, 7, now.Add(-48*time.Hour).UnixMilli(), now, event)
|
||||
errs <- s.sendDividendSuggestion(context.Background(), rb.Bot, msg, 7, p.Assets["TCB"].OpenedAt, ref)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -365,54 +450,60 @@ func TestConcurrentDividendSuggestionsCreateOneButton(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if calls := rb.Sent(); len(calls) != 1 {
|
||||
t.Fatalf("sent %d duplicate suggestions: %#v", len(calls), calls)
|
||||
}
|
||||
keys, err := pending.List(context.Background(), pendingDividendPrefix)
|
||||
if err != nil || len(keys) != 1 {
|
||||
t.Fatalf("pending actions = %v, err=%v", keys, err)
|
||||
if err != nil || len(keys) != 2 || len(rb.Sent()) != 2 {
|
||||
t.Fatalf("pending=%v messages=%d err=%v", keys, len(rb.Sent()), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividendFetchDoesNotBindOldEventToReopenedPosition(t *testing.T) {
|
||||
s, store, pending, rb, now := newDividendFlowState(t, nil)
|
||||
func TestDividendCallbackRejectsPositionOpenedAfterRecordDate(t *testing.T) {
|
||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, saigonLocation)
|
||||
s, store, pending, rb, _, _ := newDividendFlowState(t, nil)
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
blocking := &blockingDividendProvider{
|
||||
started: make(chan struct{}), release: make(chan struct{}),
|
||||
events: []DividendEvent{{ProviderID: "2612974", Symbol: "TCB", Kind: DividendKindCash, VNDPerShare: 1500}},
|
||||
}
|
||||
s.dividends = blocking
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio"))
|
||||
}()
|
||||
<-blocking.started
|
||||
p, _ := LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if _, _, ok, err := p.SellTicker("TCB", 100); err != nil || !ok {
|
||||
t.Fatalf("sell during fetch: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := p.BuyTicker("TCB", 50, 2_000_000, now.Add(time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatal(err)
|
||||
recordDate := startOfSaigonDay(now.AddDate(0, 0, -1))
|
||||
position := p.Assets["TCB"]
|
||||
position.OpenedAt = now.UnixMilli()
|
||||
p.Assets["TCB"] = position
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"2612974": {Kind: DividendKindCash, PublishedAt: now.AddDate(0, 0, -2).UnixMilli(), RecordDate: recordDate.UnixMilli(), VNDPerShare: 1500},
|
||||
}
|
||||
if err := SavePortfolio(context.Background(), store, 7, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(blocking.release)
|
||||
if err := <-done; err != nil {
|
||||
action := PendingDividendAction{
|
||||
OwnerUserID: 7, ChatID: 7, MessageID: 1, ProviderEventID: "2612974", Symbol: "TCB",
|
||||
PositionOpenedAt: position.OpenedAt, CreatedAt: now.UnixMilli(), ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
}
|
||||
key := pendingDividendKey("abcdefghijklmnopqrstuv")
|
||||
if err := pending.Put(context.Background(), key, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls := rb.Sent(); len(calls) != 1 || !strings.Contains(calls[0].Text(), "Stock Portfolio") {
|
||||
t.Fatalf("old lifecycle event was sent: %#v", calls)
|
||||
if err := s.handleDividendCallback(context.Background(), rb.Bot, dividendCallbackUpdate(7, 7, 1, "abcdefghijklmnopqrstuv")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, err := pending.List(context.Background(), pendingDividendPrefix)
|
||||
if err != nil || len(keys) != 0 {
|
||||
t.Fatalf("old lifecycle created pending action: %v, err=%v", keys, err)
|
||||
p, _ = LoadPortfolio(context.Background(), store, 7, now.UnixMilli())
|
||||
if p.VND != 100_000 || p.Dividends["TCB"]["2612974"].Processed {
|
||||
t.Fatalf("ineligible position applied event: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func dividendCallbackUpdate(userID, chatID int64, messageID int) *models.Update {
|
||||
func TestProviderFailureKeepsPortfolioAvailable(t *testing.T) {
|
||||
s, store, _, rb, now, provider := newDividendFlowState(t, nil)
|
||||
provider.err = errors.New("SSI unavailable")
|
||||
seedDividendFlowPortfolio(t, store, now, 100)
|
||||
if err := s.handleStats(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := rb.Sent()
|
||||
if len(calls) != 2 || !strings.Contains(calls[0].Text(), "Stock Portfolio") || !strings.Contains(calls[1].Text(), "could not be checked") {
|
||||
t.Fatalf("messages = %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func dividendCallbackUpdate(userID, chatID int64, messageID int, token string) *models.Update {
|
||||
return &models.Update{CallbackQuery: &models.CallbackQuery{
|
||||
ID: "query", From: models.User{ID: userID}, Data: dividendCallbackPrefix + "abcdefghijklmnopqrstuv",
|
||||
ID: "query", From: models.User{ID: userID}, Data: dividendCallbackPrefix + token,
|
||||
Message: models.MaybeInaccessibleMessage{Type: models.MaybeInaccessibleMessageTypeMessage, Message: &models.Message{
|
||||
ID: messageID, Chat: models.Chat{ID: chatID, Type: models.ChatTypePrivate},
|
||||
}},
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package stock
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
dividendDiscoveryWindow = 30 * 24 * time.Hour
|
||||
dividendRetentionDays = 90
|
||||
)
|
||||
|
||||
func (r DividendRecord) valid() bool {
|
||||
if r.PublishedAt <= 0 || r.ExDate < 0 || r.RecordDate < 0 || r.PaymentDate < 0 {
|
||||
return false
|
||||
}
|
||||
switch r.Kind {
|
||||
case DividendKindCash:
|
||||
return r.VNDPerShare > 0 && r.OwnedShares == 0 && r.NewShares == 0
|
||||
case DividendKindShares:
|
||||
return r.VNDPerShare == 0 && r.OwnedShares > 0 && r.NewShares > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func dividendRecordFromEvent(event DividendEvent) DividendRecord {
|
||||
return DividendRecord{
|
||||
Kind: event.Kind,
|
||||
PublishedAt: timeMillis(event.PublishedAt),
|
||||
ExDate: timeMillis(event.ExDate),
|
||||
RecordDate: timeMillis(event.RecordDate),
|
||||
PaymentDate: timeMillis(event.PaymentDate),
|
||||
VNDPerShare: event.VNDPerShare,
|
||||
OwnedShares: event.OwnedShares,
|
||||
NewShares: event.NewShares,
|
||||
Title: event.Title,
|
||||
SourceURL: event.SourceURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (r DividendRecord) event(symbol, providerID string) DividendEvent {
|
||||
return DividendEvent{
|
||||
ProviderID: providerID,
|
||||
Symbol: symbol,
|
||||
Kind: r.Kind,
|
||||
PublishedAt: millisTime(r.PublishedAt),
|
||||
ExDate: millisTime(r.ExDate),
|
||||
RecordDate: millisTime(r.RecordDate),
|
||||
PaymentDate: millisTime(r.PaymentDate),
|
||||
VNDPerShare: r.VNDPerShare,
|
||||
OwnedShares: r.OwnedShares,
|
||||
NewShares: r.NewShares,
|
||||
Title: r.Title,
|
||||
SourceURL: r.SourceURL,
|
||||
}
|
||||
}
|
||||
|
||||
func timeMillis(value time.Time) int64 {
|
||||
if value.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return value.UnixMilli()
|
||||
}
|
||||
|
||||
func millisTime(value int64) time.Time {
|
||||
if value <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.UnixMilli(value).In(saigonLocation)
|
||||
}
|
||||
|
||||
func (r DividendRecord) retentionAnchor() int64 {
|
||||
if r.RecordDate != 0 {
|
||||
return r.RecordDate
|
||||
}
|
||||
return r.PublishedAt
|
||||
}
|
||||
|
||||
func (p Portfolio) dividendRecord(symbol, eventID string) (DividendRecord, bool) {
|
||||
events := p.Dividends[symbol]
|
||||
if events == nil {
|
||||
return DividendRecord{}, false
|
||||
}
|
||||
record, exists := events[eventID]
|
||||
return record, exists
|
||||
}
|
||||
|
||||
func (p *Portfolio) setDividendRecord(symbol, eventID string, record DividendRecord) {
|
||||
if p.Dividends == nil {
|
||||
p.Dividends = map[string]map[string]DividendRecord{}
|
||||
}
|
||||
events := p.Dividends[symbol]
|
||||
if events == nil {
|
||||
events = map[string]DividendRecord{}
|
||||
p.Dividends[symbol] = events
|
||||
}
|
||||
events[eventID] = record
|
||||
}
|
||||
|
||||
func (p *Portfolio) upsertDividendEvent(event DividendEvent) bool {
|
||||
old, exists := p.dividendRecord(event.Symbol, event.ProviderID)
|
||||
updated := dividendRecordFromEvent(event)
|
||||
updated.Processed = old.Processed
|
||||
if exists && old == updated {
|
||||
return false
|
||||
}
|
||||
p.setDividendRecord(event.Symbol, event.ProviderID, updated)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Portfolio) pruneDividendHistory(now time.Time) bool {
|
||||
changed := false
|
||||
for symbol, events := range p.Dividends {
|
||||
for eventID, event := range events {
|
||||
if dividendRecordExpired(event, now) {
|
||||
delete(events, eventID)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
delete(p.Dividends, symbol)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func dividendRecordExpired(event DividendRecord, now time.Time) bool {
|
||||
anchor := event.retentionAnchor()
|
||||
return anchor > 0 && !startOfSaigonDay(now).Before(startOfSaigonDay(millisTime(anchor)).AddDate(0, 0, dividendRetentionDays))
|
||||
}
|
||||
|
||||
func dividendRecordDue(event DividendRecord, now time.Time) bool {
|
||||
return event.RecordDate > 0 && !startOfSaigonDay(now).Before(startOfSaigonDay(millisTime(event.RecordDate)))
|
||||
}
|
||||
|
||||
func positionOpenedByRecordDate(position AssetPosition, event DividendRecord) bool {
|
||||
if event.RecordDate <= 0 || position.OpenedAt < 0 {
|
||||
return false
|
||||
}
|
||||
// OpenedAt was added after portfolios already existed. Zero means the
|
||||
// lifecycle predates tracking, so preserve eligibility for legacy holdings.
|
||||
if position.OpenedAt == 0 {
|
||||
return true
|
||||
}
|
||||
openedDay := startOfSaigonDay(millisTime(position.OpenedAt))
|
||||
recordDay := startOfSaigonDay(millisTime(event.RecordDate))
|
||||
return !openedDay.After(recordDay)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package stock
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUpsertDividendEventPreservesLocalStateAndProviderCorrections(t *testing.T) {
|
||||
p := NewPortfolio(1)
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"2612974": {
|
||||
Kind: DividendKindCash, PublishedAt: 1, VNDPerShare: 1000,
|
||||
Processed: true,
|
||||
},
|
||||
}
|
||||
event := DividendEvent{
|
||||
ProviderID: "2612974", Symbol: "TCB", Kind: DividendKindCash,
|
||||
PublishedAt: time.UnixMilli(2), RecordDate: time.UnixMilli(3), VNDPerShare: 1500,
|
||||
}
|
||||
if !p.upsertDividendEvent(event) {
|
||||
t.Fatal("provider correction was not recorded")
|
||||
}
|
||||
got := p.Dividends["TCB"]["2612974"]
|
||||
if got.VNDPerShare != 1500 || got.RecordDate != 3 || !got.Processed {
|
||||
t.Fatalf("merged event = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDividendHistoryUsesRecordDateThenPublishedDate(t *testing.T) {
|
||||
now := time.Date(2026, 7, 22, 12, 0, 0, 0, saigonLocation)
|
||||
p := NewPortfolio(1)
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"record-expired": {
|
||||
Kind: DividendKindCash, PublishedAt: now.AddDate(0, 0, -120).UnixMilli(),
|
||||
RecordDate: now.AddDate(0, 0, -90).UnixMilli(), VNDPerShare: 1000,
|
||||
},
|
||||
"missing-expired": {
|
||||
Kind: DividendKindCash, PublishedAt: now.AddDate(0, 0, -90).UnixMilli(), VNDPerShare: 1000,
|
||||
},
|
||||
"keep": {
|
||||
Kind: DividendKindCash, PublishedAt: now.AddDate(0, 0, -100).UnixMilli(),
|
||||
RecordDate: now.AddDate(0, 0, -89).UnixMilli(), VNDPerShare: 1000,
|
||||
},
|
||||
}
|
||||
if !p.pruneDividendHistory(now) {
|
||||
t.Fatal("prune reported no change")
|
||||
}
|
||||
if len(p.Dividends["TCB"]) != 1 || p.Dividends["TCB"]["keep"].RecordDate == 0 {
|
||||
t.Fatalf("retained history = %+v", p.Dividends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositionOpenedByRecordDateUsesSaigonCalendarDay(t *testing.T) {
|
||||
recordDate := time.Date(2026, 6, 25, 0, 0, 0, 0, saigonLocation)
|
||||
event := DividendRecord{RecordDate: recordDate.UnixMilli()}
|
||||
if !positionOpenedByRecordDate(AssetPosition{}, event) {
|
||||
t.Fatal("legacy position without openedAt should remain eligible")
|
||||
}
|
||||
if !positionOpenedByRecordDate(AssetPosition{OpenedAt: recordDate.Add(12 * time.Hour).UnixMilli()}, event) {
|
||||
t.Fatal("position opened on record date should be eligible")
|
||||
}
|
||||
if positionOpenedByRecordDate(AssetPosition{OpenedAt: recordDate.AddDate(0, 0, 1).UnixMilli()}, event) {
|
||||
t.Fatal("position opened after record date should be ineligible")
|
||||
}
|
||||
}
|
||||
@@ -23,118 +23,77 @@ const (
|
||||
dividendFetchWorkers = 4
|
||||
)
|
||||
|
||||
type dividendCheckResult struct {
|
||||
symbol string
|
||||
openedAt int64
|
||||
after time.Time
|
||||
events []DividendEvent
|
||||
err error
|
||||
type dividendRef struct {
|
||||
symbol string
|
||||
eventID string
|
||||
}
|
||||
|
||||
func (s *state) notifyDividendEvents(ctx context.Context, b *bot.Bot, msg *models.Message, userID int64, p Portfolio, checkedThrough time.Time) error {
|
||||
if s.dividends == nil || s.pending == nil || len(p.Assets) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.cleanupExpiredDividends(ctx, checkedThrough.UnixMilli())
|
||||
type dividendFetchJob struct {
|
||||
symbol string
|
||||
after time.Time
|
||||
through time.Time
|
||||
recent bool
|
||||
targetIDs map[string]struct{}
|
||||
}
|
||||
|
||||
type holding struct {
|
||||
symbol string
|
||||
qty int64
|
||||
after time.Time
|
||||
type dividendCheckResult struct {
|
||||
job dividendFetchJob
|
||||
events []DividendEvent
|
||||
err error
|
||||
}
|
||||
|
||||
func (job dividendFetchJob) includes(event DividendEvent) bool {
|
||||
if event.Symbol != job.symbol || event.PublishedAt.Before(job.after) || event.PublishedAt.After(job.through) {
|
||||
return false
|
||||
}
|
||||
holdings := make([]holding, 0, len(p.Assets))
|
||||
for symbol, position := range p.Assets {
|
||||
if position.Quantity > 0 {
|
||||
holdings = append(holdings, holding{
|
||||
symbol: symbol,
|
||||
qty: position.Quantity,
|
||||
after: time.UnixMilli(position.DividendCheckedAt),
|
||||
})
|
||||
if job.recent {
|
||||
return true
|
||||
}
|
||||
_, wanted := job.targetIDs[event.ProviderID]
|
||||
return wanted
|
||||
}
|
||||
|
||||
func (s *state) notifyDividendEvents(ctx context.Context, b *bot.Bot, msg *models.Message, userID int64, snapshot Portfolio, checkedThrough time.Time) error {
|
||||
if s.pending != nil {
|
||||
s.cleanupExpiredDividends(ctx, checkedThrough.UnixMilli())
|
||||
}
|
||||
|
||||
failed, err := s.syncDividendHistory(ctx, userID, snapshot, checkedThrough)
|
||||
if err != nil {
|
||||
log.Error("stock_save_dividend_history", "user", userID, "err", err)
|
||||
if replyErr := chathelper.Reply(ctx, b, msg, "Dividend events were checked, but the history could not be saved. Try again later."); replyErr != nil {
|
||||
return replyErr
|
||||
}
|
||||
}
|
||||
if len(holdings) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(holdings, func(i, j int) bool { return holdings[i].symbol < holdings[j].symbol })
|
||||
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, dividendFetchTimeout)
|
||||
defer cancel()
|
||||
results := make([]dividendCheckResult, len(holdings))
|
||||
sem := make(chan struct{}, dividendFetchWorkers)
|
||||
var wg sync.WaitGroup
|
||||
for index, h := range holdings {
|
||||
index, h := index, h
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
events, err := s.dividends.FetchDividendEvents(fetchCtx, h.symbol, h.after, checkedThrough)
|
||||
results[index] = dividendCheckResult{
|
||||
symbol: h.symbol,
|
||||
openedAt: p.Assets[h.symbol].OpenedAt,
|
||||
after: h.after,
|
||||
events: events,
|
||||
err: err,
|
||||
}
|
||||
}()
|
||||
latest, err := LoadPortfolio(ctx, s.store, userID, checkedThrough.UnixMilli())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successful := make([]string, 0, len(results))
|
||||
failed := make([]string, 0)
|
||||
for _, result := range results {
|
||||
if result.err != nil {
|
||||
log.Error("stock_dividend_events_checked",
|
||||
"user", userID,
|
||||
"ticker", result.symbol,
|
||||
"from", result.after.UnixMilli(),
|
||||
"through", checkedThrough.UnixMilli(),
|
||||
"events", 0,
|
||||
"status", "error",
|
||||
"err", result.err)
|
||||
failed = append(failed, result.symbol)
|
||||
refs := sortedDividendRefs(latest)
|
||||
for _, ref := range refs {
|
||||
position, held := latest.Assets[ref.symbol]
|
||||
if !held || position.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
log.Info("stock_dividend_events_checked",
|
||||
"user", userID,
|
||||
"ticker", result.symbol,
|
||||
"from", result.after.UnixMilli(),
|
||||
"through", checkedThrough.UnixMilli(),
|
||||
"events", len(result.events),
|
||||
"status", "success")
|
||||
sort.Slice(result.events, func(i, j int) bool {
|
||||
if result.events[i].PublishedAt.Equal(result.events[j].PublishedAt) {
|
||||
return result.events[i].ProviderID < result.events[j].ProviderID
|
||||
}
|
||||
return result.events[i].PublishedAt.Before(result.events[j].PublishedAt)
|
||||
})
|
||||
delivered := true
|
||||
for _, event := range result.events {
|
||||
if _, applied := p.AppliedDividendEvents[dividendLedgerKey(event.ProviderID)]; applied {
|
||||
continue
|
||||
}
|
||||
if err := s.sendDividendSuggestion(ctx, b, msg, userID, result.openedAt, checkedThrough, event); err != nil {
|
||||
log.Error("stock_send_dividend_suggestion", "user", userID, "ticker", result.symbol, "event", event.ProviderID, "err", err)
|
||||
delivered = false
|
||||
break
|
||||
}
|
||||
record, exists := latest.dividendRecord(ref.symbol, ref.eventID)
|
||||
if !exists || record.Processed {
|
||||
continue
|
||||
}
|
||||
if delivered {
|
||||
successful = append(successful, result.symbol)
|
||||
} else {
|
||||
failed = append(failed, result.symbol)
|
||||
if !dividendRecordDue(record, checkedThrough) {
|
||||
if err := s.sendFutureDividendNotice(ctx, b, msg, userID, position.OpenedAt, ref); err != nil {
|
||||
log.Error("stock_send_dividend_notice", "user", userID, "ticker", ref.symbol, "event", ref.eventID, "err", err)
|
||||
failed = append(failed, ref.symbol)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.sendDividendSuggestion(ctx, b, msg, userID, position.OpenedAt, ref); err != nil {
|
||||
log.Error("stock_send_dividend_suggestion", "user", userID, "ticker", ref.symbol, "event", ref.eventID, "err", err)
|
||||
failed = append(failed, ref.symbol)
|
||||
}
|
||||
}
|
||||
|
||||
if len(successful) > 0 {
|
||||
if err := s.advanceDividendCursors(ctx, userID, successful, checkedThrough.UnixMilli()); err != nil {
|
||||
log.Error("stock_save_dividend_cursors", "user", userID, "err", err)
|
||||
if replyErr := chathelper.Reply(ctx, b, msg, "Dividend events were checked, but the check time could not be saved. You may see the same suggestions again."); replyErr != nil {
|
||||
return replyErr
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
sort.Strings(failed)
|
||||
failed = uniqueStrings(failed)
|
||||
@@ -143,64 +102,196 @@ func (s *state) notifyDividendEvents(ctx context.Context, b *bot.Bot, msg *model
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *state) advanceDividendCursors(ctx context.Context, userID int64, symbols []string, checkedThrough int64) error {
|
||||
func (s *state) syncDividendHistory(ctx context.Context, userID int64, snapshot Portfolio, now time.Time) ([]string, error) {
|
||||
jobs := dividendFetchJobs(snapshot, now)
|
||||
results := s.fetchDividendJobs(ctx, jobs)
|
||||
failed := make([]string, 0)
|
||||
|
||||
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
|
||||
p, err := LoadPortfolio(ctx, s.store, userID, checkedThrough)
|
||||
latest, err := LoadPortfolio(ctx, s.store, userID, now.UnixMilli())
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
changed := false
|
||||
for _, symbol := range symbols {
|
||||
position, ok := p.Assets[symbol]
|
||||
if ok && position.DividendCheckedAt < checkedThrough {
|
||||
position.DividendCheckedAt = checkedThrough
|
||||
p.Assets[symbol] = position
|
||||
changed = true
|
||||
changed := latest.pruneDividendHistory(now)
|
||||
for _, result := range results {
|
||||
if result.err != nil {
|
||||
log.Error("stock_dividend_events_checked",
|
||||
"user", userID,
|
||||
"ticker", result.job.symbol,
|
||||
"from", result.job.after.UnixMilli(),
|
||||
"through", result.job.through.UnixMilli(),
|
||||
"events", 0,
|
||||
"status", "error",
|
||||
"err", result.err)
|
||||
failed = append(failed, result.job.symbol)
|
||||
continue
|
||||
}
|
||||
log.Info("stock_dividend_events_checked",
|
||||
"user", userID,
|
||||
"ticker", result.job.symbol,
|
||||
"from", result.job.after.UnixMilli(),
|
||||
"through", result.job.through.UnixMilli(),
|
||||
"events", len(result.events),
|
||||
"status", "success")
|
||||
for _, event := range result.events {
|
||||
if !result.job.includes(event) {
|
||||
continue
|
||||
}
|
||||
if dividendRecordExpired(dividendRecordFromEvent(event), now) {
|
||||
continue
|
||||
}
|
||||
if latest.upsertDividendEvent(event) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
if changed {
|
||||
if err := SavePortfolio(ctx, s.store, userID, latest); err != nil {
|
||||
return failed, err
|
||||
}
|
||||
}
|
||||
return SavePortfolio(ctx, s.store, userID, p)
|
||||
return failed, nil
|
||||
}
|
||||
|
||||
func (s *state) sendDividendSuggestion(ctx context.Context, b *bot.Bot, msg *models.Message, userID, expectedOpenedAt int64, checkedThrough time.Time, event DividendEvent) error {
|
||||
func dividendFetchJobs(p Portfolio, now time.Time) []dividendFetchJob {
|
||||
recentAfter := now.Add(-dividendDiscoveryWindow)
|
||||
jobs := make([]dividendFetchJob, 0, len(p.Assets))
|
||||
for symbol, position := range p.Assets {
|
||||
if position.Quantity > 0 {
|
||||
jobs = append(jobs, dividendFetchJob{symbol: symbol, after: recentAfter, through: now, recent: true})
|
||||
}
|
||||
}
|
||||
|
||||
type historicalKey struct {
|
||||
symbol string
|
||||
day int64
|
||||
}
|
||||
historical := map[historicalKey]map[string]struct{}{}
|
||||
for symbol, events := range p.Dividends {
|
||||
if position, held := p.Assets[symbol]; !held || position.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
for eventID, event := range events {
|
||||
if event.Processed || event.PublishedAt <= 0 || !millisTime(event.PublishedAt).Before(recentAfter) {
|
||||
continue
|
||||
}
|
||||
if event.RecordDate != 0 && !dividendRecordDue(event, now) {
|
||||
continue
|
||||
}
|
||||
day := startOfSaigonDay(millisTime(event.PublishedAt))
|
||||
key := historicalKey{symbol: symbol, day: day.UnixMilli()}
|
||||
if historical[key] == nil {
|
||||
historical[key] = map[string]struct{}{}
|
||||
}
|
||||
historical[key][eventID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for key, targetIDs := range historical {
|
||||
day := millisTime(key.day)
|
||||
jobs = append(jobs, dividendFetchJob{
|
||||
symbol: key.symbol, after: day, through: day.Add(24*time.Hour - time.Millisecond), targetIDs: targetIDs,
|
||||
})
|
||||
}
|
||||
sort.Slice(jobs, func(i, j int) bool {
|
||||
if jobs[i].symbol != jobs[j].symbol {
|
||||
return jobs[i].symbol < jobs[j].symbol
|
||||
}
|
||||
return jobs[i].after.Before(jobs[j].after)
|
||||
})
|
||||
return jobs
|
||||
}
|
||||
|
||||
func (s *state) fetchDividendJobs(ctx context.Context, jobs []dividendFetchJob) []dividendCheckResult {
|
||||
if len(jobs) == 0 || s.dividends == nil {
|
||||
return nil
|
||||
}
|
||||
results := make([]dividendCheckResult, len(jobs))
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, dividendFetchTimeout)
|
||||
defer cancel()
|
||||
sem := make(chan struct{}, dividendFetchWorkers)
|
||||
var wg sync.WaitGroup
|
||||
for index, job := range jobs {
|
||||
index, job := index, job
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
events, err := s.dividends.FetchDividendEvents(fetchCtx, job.symbol, job.after, job.through)
|
||||
results[index] = dividendCheckResult{job: job, events: events, err: err}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func sortedDividendRefs(p Portfolio) []dividendRef {
|
||||
refs := make([]dividendRef, 0)
|
||||
for symbol, events := range p.Dividends {
|
||||
for eventID := range events {
|
||||
refs = append(refs, dividendRef{symbol: symbol, eventID: eventID})
|
||||
}
|
||||
}
|
||||
sort.Slice(refs, func(i, j int) bool {
|
||||
left := p.Dividends[refs[i].symbol][refs[i].eventID]
|
||||
right := p.Dividends[refs[j].symbol][refs[j].eventID]
|
||||
if left.PublishedAt != right.PublishedAt {
|
||||
return left.PublishedAt < right.PublishedAt
|
||||
}
|
||||
if refs[i].symbol != refs[j].symbol {
|
||||
return refs[i].symbol < refs[j].symbol
|
||||
}
|
||||
return refs[i].eventID < refs[j].eventID
|
||||
})
|
||||
return refs
|
||||
}
|
||||
|
||||
func (s *state) sendFutureDividendNotice(ctx context.Context, b *bot.Bot, msg *models.Message, userID, expectedOpenedAt int64, ref dividendRef) error {
|
||||
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
|
||||
|
||||
now := s.now()
|
||||
latest, err := LoadPortfolio(ctx, s.store, userID, now.UnixMilli())
|
||||
p, err := LoadPortfolio(ctx, s.store, userID, now.UnixMilli())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, applied := latest.AppliedDividendEvents[dividendLedgerKey(event.ProviderID)]; applied {
|
||||
record, exists := p.dividendRecord(ref.symbol, ref.eventID)
|
||||
if !exists || record.Processed || dividendRecordDue(record, now) {
|
||||
return nil
|
||||
}
|
||||
position, held := latest.Assets[event.Symbol]
|
||||
position, held := p.Assets[ref.symbol]
|
||||
if !held || position.Quantity <= 0 || position.OpenedAt != expectedOpenedAt {
|
||||
return nil
|
||||
}
|
||||
observedHolding := position.Quantity
|
||||
hasPending, err := s.hasPendingDividendEvent(ctx, userID, event.ProviderID, now.UnixMilli())
|
||||
if _, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
MessageThreadID: msg.MessageThreadID,
|
||||
Text: dividendEventText(record.event(ref.symbol, ref.eventID), position.Quantity, false),
|
||||
ParseMode: models.ParseModeHTML,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *state) sendDividendSuggestion(ctx context.Context, b *bot.Bot, msg *models.Message, userID, expectedOpenedAt int64, ref dividendRef) error {
|
||||
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
|
||||
|
||||
now := s.now()
|
||||
p, err := LoadPortfolio(ctx, s.store, userID, now.UnixMilli())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasPending {
|
||||
record, exists := p.dividendRecord(ref.symbol, ref.eventID)
|
||||
if !exists || record.Processed || !dividendRecordDue(record, now) {
|
||||
return nil
|
||||
}
|
||||
position, held := p.Assets[ref.symbol]
|
||||
if !held || position.Quantity <= 0 || position.OpenedAt != expectedOpenedAt || !positionOpenedByRecordDate(position, record) {
|
||||
return nil
|
||||
}
|
||||
action := PendingDividendAction{
|
||||
OwnerUserID: userID,
|
||||
ChatID: msg.Chat.ID,
|
||||
ProviderEventID: event.ProviderID,
|
||||
Symbol: event.Symbol,
|
||||
Kind: event.Kind,
|
||||
VNDPerShare: event.VNDPerShare,
|
||||
OwnedShares: event.OwnedShares,
|
||||
NewShares: event.NewShares,
|
||||
ObservedHolding: observedHolding,
|
||||
PositionOpenedAt: position.OpenedAt,
|
||||
CheckThrough: checkedThrough.UnixMilli(),
|
||||
CreatedAt: now.UnixMilli(),
|
||||
ExpiresAt: now.Add(pendingDividendTTL).UnixMilli(),
|
||||
OwnerUserID: userID, ChatID: msg.Chat.ID, ProviderEventID: ref.eventID, Symbol: ref.symbol,
|
||||
PositionOpenedAt: position.OpenedAt, CreatedAt: now.UnixMilli(), ExpiresAt: now.Add(pendingDividendTTL).UnixMilli(),
|
||||
}
|
||||
token, err := s.createPendingDividend(ctx, action)
|
||||
if err != nil {
|
||||
@@ -210,11 +301,10 @@ func (s *state) sendDividendSuggestion(ctx context.Context, b *bot.Bot, msg *mod
|
||||
sent, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
MessageThreadID: msg.MessageThreadID,
|
||||
Text: dividendEventText(event, observedHolding),
|
||||
Text: dividendEventText(record.event(ref.symbol, ref.eventID), position.Quantity, true),
|
||||
ParseMode: models.ParseModeHTML,
|
||||
ReplyMarkup: &models.InlineKeyboardMarkup{InlineKeyboard: [][]models.InlineKeyboardButton{{{
|
||||
Text: "Apply dividend",
|
||||
CallbackData: dividendCallbackPrefix + token,
|
||||
Text: "Apply dividend", CallbackData: dividendCallbackPrefix + token,
|
||||
}}}},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -230,28 +320,7 @@ func (s *state) sendDividendSuggestion(ctx context.Context, b *bot.Bot, msg *mod
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *state) hasPendingDividendEvent(ctx context.Context, userID int64, providerEventID string, now int64) (bool, error) {
|
||||
keys, err := s.pending.List(ctx, pendingDividendPrefix)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, key := range keys {
|
||||
action, _, getErr := s.pending.Get(ctx, key)
|
||||
if getErr != nil {
|
||||
continue
|
||||
}
|
||||
if action.ExpiresAt <= now || action.MessageID == 0 {
|
||||
_ = s.pending.Delete(ctx, key)
|
||||
continue
|
||||
}
|
||||
if action.OwnerUserID == userID && action.ProviderEventID == providerEventID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func dividendEventText(event DividendEvent, observedHolding int64) string {
|
||||
func dividendEventText(event DividendEvent, observedHolding int64, actionable bool) string {
|
||||
var value string
|
||||
switch event.Kind {
|
||||
case DividendKindCash:
|
||||
@@ -259,15 +328,18 @@ func dividendEventText(event DividendEvent, observedHolding int64) string {
|
||||
case DividendKindShares:
|
||||
value = strconv.FormatInt(event.OwnedShares, 10) + ":" + strconv.FormatInt(event.NewShares, 10) + " shares"
|
||||
}
|
||||
lines := []string{
|
||||
"<b>Recent dividend event · " + html.EscapeString(event.Symbol) + "</b>",
|
||||
html.EscapeString(value),
|
||||
headline := "Upcoming dividend event · "
|
||||
if actionable {
|
||||
headline = "Dividend ready to apply · "
|
||||
}
|
||||
lines := []string{"<b>" + headline + html.EscapeString(event.Symbol) + "</b>", html.EscapeString(value)}
|
||||
if !event.ExDate.IsZero() {
|
||||
lines = append(lines, "Ex-right: "+event.ExDate.Format("02/01/2006"))
|
||||
}
|
||||
if !event.RecordDate.IsZero() {
|
||||
lines = append(lines, "Record: "+event.RecordDate.Format("02/01/2006"))
|
||||
} else {
|
||||
lines = append(lines, "Record: awaiting SSI update")
|
||||
}
|
||||
if !event.PaymentDate.IsZero() {
|
||||
lines = append(lines, "Payment/trading: "+event.PaymentDate.Format("02/01/2006"))
|
||||
@@ -275,12 +347,16 @@ func dividendEventText(event DividendEvent, observedHolding int64) string {
|
||||
if title := truncateRunes(strings.TrimSpace(event.Title), 240); title != "" {
|
||||
lines = append(lines, html.EscapeString(title))
|
||||
}
|
||||
lines = append(lines,
|
||||
"SSI event: <code>"+html.EscapeString(event.ProviderID)+"</code>",
|
||||
"Observed holding: "+html.EscapeString(formatShareQuantity(observedHolding))+" shares.",
|
||||
"The dividend uses your current holding when you accept.",
|
||||
"Button expires in 24 hours.",
|
||||
)
|
||||
lines = append(lines, "SSI event: <code>"+html.EscapeString(event.ProviderID)+"</code>")
|
||||
if actionable {
|
||||
lines = append(lines,
|
||||
"Current holding: "+html.EscapeString(formatShareQuantity(observedHolding))+" shares.",
|
||||
"The dividend uses your current holding when you accept.",
|
||||
"Button expires in 24 hours.",
|
||||
)
|
||||
} else {
|
||||
lines = append(lines, "Approval becomes available from Record date.")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ func (s *state) handleCashDividend(ctx context.Context, b *bot.Bot, update *mode
|
||||
return chathelper.Reply(ctx, b, update.Message, "Dividend amount is too large.")
|
||||
}
|
||||
if err := p.ApplyDividend(symbol, held, balance, s.now().UnixMilli()); err != nil {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not update dividend checkpoint. Try again later.")
|
||||
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)
|
||||
@@ -374,7 +374,7 @@ func (s *state) handleShareDividend(ctx context.Context, b *bot.Bot, update *mod
|
||||
return chathelper.Reply(ctx, b, update.Message, "Share dividend is too large.")
|
||||
}
|
||||
if err := p.ApplyDividend(symbol, finalHolding, p.VND, s.now().UnixMilli()); err != nil {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not update dividend checkpoint. Try again later.")
|
||||
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)
|
||||
@@ -443,7 +443,7 @@ func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.U
|
||||
}
|
||||
|
||||
if err := p.ApplyDividend(symbol, finalHolding, balance, s.now().UnixMilli()); err != nil {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not update dividend checkpoint. Try again later.")
|
||||
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)
|
||||
@@ -457,9 +457,9 @@ func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.U
|
||||
"\nBalance: "+FormatVND(balance))
|
||||
}
|
||||
|
||||
// handleStats renders the portfolio first, then checks each held ticker for
|
||||
// dividend events. Cursor persistence reloads and merges under the user lock,
|
||||
// so network calls never block concurrent portfolio mutations.
|
||||
// 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.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *countingPortfolioStore) Put(ctx context.Context, id string, p Portfolio
|
||||
func seedStockPortfolio(t *testing.T, store Store, userID int64, held int64, balance float64) {
|
||||
t.Helper()
|
||||
p := NewPortfolio(123)
|
||||
p.Assets["TCB"] = AssetPosition{Quantity: held, Base: float64(held) * 30_000, DividendCheckedAt: 100}
|
||||
p.Assets["TCB"] = AssetPosition{Quantity: held, Base: float64(held) * 30_000, OpenedAt: 100}
|
||||
p.VND = balance
|
||||
if err := SavePortfolio(context.Background(), store, userID, p); err != nil {
|
||||
t.Fatalf("seed portfolio: %v", err)
|
||||
@@ -269,7 +269,7 @@ func TestHandleCashDividendAllowsRepeatedManualAdjustments(t *testing.T) {
|
||||
if got, want := p.VND, float64(418000); got != want {
|
||||
t.Fatalf("balance = %v, want %v", got, want)
|
||||
}
|
||||
if p.Assets["TCB"].Base != 139*30_000 || p.Assets["TCB"].DividendCheckedAt != 123 {
|
||||
if p.Assets["TCB"].Base != 139*30_000 || p.Assets["TCB"].OpenedAt != 100 {
|
||||
t.Fatalf("cash dividend position: %+v", p.Assets["TCB"])
|
||||
}
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func TestHandleShareDividendPreservesRatioAndFloors(t *testing.T) {
|
||||
if got, want := p.Assets["TCB"].Quantity, int64(152); got != want {
|
||||
t.Fatalf("holding = %d, want %d", got, want)
|
||||
}
|
||||
if p.Assets["TCB"].Base != 139*30_000 || p.Assets["TCB"].DividendCheckedAt != 123 {
|
||||
if p.Assets["TCB"].Base != 139*30_000 || p.Assets["TCB"].OpenedAt != 100 {
|
||||
t.Fatalf("share dividend position: %+v", p.Assets["TCB"])
|
||||
}
|
||||
rb.AssertSentText(t, "Share dividend (100:10): +13 TCB")
|
||||
@@ -378,7 +378,7 @@ func TestHandleCombinedDividendUsesPreEventHoldingAndOneSave(t *testing.T) {
|
||||
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"].DividendCheckedAt != 123 {
|
||||
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)")
|
||||
|
||||
@@ -3,11 +3,11 @@ package stock
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
@@ -18,39 +18,29 @@ const (
|
||||
pendingDividendPrefix = "pending-dividend:"
|
||||
pendingDividendTTL = 24 * time.Hour
|
||||
dividendTokenBytes = 16
|
||||
dividendTokenLength = 22
|
||||
)
|
||||
|
||||
var dividendTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`)
|
||||
|
||||
type PendingDividendStore = storage.DocStore[PendingDividendAction]
|
||||
|
||||
// PendingDividendAction is the trusted server-side half of an inline button.
|
||||
// Telegram receives only an opaque random token; all financial values and the
|
||||
// owner binding remain in storage.
|
||||
// PendingDividendAction is the server-side half of an inline button. Financial
|
||||
// values live in the user's dividend history; Telegram receives only an opaque
|
||||
// random token.
|
||||
type PendingDividendAction struct {
|
||||
OwnerUserID int64 `json:"ownerUserId" bson:"ownerUserId"`
|
||||
ChatID int64 `json:"chatId" bson:"chatId"`
|
||||
MessageID int `json:"messageId" bson:"messageId"`
|
||||
ProviderEventID string `json:"providerEventId" bson:"providerEventId"`
|
||||
Symbol string `json:"symbol" bson:"symbol"`
|
||||
Kind DividendKind `json:"kind" bson:"kind"`
|
||||
VNDPerShare int64 `json:"vndPerShare,omitempty" bson:"vndPerShare,omitempty"`
|
||||
OwnedShares int64 `json:"ownedShares,omitempty" bson:"ownedShares,omitempty"`
|
||||
NewShares int64 `json:"newShares,omitempty" bson:"newShares,omitempty"`
|
||||
ObservedHolding int64 `json:"observedHolding" bson:"observedHolding"`
|
||||
PositionOpenedAt int64 `json:"positionOpenedAt,omitempty" bson:"positionOpenedAt,omitempty"`
|
||||
CheckThrough int64 `json:"checkThrough" bson:"checkThrough"`
|
||||
CreatedAt int64 `json:"createdAt" bson:"createdAt"`
|
||||
ExpiresAt int64 `json:"expiresAt" bson:"expiresAt"`
|
||||
OwnerUserID int64 `json:"ownerUserId" bson:"ownerUserId"`
|
||||
ChatID int64 `json:"chatId" bson:"chatId"`
|
||||
MessageID int `json:"messageId" bson:"messageId"`
|
||||
ProviderEventID string `json:"providerEventId" bson:"providerEventId"`
|
||||
Symbol string `json:"symbol" bson:"symbol"`
|
||||
PositionOpenedAt int64 `json:"positionOpenedAt,omitempty" bson:"positionOpenedAt,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt" bson:"createdAt"`
|
||||
ExpiresAt int64 `json:"expiresAt" bson:"expiresAt"`
|
||||
}
|
||||
|
||||
func pendingDividendKey(token string) string { return pendingDividendPrefix + token }
|
||||
|
||||
func dividendLedgerKey(providerID string) string {
|
||||
sum := sha256.Sum256([]byte("ssi:" + providerID))
|
||||
return "ssi:" + base64.RawURLEncoding.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func generateDividendToken() (string, error) {
|
||||
raw := make([]byte, dividendTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
@@ -59,12 +49,16 @@ func generateDividendToken() (string, error) {
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func validDividendToken(token string) bool {
|
||||
return len(token) == dividendTokenLength && dividendTokenPattern.MatchString(token)
|
||||
}
|
||||
|
||||
func callbackToken(data string) (string, bool) {
|
||||
if len(data) != len(dividendCallbackPrefix)+22 || data[:len(dividendCallbackPrefix)] != dividendCallbackPrefix {
|
||||
token, ok := strings.CutPrefix(data, dividendCallbackPrefix)
|
||||
if !ok || !validDividendToken(token) {
|
||||
return "", false
|
||||
}
|
||||
token := data[len(dividendCallbackPrefix):]
|
||||
return token, dividendTokenPattern.MatchString(token)
|
||||
return token, true
|
||||
}
|
||||
|
||||
func (s *state) createPendingDividend(ctx context.Context, action PendingDividendAction) (string, error) {
|
||||
@@ -80,7 +74,7 @@ func (s *state) createPendingDividend(ctx context.Context, action PendingDividen
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !dividendTokenPattern.MatchString(token) {
|
||||
if !validDividendToken(token) {
|
||||
return "", errors.New("stock: invalid generated dividend token")
|
||||
}
|
||||
err = s.pending.PutVersioned(ctx, pendingDividendKey(token), 0, action)
|
||||
|
||||
@@ -14,21 +14,38 @@ type Store = storage.DocStore[Portfolio]
|
||||
|
||||
const CollectionName = "stock"
|
||||
|
||||
// AssetPosition keeps the complete persisted state for one stock ticker.
|
||||
// Base is total remaining VND cost, not average price. DividendCheckedAt is
|
||||
// the cursor for future dividend-event discovery.
|
||||
// AssetPosition keeps the complete persisted state for one open stock ticker.
|
||||
// Base is total remaining VND cost, not average price.
|
||||
type AssetPosition struct {
|
||||
Quantity int64 `json:"quantity" bson:"quantity"`
|
||||
Base float64 `json:"base" bson:"base"`
|
||||
DividendCheckedAt int64 `json:"dividendCheckedAt" bson:"dividendCheckedAt"`
|
||||
OpenedAt int64 `json:"openedAt,omitempty" bson:"openedAt,omitempty"`
|
||||
Quantity int64 `json:"quantity" bson:"quantity"`
|
||||
Base float64 `json:"base" bson:"base"`
|
||||
OpenedAt int64 `json:"openedAt,omitempty" bson:"openedAt,omitempty"`
|
||||
}
|
||||
|
||||
// DividendRecord is one normalized SSI event in a user's retained history.
|
||||
// The raw SSI event ID is the containing map key.
|
||||
type DividendRecord struct {
|
||||
Kind DividendKind `json:"kind" bson:"kind"`
|
||||
PublishedAt int64 `json:"publishedAt" bson:"publishedAt"`
|
||||
ExDate int64 `json:"exDate,omitempty" bson:"exDate,omitempty"`
|
||||
RecordDate int64 `json:"recordDate,omitempty" bson:"recordDate,omitempty"`
|
||||
PaymentDate int64 `json:"paymentDate,omitempty" bson:"paymentDate,omitempty"`
|
||||
|
||||
VNDPerShare int64 `json:"vndPerShare,omitempty" bson:"vndPerShare,omitempty"`
|
||||
OwnedShares int64 `json:"ownedShares,omitempty" bson:"ownedShares,omitempty"`
|
||||
NewShares int64 `json:"newShares,omitempty" bson:"newShares,omitempty"`
|
||||
|
||||
Title string `json:"title,omitempty" bson:"title,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty" bson:"sourceUrl,omitempty"`
|
||||
|
||||
Processed bool `json:"processed" bson:"processed"`
|
||||
}
|
||||
|
||||
type Portfolio struct {
|
||||
VND float64 `json:"vnd" bson:"vnd"`
|
||||
Assets map[string]AssetPosition `json:"assets" bson:"assets"`
|
||||
AppliedDividendEvents map[string]int64 `json:"appliedDividendEvents,omitempty" bson:"appliedDividendEvents,omitempty"`
|
||||
Meta PortfolioMeta `json:"meta" bson:"meta"`
|
||||
VND float64 `json:"vnd" bson:"vnd"`
|
||||
Assets map[string]AssetPosition `json:"assets" bson:"assets"`
|
||||
Dividends map[string]map[string]DividendRecord `json:"dividends,omitempty" bson:"dividends,omitempty"`
|
||||
Meta PortfolioMeta `json:"meta" bson:"meta"`
|
||||
}
|
||||
|
||||
type PortfolioMeta struct {
|
||||
@@ -37,7 +54,11 @@ type PortfolioMeta struct {
|
||||
}
|
||||
|
||||
func NewPortfolio(now int64) Portfolio {
|
||||
return Portfolio{Assets: map[string]AssetPosition{}, Meta: PortfolioMeta{CreatedAt: now}}
|
||||
return Portfolio{
|
||||
Assets: map[string]AssetPosition{},
|
||||
Dividends: map[string]map[string]DividendRecord{},
|
||||
Meta: PortfolioMeta{CreatedAt: now},
|
||||
}
|
||||
}
|
||||
|
||||
func portfolioKey(userID int64) string {
|
||||
@@ -51,8 +72,8 @@ func LoadPortfolio(ctx context.Context, store Store, userID int64, now int64) (P
|
||||
if p.Assets == nil {
|
||||
p.Assets = map[string]AssetPosition{}
|
||||
}
|
||||
if p.AppliedDividendEvents == nil {
|
||||
p.AppliedDividendEvents = map[string]int64{}
|
||||
if p.Dividends == nil {
|
||||
p.Dividends = map[string]map[string]DividendRecord{}
|
||||
}
|
||||
if p.Meta.CreatedAt == 0 {
|
||||
p.Meta.CreatedAt = now
|
||||
@@ -87,13 +108,19 @@ func (p Portfolio) Validate() error {
|
||||
if err != nil || canonical != symbol {
|
||||
return fmt.Errorf("stock: invalid ticker %q", symbol)
|
||||
}
|
||||
if position.Quantity <= 0 || !isPositiveFiniteCost(position.Base) || position.DividendCheckedAt <= 0 || position.OpenedAt < 0 {
|
||||
if position.Quantity <= 0 || !isPositiveFiniteCost(position.Base) || position.OpenedAt < 0 {
|
||||
return fmt.Errorf("stock: %s has invalid position", symbol)
|
||||
}
|
||||
}
|
||||
for eventID, appliedAt := range p.AppliedDividendEvents {
|
||||
if eventID == "" || len(eventID) > 128 || appliedAt <= 0 {
|
||||
return fmt.Errorf("stock: invalid applied dividend event")
|
||||
for symbol, events := range p.Dividends {
|
||||
canonical, err := normalizeStockSymbol(symbol)
|
||||
if err != nil || canonical != symbol || events == nil {
|
||||
return fmt.Errorf("stock: invalid dividend ticker %q", symbol)
|
||||
}
|
||||
for eventID, event := range events {
|
||||
if !ssiProviderIDPattern.MatchString(eventID) || !event.valid() {
|
||||
return fmt.Errorf("stock: invalid dividend event %q for %s", eventID, symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -111,8 +138,8 @@ func (p *Portfolio) DeductVND(amount float64) (ok bool, balance float64) {
|
||||
return true, p.VND
|
||||
}
|
||||
|
||||
// BuyTicker adds quantity and basis. DividendCheckedAt is set only when opening
|
||||
// a new position so later buys cannot skip events after the original buy.
|
||||
// BuyTicker adds quantity and basis. OpenedAt identifies the current position
|
||||
// lifecycle and is reset only after a full exit.
|
||||
func (p *Portfolio) BuyTicker(symbol string, quantity int64, base float64, now int64) error {
|
||||
if quantity <= 0 || !isPositiveFiniteCost(base) || now <= 0 {
|
||||
return fmt.Errorf("stock: invalid purchase position")
|
||||
@@ -130,9 +157,6 @@ func (p *Portfolio) BuyTicker(symbol string, quantity int64, base float64, now i
|
||||
if !isPositiveFiniteCost(position.Base) {
|
||||
return fmt.Errorf("stock: cost basis overflows")
|
||||
}
|
||||
if position.DividendCheckedAt == 0 {
|
||||
position.DividendCheckedAt = now
|
||||
}
|
||||
if isOpening {
|
||||
position.OpenedAt = now
|
||||
}
|
||||
@@ -140,8 +164,8 @@ func (p *Portfolio) BuyTicker(symbol string, quantity int64, base float64, now i
|
||||
return nil
|
||||
}
|
||||
|
||||
// SellTicker removes proportional weighted-average basis while preserving the
|
||||
// dividend cursor. A full exit removes the entire ticker document.
|
||||
// SellTicker removes proportional weighted-average basis. A full exit removes
|
||||
// the active position but retained dividend history remains separate.
|
||||
func (p *Portfolio) SellTicker(symbol string, quantity int64) (remaining int64, soldBase float64, ok bool, err error) {
|
||||
position, exists := p.Assets[symbol]
|
||||
if !exists || position.Quantity < quantity || quantity <= 0 {
|
||||
@@ -170,7 +194,6 @@ func (p *Portfolio) ApplyDividend(symbol string, quantity int64, vnd float64, no
|
||||
return fmt.Errorf("stock: invalid dividend position")
|
||||
}
|
||||
position.Quantity = quantity
|
||||
position.DividendCheckedAt = now
|
||||
p.Assets[symbol] = position
|
||||
p.VND = vnd
|
||||
return nil
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestLoadPortfolioFirstTimeUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.VND != 0 || p.Assets == nil || p.Meta.CreatedAt != 1234567890 {
|
||||
if p.VND != 0 || p.Assets == nil || p.Dividends == nil || p.Meta.CreatedAt != 1234567890 {
|
||||
t.Fatalf("portfolio=%+v", p)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Meta.Invested = 5_000_000
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"2612974": {Kind: DividendKindCash, PublishedAt: 2, RecordDate: 3, VNDPerShare: 1500},
|
||||
}
|
||||
if err := SavePortfolio(ctx, store, 42, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -38,12 +41,12 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position := got.Assets["TCB"]
|
||||
if got.VND != 5_000_000 || position.Quantity != 100 || position.Base != 3_000_000 || position.DividendCheckedAt != 10 || got.Meta.CreatedAt != 1 {
|
||||
if got.VND != 5_000_000 || position.Quantity != 100 || position.Base != 3_000_000 || position.OpenedAt != 10 || got.Meta.CreatedAt != 1 || got.Dividends["TCB"]["2612974"].VNDPerShare != 1500 {
|
||||
t.Fatalf("portfolio=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyPreservesDividendCheckedAtAndSellUsesWeightedBasis(t *testing.T) {
|
||||
func TestBuyPreservesOpenedAtAndSellUsesWeightedBasis(t *testing.T) {
|
||||
p := NewPortfolio(1)
|
||||
if err := p.BuyTicker("TCB", 100, 2_000_000, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -52,27 +55,46 @@ func TestBuyPreservesDividendCheckedAtAndSellUsesWeightedBasis(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position := p.Assets["TCB"]
|
||||
if position.DividendCheckedAt != 10 {
|
||||
t.Fatalf("additional buy changed dividend cursor to %d", position.DividendCheckedAt)
|
||||
if position.OpenedAt != 10 {
|
||||
t.Fatalf("additional buy changed openedAt to %d", position.OpenedAt)
|
||||
}
|
||||
remaining, soldBase, ok, err := p.SellTicker("TCB", 60)
|
||||
if err != nil || !ok || remaining != 90 || soldBase != 1_400_000 {
|
||||
t.Fatalf("remaining=%d soldBase=%v ok=%v err=%v", remaining, soldBase, ok, err)
|
||||
}
|
||||
position = p.Assets["TCB"]
|
||||
if position.Base != 2_100_000 || position.DividendCheckedAt != 10 {
|
||||
if position.Base != 2_100_000 || position.OpenedAt != 10 {
|
||||
t.Fatalf("position=%+v", position)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividendAdvancesCursorWithoutChangingBase(t *testing.T) {
|
||||
func TestDividendDoesNotChangeLifecycleOrBase(t *testing.T) {
|
||||
p := NewPortfolio(1)
|
||||
_ = p.BuyTicker("TCB", 100, 3_000_000, 10)
|
||||
if err := p.ApplyDividend("TCB", 110, 500_000, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position := p.Assets["TCB"]
|
||||
if position.Quantity != 110 || position.Base != 3_000_000 || position.DividendCheckedAt != 30 || p.VND != 500_000 {
|
||||
if position.Quantity != 110 || position.Base != 3_000_000 || position.OpenedAt != 10 || p.VND != 500_000 {
|
||||
t.Fatalf("portfolio=%+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullSaleKeepsDividendHistorySeparate(t *testing.T) {
|
||||
p := NewPortfolio(1)
|
||||
if err := p.BuyTicker("TCB", 100, 3_000_000, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Dividends["TCB"] = map[string]DividendRecord{
|
||||
"2612974": {Kind: DividendKindCash, PublishedAt: 2, VNDPerShare: 1500, Processed: true},
|
||||
}
|
||||
if _, _, ok, err := p.SellTicker("TCB", 100); err != nil || !ok {
|
||||
t.Fatalf("full sale: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok := p.Assets["TCB"]; ok {
|
||||
t.Fatal("full sale retained active asset")
|
||||
}
|
||||
if !p.Dividends["TCB"]["2612974"].Processed {
|
||||
t.Fatal("full sale removed dividend history")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
||||
)
|
||||
|
||||
const (
|
||||
dividendHistoryMigrationMarkerKey = "migration:stock-dividend-history-v1"
|
||||
dividendHistoryMigrationRetries = 5
|
||||
)
|
||||
|
||||
// legacyDividendAssetPosition contains the retired discovery cursor so a
|
||||
// whole-document replacement can physically remove it from MongoDB.
|
||||
type legacyDividendAssetPosition struct {
|
||||
Quantity int64 `json:"quantity" bson:"quantity"`
|
||||
Base float64 `json:"base" bson:"base"`
|
||||
DividendCheckedAt *int64 `json:"dividendCheckedAt,omitempty" bson:"dividendCheckedAt,omitempty"`
|
||||
OpenedAt int64 `json:"openedAt,omitempty" bson:"openedAt,omitempty"`
|
||||
}
|
||||
|
||||
// legacyDividendPortfolio mirrors both the current portfolio fields and the
|
||||
// retired applied-event ledger. Keeping Dividends here is important: migration
|
||||
// must never discard history already written by the new runtime.
|
||||
type legacyDividendPortfolio struct {
|
||||
VND float64 `json:"vnd" bson:"vnd"`
|
||||
Assets map[string]legacyDividendAssetPosition `json:"assets" bson:"assets"`
|
||||
Dividends map[string]map[string]DividendRecord `json:"dividends,omitempty" bson:"dividends,omitempty"`
|
||||
AppliedDividendEvents map[string]int64 `json:"appliedDividendEvents,omitempty" bson:"appliedDividendEvents,omitempty"`
|
||||
Meta PortfolioMeta `json:"meta" bson:"meta"`
|
||||
}
|
||||
|
||||
// InitStore removes the cursor-era dividend fields from stock user portfolios.
|
||||
// The completion marker makes the one-time migration idempotent; it is written
|
||||
// only after every listed user document has been handled successfully.
|
||||
func InitStore(ctx context.Context, portfolioColl, systemColl storage.Collection) error {
|
||||
system := systemstate.New(systemColl)
|
||||
marker, exists, err := system.Get(ctx, dividendHistoryMigrationMarkerKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stock dividend history migration: read marker: %w", err)
|
||||
}
|
||||
if exists && marker.Status == "completed" {
|
||||
return nil
|
||||
}
|
||||
|
||||
docs := storage.Typed[legacyDividendPortfolio](portfolioColl)
|
||||
keys, err := docs.List(ctx, "user:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("stock dividend history migration: list portfolios: %w", err)
|
||||
}
|
||||
|
||||
var migrated int64
|
||||
for index, key := range keys {
|
||||
changed, err := migrateDividendHistorySchema(ctx, docs, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
migrated++
|
||||
log.Info("stock dividend history migrated", "portfolio", index+1, "total", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
marker = completedDividendHistoryMigration(marker, exists, migrated, time.Now().UnixMilli())
|
||||
if err := system.Put(ctx, dividendHistoryMigrationMarkerKey, marker); err != nil {
|
||||
return fmt.Errorf("stock dividend history migration: write marker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func completedDividendHistoryMigration(marker systemstate.Record, exists bool, migrated, now int64) systemstate.Record {
|
||||
if !exists {
|
||||
marker = systemstate.Record{
|
||||
Kind: "migration",
|
||||
Name: "stock dividend history v1",
|
||||
}
|
||||
}
|
||||
if marker.CompletedAt == 0 {
|
||||
marker.CompletedAt = now
|
||||
}
|
||||
marker.Status = "completed"
|
||||
marker.Count += migrated
|
||||
marker.UpdatedAt = now
|
||||
return marker
|
||||
}
|
||||
|
||||
func migrateDividendHistorySchema(ctx context.Context, docs storage.DocStore[legacyDividendPortfolio], key string) (bool, error) {
|
||||
for attempt := 0; attempt < dividendHistoryMigrationRetries; attempt++ {
|
||||
doc, version, err := docs.Get(ctx, key)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("stock dividend history migration: read %s: %w", key, err)
|
||||
}
|
||||
if !doc.hasLegacyDividendFields() {
|
||||
return false, nil
|
||||
}
|
||||
current := doc.currentPortfolio()
|
||||
if err := current.Validate(); err != nil {
|
||||
return false, fmt.Errorf("stock dividend history migration: validate %s: %w", key, err)
|
||||
}
|
||||
err = docs.PutVersioned(ctx, key, version, doc.withoutLegacyDividendFields())
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if !errors.Is(err, storage.ErrConflict) {
|
||||
return false, fmt.Errorf("stock dividend history migration: write %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("stock dividend history migration: write %s: %w", key, storage.ErrConflict)
|
||||
}
|
||||
|
||||
func (p legacyDividendPortfolio) hasLegacyDividendFields() bool {
|
||||
if p.AppliedDividendEvents != nil {
|
||||
return true
|
||||
}
|
||||
for _, position := range p.Assets {
|
||||
if position.DividendCheckedAt != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p legacyDividendPortfolio) withoutLegacyDividendFields() legacyDividendPortfolio {
|
||||
p.AppliedDividendEvents = nil
|
||||
for ticker, position := range p.Assets {
|
||||
position.DividendCheckedAt = nil
|
||||
p.Assets[ticker] = position
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p legacyDividendPortfolio) currentPortfolio() Portfolio {
|
||||
assets := make(map[string]AssetPosition, len(p.Assets))
|
||||
for ticker, position := range p.Assets {
|
||||
assets[ticker] = AssetPosition{
|
||||
Quantity: position.Quantity,
|
||||
Base: position.Base,
|
||||
OpenedAt: position.OpenedAt,
|
||||
}
|
||||
}
|
||||
return Portfolio{VND: p.VND, Assets: assets, Dividends: p.Dividends, Meta: p.Meta}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
||||
"github.com/tiennm99/miti99bot/internal/testutil/mongotest"
|
||||
)
|
||||
|
||||
var stockMongoTests mongotest.Manager
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(stockMongoTests.Run(m))
|
||||
}
|
||||
|
||||
func TestInitStorePhysicallyDropsLegacyDividendFieldsInMongoDB(t *testing.T) {
|
||||
ctx, portfolioColl, systemColl := setupMongoStockMigrationTest(t)
|
||||
docs := storage.Typed[legacyDividendPortfolio](portfolioColl)
|
||||
if err := docs.Put(ctx, "user:7", legacyDividendPortfolio{
|
||||
VND: 50_000,
|
||||
Assets: map[string]legacyDividendAssetPosition{
|
||||
"TCB": {Quantity: 100, Base: 3_000_000, DividendCheckedAt: legacyCursor(123), OpenedAt: 99},
|
||||
},
|
||||
Dividends: map[string]map[string]DividendRecord{
|
||||
"TCB": {"2612974": legacyPreservedDividend()},
|
||||
},
|
||||
AppliedDividendEvents: map[string]int64{"old-hash": 456},
|
||||
Meta: PortfolioMeta{Invested: 3_000_000, CreatedAt: 1},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := InitStore(ctx, portfolioColl, systemColl); err != nil {
|
||||
t.Fatalf("InitStore: %v", err)
|
||||
}
|
||||
if err := InitStore(ctx, portfolioColl, systemColl); err != nil {
|
||||
t.Fatalf("second InitStore: %v", err)
|
||||
}
|
||||
|
||||
rawColl, ok := storage.MongoCollection(portfolioColl)
|
||||
if !ok {
|
||||
t.Fatal("portfolio collection is not MongoDB-backed")
|
||||
}
|
||||
var raw bson.Raw
|
||||
if err := rawColl.FindOne(ctx, bson.M{"_id": "user:7"}).Decode(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := raw.LookupErr("appliedDividendEvents"); err == nil {
|
||||
t.Fatalf("root legacy ledger remains in %v", raw)
|
||||
}
|
||||
position := raw.Lookup("assets").Document().Lookup("TCB").Document()
|
||||
if _, err := position.LookupErr("dividendCheckedAt"); err == nil {
|
||||
t.Fatalf("asset legacy cursor remains in %v", position)
|
||||
}
|
||||
if position.Lookup("openedAt").Int64() != 99 || raw.Lookup("vnd").Double() != 50_000 {
|
||||
t.Fatalf("current portfolio fields changed: %v", raw)
|
||||
}
|
||||
if _, err := raw.LookupErr("dividends"); err != nil {
|
||||
t.Fatalf("new dividend history was discarded: %v", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func setupMongoStockMigrationTest(t *testing.T) (context.Context, storage.Collection, storage.Collection) {
|
||||
t.Helper()
|
||||
uri := stockMongoTests.URI(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
client, err := storage.NewMongoClient(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := client.Database(fmt.Sprintf("miti99bot_stock_migration_test_%d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_ = db.Drop(cleanupCtx)
|
||||
_ = client.Disconnect(cleanupCtx)
|
||||
})
|
||||
provider := storage.NewMongoProvider(db)
|
||||
return ctx, provider.Collection(CollectionName), provider.Collection(systemstate.CollectionName)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
||||
)
|
||||
|
||||
func legacyCursor(value int64) *int64 { return &value }
|
||||
|
||||
func legacyPreservedDividend() DividendRecord {
|
||||
return DividendRecord{
|
||||
Kind: DividendKindCash,
|
||||
PublishedAt: 1,
|
||||
RecordDate: 2,
|
||||
VNDPerShare: 1_500,
|
||||
Title: "Cash dividend",
|
||||
SourceURL: "https://example.test/event/2612974",
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitStoreRemovesLegacyDividendFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
provider := storage.NewMemoryProvider()
|
||||
portfolioColl := provider.Collection(CollectionName)
|
||||
systemColl := provider.Collection(systemstate.CollectionName)
|
||||
docs := storage.Typed[legacyDividendPortfolio](portfolioColl)
|
||||
|
||||
legacy := legacyDividendPortfolio{
|
||||
VND: 50_000,
|
||||
Assets: map[string]legacyDividendAssetPosition{
|
||||
"TCB": {Quantity: 100, Base: 3_000_000, DividendCheckedAt: legacyCursor(123), OpenedAt: 99},
|
||||
},
|
||||
Dividends: map[string]map[string]DividendRecord{
|
||||
"TCB": {"2612974": legacyPreservedDividend()},
|
||||
},
|
||||
AppliedDividendEvents: map[string]int64{"old-hash": 456},
|
||||
Meta: PortfolioMeta{Invested: 3_000_000, CreatedAt: 1},
|
||||
}
|
||||
if err := docs.Put(ctx, "user:7", legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := docs.Put(ctx, "pending-dividend:leave-me", legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := InitStore(ctx, portfolioColl, systemColl); err != nil {
|
||||
t.Fatalf("InitStore: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := docs.Get(ctx, "user:7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.AppliedDividendEvents != nil || got.Assets["TCB"].DividendCheckedAt != nil {
|
||||
t.Fatalf("legacy fields remain: %+v", got)
|
||||
}
|
||||
if got.VND != legacy.VND || got.Assets["TCB"].Quantity != 100 || got.Assets["TCB"].Base != 3_000_000 || got.Assets["TCB"].OpenedAt != 99 || got.Meta != legacy.Meta {
|
||||
t.Fatalf("portfolio data changed: got=%+v want=%+v", got, legacy)
|
||||
}
|
||||
if event, ok := got.Dividends["TCB"]["2612974"]; !ok || event != legacyPreservedDividend() {
|
||||
t.Fatalf("dividend history was discarded: %+v", got.Dividends)
|
||||
}
|
||||
|
||||
pending, _, err := docs.Get(ctx, "pending-dividend:leave-me")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending.AppliedDividendEvents == nil || pending.Assets["TCB"].DividendCheckedAt == nil {
|
||||
t.Fatalf("non-user document was rewritten: %+v", pending)
|
||||
}
|
||||
|
||||
marker, exists, err := systemstate.New(systemColl).Get(ctx, dividendHistoryMigrationMarkerKey)
|
||||
if err != nil || !exists || marker.Status != "completed" || marker.Count != 1 {
|
||||
t.Fatalf("marker=%+v exists=%v err=%v", marker, exists, err)
|
||||
}
|
||||
|
||||
if err := InitStore(ctx, portfolioColl, systemColl); err != nil {
|
||||
t.Fatalf("second InitStore: %v", err)
|
||||
}
|
||||
marker, _, _ = systemstate.New(systemColl).Get(ctx, dividendHistoryMigrationMarkerKey)
|
||||
if marker.Count != 1 {
|
||||
t.Fatalf("idempotent marker count=%d, want 1", marker.Count)
|
||||
}
|
||||
}
|
||||
|
||||
type conflictOnceDividendMigrationStore struct {
|
||||
storage.DocStore[legacyDividendPortfolio]
|
||||
conflicted bool
|
||||
}
|
||||
|
||||
func (s *conflictOnceDividendMigrationStore) PutVersioned(ctx context.Context, key string, version int64, value legacyDividendPortfolio) error {
|
||||
if !s.conflicted {
|
||||
s.conflicted = true
|
||||
return storage.ErrConflict
|
||||
}
|
||||
return s.DocStore.PutVersioned(ctx, key, version, value)
|
||||
}
|
||||
|
||||
func TestDividendHistoryMigrationRetriesVersionConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
provider := storage.NewMemoryProvider()
|
||||
docs := storage.Typed[legacyDividendPortfolio](provider.Collection(CollectionName))
|
||||
if err := docs.Put(ctx, "user:7", legacyDividendPortfolio{
|
||||
Assets: map[string]legacyDividendAssetPosition{
|
||||
"TCB": {Quantity: 10, Base: 300_000, DividendCheckedAt: legacyCursor(123), OpenedAt: 99},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &conflictOnceDividendMigrationStore{DocStore: docs}
|
||||
changed, err := migrateDividendHistorySchema(ctx, store, "user:7")
|
||||
if err != nil || !changed || !store.conflicted {
|
||||
t.Fatalf("changed=%v conflicted=%v err=%v", changed, store.conflicted, err)
|
||||
}
|
||||
}
|
||||
|
||||
type alwaysConflictDividendMigrationStore struct {
|
||||
storage.DocStore[legacyDividendPortfolio]
|
||||
}
|
||||
|
||||
func (s alwaysConflictDividendMigrationStore) PutVersioned(context.Context, string, int64, legacyDividendPortfolio) error {
|
||||
return storage.ErrConflict
|
||||
}
|
||||
|
||||
func TestDividendHistoryMigrationReturnsExhaustedConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
provider := storage.NewMemoryProvider()
|
||||
docs := storage.Typed[legacyDividendPortfolio](provider.Collection(CollectionName))
|
||||
if err := docs.Put(ctx, "user:7", legacyDividendPortfolio{
|
||||
Assets: map[string]legacyDividendAssetPosition{
|
||||
"TCB": {Quantity: 10, Base: 300_000, DividendCheckedAt: legacyCursor(123), OpenedAt: 99},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := migrateDividendHistorySchema(ctx, alwaysConflictDividendMigrationStore{docs}, "user:7")
|
||||
if changed || !errors.Is(err, storage.ErrConflict) {
|
||||
t.Fatalf("changed=%v err=%v, want wrapped conflict", changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitStoreDoesNotMarkFailedMigrationComplete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
provider := storage.NewMemoryProvider()
|
||||
// A legacy portfolio with an invalid position must fail validation before
|
||||
// its retired fields are removed.
|
||||
if err := storage.Typed[legacyDividendPortfolio](provider.Collection(CollectionName)).Put(ctx, "user:7", legacyDividendPortfolio{
|
||||
Assets: map[string]legacyDividendAssetPosition{
|
||||
"TCB": {Quantity: 10, Base: 0, DividendCheckedAt: legacyCursor(123), OpenedAt: 99},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InitStore(ctx, provider.Collection(CollectionName), provider.Collection(systemstate.CollectionName)); err == nil {
|
||||
t.Fatal("InitStore accepted invalid legacy portfolio")
|
||||
}
|
||||
_, exists, err := systemstate.New(provider.Collection(systemstate.CollectionName)).Get(ctx, dividendHistoryMigrationMarkerKey)
|
||||
if err != nil || exists {
|
||||
t.Fatalf("completion marker exists=%v after failure, err=%v", exists, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user