feat(stock): persist per-user dividend history

This commit is contained in:
2026-07-22 16:17:29 +07:00
parent b6f6361c94
commit fda0bbd0b1
25 changed files with 2076 additions and 582 deletions
+32 -24
View File
@@ -48,19 +48,34 @@ Stock dividends are manual portfolio adjustments:
Ratios use `owned:new` exactly as written in the issuer notice. Equivalent
unreduced ratios are accepted and the entered ratio is preserved in the reply.
The bot validates syntax, tickers, and arithmetic safety. `/stock_portfolio`
also checks SSI iBoard for cash and explicit share-dividend events published
since each holding's last successful check. The portfolio is always sent first;
each event then appears in its own message with an `Apply dividend` button. If
no relevant event exists, no additional message is sent.
also checks SSI iBoard for cash and explicit share-dividend events published in
the preceding 30 days. The portfolio is always sent first. Every retained
unprocessed event is re-sent on each `/stock_portfolio` until it is processed
or expires after 90 days: future events are informational messages without a
button, while events from Record date include an `Apply dividend` button.
Events with no Record date remain informational while the bot rechecks their
original publication window for SSI updates.
Suggestions expire after 24 hours and are bound to the Telegram user who
requested the portfolio, the originating chat, and the event message. Another
group member cannot apply them. Acceptance calculates from the user's current
holding at click time, records the provider event atomically with the portfolio
change, and prevents the same SSI event from being applied twice. SSI iBoard is
an undocumented, best-effort source; failures do not prevent the portfolio from
being shown. The bot does not persist dated lots, so suggestions are not legal
record-date entitlement calculations. Users should verify the issuer notice.
holding at click time and marks the per-user event processed atomically with the
portfolio change. The current position must have opened on or before Record
date. SSI iBoard is an undocumented, best-effort source; failures do not prevent
the portfolio from being shown. The bot does not persist dated lots, so
suggestions are not legal record-date entitlement calculations. Users should
verify the issuer notice.
Repeated portfolio requests can create multiple valid buttons for the same
event. Processing is idempotent: the first accepted button marks the event
processed, and later buttons cannot credit it again.
Normalized SSI history is retained under
`dividends.<ticker>.<ssi_event_id>`, separate from active assets so a full sale
does not permit the same event to be applied after a repurchase. Records are
removed 90 days after Record date. If SSI never supplies Record date, they are
removed 90 days after publication. A later SSI response that omits an event
does not remove or suppress the retained per-user record.
The manual commands remain available, but they do not carry an SSI event ID.
Applying an event manually and then accepting its button can therefore record
@@ -70,7 +85,7 @@ the same dividend twice; use one method for a given event.
Stock and coin portfolios embed each open position under `assets.<symbol>`.
Both store `quantity` and total remaining `base`; stock positions additionally
store `dividendCheckedAt` and an `openedAt` lifecycle marker. Stock cash is
store an `openedAt` lifecycle marker. Stock cash is
stored directly as `vnd`; coin cash remains `usd`. Buys add their actual spend.
Partial sells remove basis using the weighted-average method and report realized
P&L; full sells remove the position and its basis. Stock share dividends add
@@ -83,20 +98,13 @@ account value minus all top-ups, so it also reflects realized proceeds,
dividend cash, and idle cash. If any current quote is unavailable, totals are
marked partial and numeric Account P&L is withheld.
For stock positions, `dividendCheckedAt` is the dividend-event discovery cursor.
It is initialized when a position is first bought, preserved across later buys
and sells, and advanced after a successful event check or when a manual stock
dividend is recorded. Failed checks do not advance it. A full exit removes the
cursor; reopening the position starts it again. Coin positions do not store a
dividend cursor. Applied SSI event identities are retained in the stock
portfolio as `appliedDividendEvents.<hashed_provider_event_id> =
<applied_at_unix_milliseconds>` for idempotency and audit history. SSI queries
overlap the previous Asia/Saigon calendar day to avoid missing provider rows
whose publication time has only day precision; pending and applied provider IDs
suppress duplicate suggestions. The stock-only `assets.<ticker>.openedAt`
marker identifies the current position lifecycle and invalidates suggestion
buttons after a full sale and later repurchase. Existing positions adopt this
behavior without a startup migration.
Stock dividend discovery has no per-position cursor. SSI queries use a rolling
30-day publication window and overlap the previous Asia/Saigon calendar day at
the provider boundary; caller-side filtering restores the exact interval.
Per-user event history provides notification state and processing idempotency.
The stock-only `assets.<ticker>.openedAt` marker identifies the current position
lifecycle, invalidates buttons after a full sale and later repurchase, and
prevents a position opened after Record date from applying an older event.
## Layout
+25
View File
@@ -29,6 +29,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/wordle"
"github.com/tiennm99/miti99bot/internal/server"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/systemstate"
"github.com/tiennm99/miti99bot/internal/telegram"
)
@@ -94,6 +95,10 @@ func factories() map[string]modules.Factory {
// container; 10s leaves headroom without hiding a wedged cluster.
const mongodbInitTimeout = 10 * time.Second
// stockMigrationTimeout bounds the one-time scan of persisted stock
// portfolios without tying it to the shorter MongoDB connection timeout.
const stockMigrationTimeout = 2 * time.Minute
func main() {
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -119,6 +124,12 @@ func main() {
if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil {
log.Fatal("lol storage init failed", "err", err)
}
migrationCtx, cancelMigration := context.WithTimeout(rootCtx, stockMigrationTimeout)
if err := initStockStore(migrationCtx, provider); err != nil {
cancelMigration()
log.Fatal("stock storage init failed", "err", err)
}
cancelMigration()
b, err := telegram.NewBot(cfg.TelegramBotToken)
if err != nil {
@@ -209,6 +220,20 @@ func main() {
}
}
func initStockStore(ctx context.Context, provider storage.Provider) error {
return initStockStoreWith(ctx, provider, stock.InitStore)
}
type stockStoreInitializer func(context.Context, storage.Collection, storage.Collection) error
func initStockStoreWith(ctx context.Context, provider storage.Provider, init stockStoreInitializer) error {
return init(
ctx,
provider.Collection(stock.CollectionName),
provider.Collection(systemstate.CollectionName),
)
}
// buildProvider picks the storage backend. Selection order:
// 1. Explicit KV_PROVIDER env (memory|mongodb) wins.
// 2. Auto-detect: MONGO_URL set → mongodb; otherwise memory.
+29
View File
@@ -1,13 +1,17 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/stock"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/systemstate"
)
func TestResolveCommitSHA(t *testing.T) {
@@ -32,6 +36,31 @@ func TestResolveCommitSHA(t *testing.T) {
}
}
func TestInitStockStoreRunsStartupMigration(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
if err := initStockStore(ctx, provider); err != nil {
t.Fatalf("initStockStore: %v", err)
}
marker, exists, err := systemstate.New(provider.Collection(systemstate.CollectionName)).Get(ctx, "migration:stock-dividend-history-v1")
if err != nil || !exists || marker.Status != "completed" {
t.Fatalf("marker=%+v exists=%v err=%v", marker, exists, err)
}
if _, _, err := storage.Typed[stock.Portfolio](provider.Collection(stock.CollectionName)).Get(ctx, "user:1"); !errors.Is(err, storage.ErrNotFound) {
t.Fatalf("unexpected portfolio lookup error: %v", err)
}
}
func TestInitStockStorePropagatesMigrationError(t *testing.T) {
want := errors.New("migration failed")
err := initStockStoreWith(context.Background(), storage.NewMemoryProvider(), func(context.Context, storage.Collection, storage.Collection) error {
return want
})
if !errors.Is(err, want) {
t.Fatalf("initStockStoreWith error=%v, want %v", err, want)
}
}
func TestShortCommitSHA(t *testing.T) {
if got := shortCommitSHA(" 0123456789abcdef "); got != "0123456" {
t.Errorf("full revision: got %q, want %q", got, "0123456")
+10 -5
View File
@@ -102,11 +102,16 @@ Successful GIF replies include the result behind Telegram spoiler formatting.
> retained with `deleted: true`; `/stats` queries filter those rows from visible
> results. A historical `system` collection may remain in MongoDB with completed
> migration records; keep those records as audit history. Stock stores cash as
> `vnd`, embeds positions as
> `assets.<symbol>.{quantity,base,dividendCheckedAt,openedAt}`, and retains
> applied dividend identities under `appliedDividendEvents`. Coin stores cash as
> `usd` and embeds positions as `assets.<symbol>.{quantity,base}`. No completed
> one-time migration code runs at startup.
> `vnd`, embeds positions as `assets.<symbol>.{quantity,base,openedAt}`, and
> retains normalized per-user SSI history under
> `dividends.<symbol>.<ssi_event_id>`. Unprocessed retained dividend events are
> replayed on every `/stock_portfolio` until they are processed or expire after
> 90 days; events with no Record date stay informational while SSI is
> rechecked, and later SSI responses that omit an event do not delete the
> retained record. Coin stores cash as `usd` and embeds positions as
> `assets.<symbol>.{quantity,base}`. Stock startup maintenance runs the
> idempotent `migration:stock-dividend-history-v1` migration to remove the
> retired dividend cursor and hashed applied-event ledger.
## 2. Coolify
@@ -0,0 +1,49 @@
---
title: Stock Dividend History and Notification State
date: 2026-07-22 16:14
component: stock
status: resolved
---
# Stock Dividend History and Notification State
## Context
We needed per-user dividend history that survives repeated `/stock_portfolio` calls without turning SSIs flaky responses into duplicate credits or missing user notifications. The old shape was not enough: it had no stable per-event history, no clean way to separate notifications from processing state, and no safe way to distinguish fresh SSI data from already-accepted dividends.
## What Happened
We moved dividend history to `dividends.<ticker>.<SSI ID>` so every SSI event has a stable per-user record. We intentionally did not add `dividendCheckedAt`; that timestamp would have encouraged the wrong mental model and still would not have solved idempotency.
Record-date gating now decides whether the portfolio shows an `Apply` button. Events without a Record date stay informational while the bot keeps refreshing the original publication window for SSI updates. Historical refresh stays active until the event is processed or ages out at 90 days.
Notifications now repeat on every `/stock_portfolio` response until the event is processed or expired, even if SSI temporarily omits the event. That was the right call because silence from SSI is not proof that the event disappeared. Multiple buttons for the same event are still expected; only the first successful click can mark the per-user record processed.
We also added a startup migration to initialize the new history layout and preserve existing state. The migration is idempotent and guarded so it can run on every boot without redoing work.
## Decisions
We chose stable per-event history over recalculating from raw SSI responses on every request. That gave us deterministic notification state and idempotent processing.
We accepted the legacy duplicate-credit risk for manual dividend commands versus SSI-backed buttons. That is ugly, but it is explicit: manual commands do not carry SSI event IDs, and trying to retrofit full cross-path deduplication would have been more invasive than the feature warranted.
We also kept notifications visible until processed or 90 days old, instead of suppressing them after one failed SSI lookup. Suppression would have hidden real work from users and made the bot look broken.
## Verification
We verified the history shape and button flow with repeated portfolio refreshes, missing-date refreshes, and repeated clicks on the same event. The key checks were:
- the same SSI event keeps the same `dividends.<ticker>.<SSI ID>` record
- `Apply` only appears for Record-date events
- informational events continue to recheck their original publication window
- repeated portfolio commands keep showing unprocessed notifications
- a second button click does not credit the same event again
- the startup migration runs cleanly more than once
## Risks/Next
The remaining risk is mostly operational, not logical: SSI can still omit or reshuffle events, and the bot has to treat that as an external data problem rather than a local delete signal.
The legacy duplicate-credit risk is still real for manual commands. We are accepting that for now because the alternative was a larger behavioral rewrite across old and new dividend paths.
Next step is to keep watching for edge cases where SSI history and manual adjustments overlap in surprising ways. If that starts showing up in real usage, the processing model will need a dedicated reconciliation pass instead of more ad hoc checks.
+46 -42
View File
@@ -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
}
+367 -276
View File
@@ -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},
}},
+146
View File
@@ -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")
}
}
+240 -164
View File
@@ -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")
}
+6 -6
View File
@@ -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 {
+4 -4
View File
@@ -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)")
+21 -27
View File
@@ -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)
+49 -26
View File
@@ -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
+30 -8
View File
@@ -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")
}
}
+148
View File
@@ -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)
}
+165
View File
@@ -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)
}
}
@@ -0,0 +1,76 @@
---
phase: 1
title: Introduce Dividend History and Migration
status: completed
priority: P1
dependencies: []
effort: large
---
# Phase 1: Introduce Dividend History and Migration
## Overview
Replace cursor and hashed-ledger persistence with a validated per-user dividend
history, simplify asset lifecycle state, and physically remove obsolete MongoDB
fields through an idempotent startup migration.
## Requirements
- Add a persisted dividend record containing normalized kind, provider dates,
amount or share ratio, display details, and `processed`.
- Store records as `Portfolio.Dividends[ticker][rawSSIEventID]`.
- Remove `AssetPosition.DividendCheckedAt` and
`Portfolio.AppliedDividendEvents` from the runtime schema.
- Preserve `openedAt` when buying more and reset it only for a newly opened
position.
- Make manual dividend application independent of discovery state.
- Rewrite legacy user documents once and mark migration completion through
`internal/systemstate`.
## Related Code Files
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/portfolio.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/portfolio_test.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/handlers.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/handlers_test.go`
- Create: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/startup.go`
- Create: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/startup_test.go`
- Create: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/startup_mongo_test.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/cmd/server/main.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/cmd/server/main_test.go`
## Implementation Steps
1. Add failing table-driven tests for dividend-map initialization, validation,
storage round trips, buy/partial-sell/full-sell independence, and manual
dividend behavior without a cursor.
2. Define `DividendRecord` and nested history maps using existing JSON/BSON
naming conventions and raw provider-ID validation.
3. Update portfolio load, creation, validation, and financial mutation helpers;
remove obsolete cursor error language from manual handlers.
4. Add migration fixtures containing both obsolete fields and pending-action
documents; ensure only `user:` portfolios are rewritten.
5. Implement conflict-aware migration of stock user documents, dropping
`dividendCheckedAt` and `appliedDividendEvents`, initializing the new map, and
recording an idempotent system-state marker with migrated count.
6. Call stock startup maintenance from `cmd/server/main.go` before module build
and cover invocation/error propagation at the server boundary.
## Success Criteria
- [x] New and legacy portfolios load with initialized dividend history.
- [x] Assets no longer persist or validate `dividendCheckedAt`.
- [x] Manual dividends change only shares/cash and basis-related portfolio data.
- [x] The legacy hashed ledger is deliberately discarded as approved.
- [x] Startup migration is idempotent, retries version conflicts safely, skips
pending-action keys, and records completion in `system`.
- [x] A MongoDB fixture proves obsolete BSON fields are physically absent after
migration.
## Risk Assessment
The migration touches every stock user document and legacy duplicate history is
intentionally removed. Limit the scan to `user:`, validate rewritten documents,
use version-aware writes, and set the completion marker only after the entire
scan succeeds. Never log document contents.
@@ -0,0 +1,72 @@
---
phase: 2
title: Synchronize, Refresh, and Retain SSI Events
status: completed
priority: P1
dependencies:
- phase-01-introduce-dividend-history-and-migration
effort: large
---
# Phase 2: Synchronize, Refresh, and Retain SSI Events
## Overview
Build deterministic synchronization around the rolling 30-day publication
window, targeted historical refreshes, provider corrections, and 90-day cleanup.
## Requirements
- Fetch an exact `[now-30d, now]` publication window for currently held tickers.
- Upsert by ticker and raw SSI ID while preserving local processing state.
- Re-fetch missing-Record-date events from their stored publication-day window.
- Refresh an event before it becomes actionable.
- Delete history 90 days after Record date, or after publication when Record
date remains missing.
- Continue displaying the portfolio and preserve stored data on provider errors.
## Related Code Files
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_events.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_events_ssi.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_events_ssi_test.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_notifications.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_flow_test.go`
- Create: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_history.go`
- Create: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_history_test.go`
## Implementation Steps
1. Add failing tests for exact boundaries, overlapping SSI day queries, merge
semantics, corrections, missing-date refresh, grouped refresh requests,
partial failures, and both retention clocks.
2. Isolate conversion between `DividendEvent` and the persisted record so raw
SSI data is normalized once and the local processed field cannot be overwritten.
3. Make the caller enforce the exact 30-day `PublishedAt` window after the SSI
adapter's intentional prior-day overlap.
4. Group incomplete refreshes by ticker and original publication day, fetch
bounded windows, and update only matching raw event IDs.
5. Refresh due events before action creation when possible; if SSI omits the
matching event, keep using the retained record until processing or expiry.
6. Prune expired records using Asia/Saigon date boundaries and remove empty
ticker maps before saving.
7. Keep fetch concurrency bounded and preserve the current behavior where one
ticker failure does not suppress successful results for other tickers.
## Success Criteria
- [x] Events outside the exact 30-day publication interval are not discovered.
- [x] Repeated fetches are idempotent and preserve `processed`.
- [x] Missing Record dates can be filled after the event leaves the recent feed.
- [x] SSI corrections replace unprocessed provider details before approval.
- [x] Processed state is never reverted by a provider refresh.
- [x] Record-dated and permanently incomplete events are deleted on their
respective 90-day boundaries.
- [x] Provider failures do not delete history or block portfolio rendering.
## Risk Assessment
Historical refreshes can amplify SSI traffic. Group requests by ticker/day,
reuse existing pagination limits and timeouts, and refresh only incomplete or
newly due unprocessed records. Boundary tests must use the Asia/Saigon location
to avoid UTC-dependent behavior.
@@ -0,0 +1,72 @@
---
phase: 3
title: Gate Notifications and Approval by Record Date
status: completed
priority: P1
dependencies:
- phase-02-synchronize-refresh-and-retain-events
effort: large
---
# Phase 3: Gate Notifications and Approval by Record Date
## Overview
Split future notices from actionable suggestions and make repeated notifications
safe through idempotent per-user dividend processing.
## Requirements
- Send one informational message before Record date after every portfolio request.
- Do not expose approval while Record date is missing.
- At or after the start of Record date in Asia/Saigon, send one separate
actionable message per eligible event after every portfolio request.
- Require a current position whose `openedAt` is no later than Record date.
- Calculate from current quantity and atomically mark the stored event processed.
- Store only event references and Telegram security context in pending actions.
## Related Code Files
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_notifications.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_callback.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/pending_dividend.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividend_flow_test.go`
## Implementation Steps
1. Add failing tests for before/on/after Record date, missing Record date,
repeated future notices, failed-send retries, multiple event messages, and
multiple pending buttons guarded by processed-state idempotency.
2. Add callback tests for owner/chat/message binding, sold positions,
postRecord date rebuys, current-quantity calculations, double clicks,
concurrent calls, save failures, and already processed history.
3. Split rendering into informational and actionable event messages while
retaining source dates and the SSI disclaimer.
4. Reload under the per-user lock before every delivery so processed events stop
immediately while unprocessed events repeat on every request.
5. Reduce `PendingDividendAction` to owner/chat/message/token timing plus ticker,
SSI ID, and current-position lifecycle binding.
6. During callback, load the authoritative persisted dividend, revalidate Record
date and eligibility, apply cash/share changes, and set `processed = true` in
the same portfolio save.
7. Keep cleanup, callback acknowledgement, and keyboard removal best-effort
after the financial save, preserving safe retry behavior on save failure.
## Success Criteria
- [x] Future events have no approval control and notify after every portfolio
request until processed or expired.
- [x] Missing Record dates never become actionable.
- [x] Every due event receives its own message and button.
- [x] A postRecord date position lifecycle cannot claim an old event.
- [x] Financial values come from portfolio dividend history, not pending data.
- [x] Successful processing and `processed = true` are atomic and idempotent.
- [x] Multiple or recreated pending actions remain safe because only the first
successful callback can process the event.
## Risk Assessment
Telegram delivery and portfolio persistence cannot be one transaction. Preserve
the existing conservative sequence, recheck state under the user lock, and test
ambiguous failure paths. Opaque random tokens and exact owner/chat/message
binding remain mandatory.
@@ -0,0 +1,63 @@
---
phase: 4
title: Integrate, Document, and Verify
status: completed
priority: P1
dependencies:
- phase-03-gate-notifications-and-approval
effort: medium
---
# Phase 4: Integrate, Document, and Verify
## Overview
Complete handler integration, align user and operations documentation, and run
the repository's full quality gates.
## Requirements
- `/stock_portfolio` sends the portfolio first and then runs history sync and
notifications.
- User-facing errors no longer refer to dividend checkpoints.
- Documentation describes the new schema, Record-date gate, refresh policy,
eligibility approximation, pending lifetime, and retention.
- All focused and repository-wide Go checks pass.
## Related Code Files
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/handlers.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/handlers_test.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/internal/modules/stock/dividends_test.go`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/README.md`
- Modify: `C:/Users/miti99/Workspaces/tiennm99/miti99bot/docs/deploy-coolify-selfhosted.md`
## Implementation Steps
1. Add integration assertions that the portfolio is sent before SSI messages
and remains available during discovery or refresh errors.
2. Update handler error text and remove obsolete cursor expectations from
manual dividend and portfolio tests.
3. Document per-user history, exact recent discovery, historical missing-date
refresh, repeated unprocessed notices, Record-date approval, current-holding
approximation, and 90-day cleanup.
4. Run `gofmt` on all changed Go files.
5. Run focused tests for `internal/modules/stock` and `cmd/server`.
6. Run `go test ./...`, `go vet ./...`, and `go build ./...`.
7. Run `golangci-lint run` when available and resolve all in-scope findings.
## Success Criteria
- [x] `/stock_portfolio` preserves portfolio-first and best-effort SSI behavior.
- [x] README and deployment docs contain no cursor or hashed-ledger claims.
- [x] Focused stock/server tests pass.
- [x] Full tests, vet, and build pass.
- [x] CI lint gate passes when locally available.
- [x] `git diff --check` reports no whitespace errors.
## Risk Assessment
The main integration risk is documentation or older tests silently preserving
the cursor-era contract. Search the full repository for `dividendCheckedAt`,
`AppliedDividendEvents`, `appliedDividendEvents`, and checkpoint wording before
declaring completion.
@@ -0,0 +1,84 @@
---
title: Per-User Stock Dividend History
description: >-
Replace dividend cursors and the hashed applied ledger with per-user SSI
dividend history, Record-date approval gating, refresh, and retention.
status: completed
priority: P1
branch: main
tags:
- feature
- stock
- dividends
- migration
- telegram
blockedBy: []
blocks: []
created: '2026-07-22T13:56:06+07:00'
createdBy: 'ck:plan'
source: skill
---
# Per-User Stock Dividend History
## Overview
Persist normalized SSI events under
`portfolio.dividends.<ticker>.<ssi-event-id>`, independent of active positions.
Discover events from the recent 30-day publication window, refresh incomplete
historical records, repeat unprocessed notices on every portfolio request, and
require approval from Record date onward. Remove records after 90 days and migrate obsolete cursor and
hashed-ledger fields out of MongoDB.
Source: [approved brainstorm report](../reports/260722-1356-per-user-dividend-history-brainstorm.md).
## Delivery Mode
Test-first. Each phase adds or revises focused tests before changing financial
state or notification behavior.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Introduce Dividend History and Migration](./phase-01-introduce-dividend-history-and-migration.md) | Completed |
| 2 | [Synchronize, Refresh, and Retain SSI Events](./phase-02-synchronize-refresh-and-retain-events.md) | Completed |
| 3 | [Gate Notifications and Approval by Record Date](./phase-03-gate-notifications-and-approval.md) | Completed |
| 4 | [Integrate, Document, and Verify](./phase-04-integrate-document-and-verify.md) | Completed |
## Dependencies
- Phases are sequential because notification and callback behavior depend on
the new persisted model and synchronization helpers.
- No cross-plan dependencies.
- SSI remains the existing provider; no new external dependency is required.
- MongoDB integration tests require Docker or `MONGODB_TEST_URL` and may skip
explicitly when neither is available.
## Data Contract
- `assets.<ticker>` retains only quantity, basis, and `openedAt`.
- `dividends.<ticker>.<raw SSI ID>` contains normalized provider details plus
local `processed` state.
- Pending actions reference ticker and SSI ID; they do not duplicate dividend
financial fields.
- Entries expire 90 days after Record date, or after publication when Record
date never becomes available.
## Boundaries
- Current quantity remains the calculation basis at approval time.
- The current asset must have opened on or before Record date.
- Manual dividend commands do not create or process SSI history entries.
- Portfolio output remains available when SSI discovery or refresh fails.
- No command registration or parameter changes.
## Definition of Done
- All phase success criteria pass.
- Focused stock and server tests pass, followed by `go test ./...` and
`go vet ./...`.
- Changed Go files are formatted with `gofmt`.
- `golangci-lint run` passes when the binary is available.
- README and deployment storage documentation match the new persisted schema
and Record-date behavior.
@@ -0,0 +1,46 @@
---
type: project-status
plan: per-user-dividend-history
generated_at: 2026-07-22T16:11:50+07:00
status: completed
---
# Plan Complete: Per-User Dividend History
## Summary
| Metric | Result |
|---|---|
| Phases | 4/4 completed |
| Acceptance checks | 26/26 completed |
| Full tests | 607 passed, 1 intentional helper skip |
| Race test | Stock package passed |
| Static gates | Vet, build, lint, diff check passed |
| Review | Primary and adversarial PASS |
## Achievements
- Replaced asset dividend cursors with retained per-user SSI event history.
- Added exact 30-day discovery and historical missing-date refresh.
- Added Record-date approval gating and minimal opaque pending actions.
- Repeated unprocessed notices after every portfolio request while keeping
multiple buttons financially idempotent.
- Added 90-day cleanup and idempotent MongoDB legacy-field migration.
- Updated runtime, deployment, and user-facing documentation.
## Accepted Risk
- Legacy hashed applied IDs are intentionally deleted. A recently applied
legacy event could be rediscovered and credited again; the owner explicitly
accepted this before landing.
## Documentation
- `README.md`
- `docs/deploy-coolify-selfhosted.md`
- Approved brainstorm report and all implementation phases
## Unresolved Questions
- None blocking. Optional future hardening: direct concurrent-callback and
callback-save-failure tests.
@@ -0,0 +1,143 @@
---
type: brainstorm-report
topic: per-user-dividend-history
conducted_at: 2026-07-22T13:56:06+07:00
status: approved
---
# Brainstorm Report: Per-User Dividend History
## Problem
Stock dividend discovery currently depends on `assets.<ticker>.dividendCheckedAt`
and a global hashed `appliedDividendEvents` ledger. That model offers SSI events
immediately, loses the discovery cursor on a full exit, duplicates trusted event
data in pending buttons, and cannot defer approval reliably until Record date.
The desired behavior is to check SSI when `/stock_portfolio` runs, notify users
about future events, expose approval only from Record date, retain enough event
data to refresh incomplete notices, and remove old history after 90 days.
## Constraints Discovered
- SSI queries are filtered by publication date, not Record date. An event can
leave a rolling 30-day feed before its Record date arrives.
- SSI `corId` lookup is not reliable enough to make ID-only persistence safe.
- Full sales delete `assets.<ticker>`, so nesting processed history there would
allow the same event to be rediscovered after a sell and rebuy.
- Existing applied-event keys are one-way hashes without ticker identity and
cannot be losslessly converted to raw SSI IDs.
- The bot does not persist historical lots. Eligibility can only be bounded by
the current position and its `openedAt` lifecycle marker.
## Approaches Considered
### Per-user dividend history
Persist full normalized SSI details under
`portfolio.dividends.<ticker>.<ssi-event-id>`, separate from active assets.
This keeps approval self-contained, survives full exits, and gives every user
an explicit notification and processing lifecycle.
### Global event cache with per-user references
Normalize SSI events once globally and keep only user state in each portfolio.
This reduces duplicated provider data but adds cross-document consistency,
retention, and callback failure modes that are unnecessary at current scale.
### Event IDs only with SSI refetch
Persist only IDs and retrieve financial details at approval time. This is the
smallest schema but is unsafe because old publication windows move out of the
recent feed and SSI does not provide dependable lookup by ID.
## Approved Design
Use per-user dividend history:
```text
portfolio
|- assets.<ticker>
| |- quantity
| |- base
| `- openedAt
`- dividends.<ticker>.<ssi-event-id>
|- normalized SSI kind, dates, amount/ratio, title, and source URL
`- processed
```
The SSI event ID is the dynamic map key rather than a duplicated record field.
Raw IDs are already constrained to BSON-safe characters by the SSI adapter.
## Discovery and Refresh Flow
On `/stock_portfolio`:
1. Render the portfolio even if SSI later fails.
2. Fetch events published in the exact preceding 30 days for held tickers.
3. Upsert normalized details while preserving local notification and processed
state.
4. Re-query the original publication-date window for stored events whose Record
date is missing. Before creating an actionable message, refresh the event
once more so SSI corrections are captured.
5. Send an informational message for every future unprocessed event after each
portfolio request.
6. At or after the start of Record date in Asia/Saigon, send a separate approval
message for every unprocessed eligible event after each portfolio request.
An event with no Record date remains informational-only until SSI fills the
date. Re-fetches match the original ticker and raw SSI event ID.
## Approval and Idempotency
Pending actions retain only the opaque token bindings and references needed to
locate the user dividend record. Financial values are read from the trusted
portfolio record during callback handling.
Approval requires a current position, `openedAt` no later than Record date, a
valid owner/chat/message-bound token, and `processed == false`. Calculations use
the current quantity because dated holdings are outside scope. Portfolio
mutation and `processed = true` are persisted together under the existing
per-user lock. Repeated requests may create multiple buttons; after the first
successful approval, all later buttons are rejected by the processed marker.
## Retention and Migration
- Delete every dividend 90 days after its Record date, processed or not.
- If Record date remains missing, delete it 90 days after publication so
malformed provider rows cannot persist forever.
- Remove `dividendCheckedAt` from asset positions and manual dividend behavior.
- Remove the old hashed `appliedDividendEvents` field; the owner explicitly
accepts dropping that legacy duplicate ledger.
- Run an idempotent startup migration over `user:` stock documents and record
completion in the shared `system` collection, ensuring obsolete MongoDB fields
are physically removed rather than waiting for organic portfolio writes.
## Boundaries
- Manual dividend commands remain independent because they have no SSI ID.
- No dated-lot or legal-entitlement accounting is introduced.
- No automatic dividend application occurs.
- No command names or parameters change.
- SSI remains a best-effort, replaceable provider.
## Risks and Mitigations
- Provider corrections: refresh incomplete records and refresh again before an
actionable message.
- Duplicate processing: allow repeated notifications but use the persisted
processed marker to ensure only the first approved button mutates finances.
- PostRecord date rebuy: reject when the current position opened after Record
date.
- Partial provider failure: keep the portfolio response and existing stored
history intact; retry on a later request.
- Concurrent callbacks: recheck state under the user lock and save processing
atomically with the financial mutation.
## Approval
The project owner selected per-user dividend history and approved storing full
event details outside `assets`, historical refetch for missing Record dates,
Record-date approval gating, and 90-day retention. The owner later revised the
delivery rule so every portfolio request repeats every unprocessed event until
processing or expiry, even if SSI later omits the retained event.