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