mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 12:28:54 +00:00
201 lines
8.4 KiB
Go
201 lines
8.4 KiB
Go
package stock
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
|
||
"github.com/go-telegram/bot"
|
||
"github.com/go-telegram/bot/models"
|
||
|
||
"github.com/tiennm99/miti99bot/internal/log"
|
||
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
|
||
"github.com/tiennm99/miti99bot/internal/storage"
|
||
)
|
||
|
||
func answerDividendCallback(ctx context.Context, b *bot.Bot, queryID, text string, alert bool) error {
|
||
_, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{
|
||
CallbackQueryID: queryID,
|
||
Text: text,
|
||
ShowAlert: alert,
|
||
})
|
||
return err
|
||
}
|
||
|
||
func removeDividendButton(ctx context.Context, b *bot.Bot, chatID int64, messageID int) error {
|
||
_, err := b.EditMessageReplyMarkup(ctx, &bot.EditMessageReplyMarkupParams{
|
||
ChatID: chatID,
|
||
MessageID: messageID,
|
||
ReplyMarkup: &models.InlineKeyboardMarkup{},
|
||
})
|
||
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
|
||
}
|
||
query := update.CallbackQuery
|
||
action, msg, actionKey, handled, err := s.resolveDividendCallback(ctx, b, query)
|
||
if err != nil || handled {
|
||
return err
|
||
}
|
||
defer s.locks.Acquire(strconv.FormatInt(action.OwnerUserID, 10))()
|
||
return s.consumeDividendAction(ctx, b, query, msg, actionKey, action)
|
||
}
|
||
|
||
func (s *state) resolveDividendCallback(ctx context.Context, b *bot.Bot, query *models.CallbackQuery) (PendingDividendAction, *models.Message, string, bool, error) {
|
||
token, ok := callbackToken(query.Data)
|
||
if !ok || s.pending == nil {
|
||
err := answerDividendCallback(ctx, b, query.ID, "This dividend suggestion is invalid.", true)
|
||
return PendingDividendAction{}, nil, "", true, err
|
||
}
|
||
actionKey := pendingDividendKey(token)
|
||
action, _, err := s.pending.Get(ctx, actionKey)
|
||
if errors.Is(err, storage.ErrNotFound) {
|
||
err = answerDividendCallback(ctx, b, query.ID, "This dividend suggestion expired or was already used.", true)
|
||
return PendingDividendAction{}, nil, "", true, err
|
||
}
|
||
if err != nil {
|
||
log.Error("stock_load_dividend_action", "err", err)
|
||
err = answerDividendCallback(ctx, b, query.ID, "Could not load this dividend suggestion. Try again.", true)
|
||
return PendingDividendAction{}, nil, "", true, err
|
||
}
|
||
if query.From.ID == 0 || query.From.ID != action.OwnerUserID {
|
||
err = answerDividendCallback(ctx, b, query.ID, "Only the user who requested this portfolio can apply this dividend.", true)
|
||
return PendingDividendAction{}, nil, "", true, err
|
||
}
|
||
msg := query.Message.Message
|
||
if msg == nil || msg.Chat.ID != action.ChatID || msg.ID != action.MessageID || action.MessageID == 0 {
|
||
err = answerDividendCallback(ctx, b, query.ID, "This dividend suggestion is no longer valid here.", true)
|
||
return PendingDividendAction{}, nil, "", true, err
|
||
}
|
||
now := s.now().UnixMilli()
|
||
if action.ExpiresAt <= now {
|
||
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
|
||
}
|
||
|
||
func (s *state) consumeDividendAction(ctx context.Context, b *bot.Bot, query *models.CallbackQuery, msg *models.Message, actionKey string, action PendingDividendAction) error {
|
||
now := s.now().UnixMilli()
|
||
// Re-read under the user lock so two callback workers cannot consume the
|
||
// same action using stale state if handler execution becomes asynchronous.
|
||
action, _, err := s.pending.Get(ctx, actionKey)
|
||
if errors.Is(err, storage.ErrNotFound) {
|
||
return answerDividendCallback(ctx, b, query.ID, "This dividend was already handled.", true)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("reload pending dividend action: %w", err)
|
||
}
|
||
p, err := LoadPortfolio(ctx, s.store, action.OwnerUserID, now)
|
||
if err != nil {
|
||
log.Error("stock_load_portfolio", "user", action.OwnerUserID, "err", err)
|
||
return answerDividendCallback(ctx, b, query.ID, "Could not load your portfolio. Try again.", 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 {
|
||
return s.rejectDividendAction(ctx, b, query.ID, actionKey, action, "You no longer hold "+action.Symbol+". Dividend not applied.")
|
||
}
|
||
if position.OpenedAt != action.PositionOpenedAt {
|
||
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.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)
|
||
}
|
||
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 history is the source of truth for idempotency. Cleanup and
|
||
// Telegram UI updates are best-effort after the atomic portfolio save.
|
||
deleteErr, removeErr := s.invalidateDividendAction(ctx, b, actionKey, action)
|
||
if deleteErr != nil {
|
||
log.Error("stock_delete_dividend_action", "err", deleteErr)
|
||
}
|
||
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)
|
||
}
|
||
return chathelper.Reply(ctx, b, msg, result)
|
||
}
|
||
|
||
func applySuggestedDividend(p *Portfolio, symbol string, record DividendRecord, held, now int64) (string, error) {
|
||
switch record.Kind {
|
||
case DividendKindCash:
|
||
total, err := cashDividendTotal(held, record.VNDPerShare)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
balance, err := checkedVNDBalance(p.VND, total)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := p.ApplyDividend(symbol, held, balance, now); err != nil {
|
||
return "", err
|
||
}
|
||
return "Applied cash dividend for " + symbol + ": " + FormatVND(float64(record.VNDPerShare)) +
|
||
" × " + formatShareQuantity(held) + " = " + FormatVND(float64(total)) +
|
||
"\nBalance: " + FormatVND(balance), nil
|
||
|
||
case DividendKindShares:
|
||
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
|
||
}
|
||
if newShares == 0 {
|
||
return "", errors.New("share dividend rounds down to zero")
|
||
}
|
||
finalHolding, err := checkedHoldingAfterDividend(held, newShares)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := p.ApplyDividend(symbol, finalHolding, p.VND, now); err != nil {
|
||
return "", err
|
||
}
|
||
return "Applied share dividend for " + symbol + " (" + ratio.raw + "): +" +
|
||
formatShareQuantity(newShares) + "\nHolding: " + formatShareQuantity(held) + " → " +
|
||
formatShareQuantity(finalHolding), nil
|
||
default:
|
||
return "", errors.New("unsupported dividend kind")
|
||
}
|
||
}
|