From 49a6e7091ae2f5e23c1df070e2f9974aac39de7d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 12 Jun 2026 11:07:27 +0700 Subject: [PATCH] fix(coin): accept symbol-first trades --- internal/modules/coin/handlers.go | 18 ++++---- internal/modules/coin/handlers_order_test.go | 45 ++++++++++++++++++++ internal/modules/coin/trade_args.go | 38 +++++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 internal/modules/coin/handlers_order_test.go create mode 100644 internal/modules/coin/trade_args.go diff --git a/internal/modules/coin/handlers.go b/internal/modules/coin/handlers.go index c67787d..4b1578d 100644 --- a/internal/modules/coin/handlers.go +++ b/internal/modules/coin/handlers.go @@ -70,16 +70,17 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update } args := argsAfterCommand(update.Message.Text) if len(args) != 2 { - return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy \nExample: /coin_buy 100 BTC") + return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy \nExample: /coin_buy BTC 10") } - amount, ok := parsePositiveFinite(args[0]) - if !ok || !isSafeUSD(amount) { + parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount) + if errors.Is(err, errInvalidUSDAmount) { return chathelper.Reply(ctx, b, update.Message, "USD amount must be a positive finite number within the supported range.") } - coin, err := ResolveCoinSymbol(args[1]) if err != nil { return s.replyPriceError(ctx, b, update, err) } + coin := parsed.coin + amount := parsed.value price, err := s.prices.FetchUSD(ctx, coin) if err != nil { return s.replyPriceError(ctx, b, update, err) @@ -120,16 +121,17 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat } args := argsAfterCommand(update.Message.Text) if len(args) != 2 { - return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell \nExample: /coin_sell 0.01 BTC") + return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell \nExample: /coin_sell BTC 0.01") } - qty, ok := parsePositiveFinite(args[0]) - if !ok { + parsed, err := parseCoinValueArgs(args, isPositiveFinite, errInvalidQuantity) + if errors.Is(err, errInvalidQuantity) { return chathelper.Reply(ctx, b, update.Message, "Quantity must be a positive finite number.") } - coin, err := ResolveCoinSymbol(args[1]) if err != nil { return s.replyPriceError(ctx, b, update, err) } + coin := parsed.coin + qty := parsed.value price, err := s.prices.FetchUSD(ctx, coin) if err != nil { return s.replyPriceError(ctx, b, update, err) diff --git a/internal/modules/coin/handlers_order_test.go b/internal/modules/coin/handlers_order_test.go new file mode 100644 index 0000000..bd571e4 --- /dev/null +++ b/internal/modules/coin/handlers_order_test.go @@ -0,0 +1,45 @@ +package coin + +import ( + "context" + "testing" + + "github.com/tiennm99/miti99bot/internal/testutil" +) + +func TestHandleBuyAcceptsCoinFirstOrder(t *testing.T) { + ctx := context.Background() + s := newTestState(map[string]CoinPrice{"BTC": {USD: 50000, Source: "Binance"}}, nil) + rb := testutil.NewRecordingBot(t) + _ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_topup 1000")) + rb.Reset() + + if err := s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_buy BTC 10")); err != nil { + t.Fatalf("handleBuy: %v", err) + } + + rb.AssertSentText(t, "Bought 0.0002 BTC") + p, _ := LoadPortfolio(ctx, s.kv, 7, 999) + if p.USD != 990 || p.Assets["BTC"] != 0.0002 { + t.Fatalf("after coin-first buy = %+v", p) + } +} + +func TestHandleSellAcceptsCoinFirstOrder(t *testing.T) { + ctx := context.Background() + s := newTestState(map[string]CoinPrice{"BTC": {USD: 50000, Source: "Binance"}}, nil) + rb := testutil.NewRecordingBot(t) + _ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_topup 1000")) + _ = s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_buy 500 BTC")) + rb.Reset() + + if err := s.handleSell(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_sell BTC 0.01")); err != nil { + t.Fatalf("handleSell: %v", err) + } + + rb.AssertSentText(t, "Sold 0.01 BTC") + p, _ := LoadPortfolio(ctx, s.kv, 7, 999) + if p.USD != 1000 || len(p.Assets) != 0 { + t.Fatalf("after coin-first sell = %+v", p) + } +} diff --git a/internal/modules/coin/trade_args.go b/internal/modules/coin/trade_args.go new file mode 100644 index 0000000..07d3c5e --- /dev/null +++ b/internal/modules/coin/trade_args.go @@ -0,0 +1,38 @@ +package coin + +import "errors" + +var ( + errInvalidUSDAmount = errors.New("coin: invalid USD amount") + errInvalidQuantity = errors.New("coin: invalid quantity") +) + +type coinValueArgs struct { + coin CoinSymbol + value float64 +} + +func parseCoinValueArgs(args []string, validValue func(float64) bool, invalidValueErr error) (coinValueArgs, error) { + if len(args) != 2 { + return coinValueArgs{}, invalidValueErr + } + if coin, err := ResolveCoinSymbol(args[0]); err == nil { + value, ok := parsePositiveFinite(args[1]) + if !ok || !validValue(value) { + return coinValueArgs{}, invalidValueErr + } + return coinValueArgs{coin: coin, value: value}, nil + } + value, ok := parsePositiveFinite(args[0]) + if !ok || !validValue(value) { + if _, secondOK := parsePositiveFinite(args[1]); secondOK { + return coinValueArgs{}, ErrUnsupportedCoin + } + return coinValueArgs{}, invalidValueErr + } + coin, err := ResolveCoinSymbol(args[1]) + if err != nil { + return coinValueArgs{}, err + } + return coinValueArgs{coin: coin, value: value}, nil +}