diff --git a/internal/modules/gold/format.go b/internal/modules/gold/format.go index 3976811..d9b495a 100644 --- a/internal/modules/gold/format.go +++ b/internal/modules/gold/format.go @@ -49,6 +49,13 @@ func FormatPnL(currentValue, invested float64) string { return sign + FormatVND(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)" } +func FormatUSD(n float64) string { + if math.IsNaN(n) || math.IsInf(n, 0) { + return "invalid USD" + } + return "$" + strconv.FormatFloat(n, 'f', 2, 64) +} + func absInt64(n int64) int64 { if n < 0 { return -n diff --git a/internal/modules/gold/gold.go b/internal/modules/gold/gold.go index 4ec29c9..68b5726 100644 --- a/internal/modules/gold/gold.go +++ b/internal/modules/gold/gold.go @@ -8,6 +8,12 @@ func New(deps modules.Deps) modules.Module { s := newState(deps.KV) return modules.Module{ Commands: []modules.Command{ + { + Name: "gold_price", + Visibility: modules.VisibilityPublic, + Description: "Show current gold spot price (USD & VND)", + Handler: s.handlePrice, + }, { Name: "gold_topup", Visibility: modules.VisibilityPublic, diff --git a/internal/modules/gold/handlers.go b/internal/modules/gold/handlers.go index 6918554..d5d524d 100644 --- a/internal/modules/gold/handlers.go +++ b/internal/modules/gold/handlers.go @@ -12,6 +12,24 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" ) +func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error { + args := argsAfterCommand(update.Message.Text) + if len(args) != 0 { + return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_price") + } + p, err := s.prices.FetchPrice(ctx) + if err != nil { + return s.replyPriceError(ctx, b, update, err) + } + lines := []string{ + "Gold Spot Price", + "XAU: " + FormatUSD(p.XAUUSD) + " USD/oz", + "Rate: " + FormatVND(p.USDVND) + "/USD", + "VND: " + FormatVND(p.VNDPerLuong) + "/luong", + } + return chathelper.Reply(ctx, b, update.Message, strings.Join(lines, "\n")) +} + func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Update) error { userID, ok := senderInfo(update) if !ok { diff --git a/internal/modules/gold/handlers_test.go b/internal/modules/gold/handlers_test.go index b81ed62..8a7b107 100644 --- a/internal/modules/gold/handlers_test.go +++ b/internal/modules/gold/handlers_test.go @@ -21,6 +21,13 @@ func (f fakePriceFetcher) FetchLuongPrice(context.Context) (float64, error) { return f.price, f.err } +func (f fakePriceFetcher) FetchPrice(context.Context) (GoldPrice, error) { + if f.err != nil { + return GoldPrice{}, f.err + } + return GoldPrice{XAUUSD: 3000, USDVND: 25000, VNDPerLuong: f.price}, nil +} + func newTestState(price float64, err error) *state { return &state{ kv: storage.NewMemoryKVStore(), @@ -47,7 +54,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) { for _, cmd := range mod.Commands { got[cmd.Name] = true } - for _, name := range []string{"gold_topup", "gold_buy", "gold_sell", "gold_stats"} { + for _, name := range []string{"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_stats"} { if !got[name] { t.Fatalf("missing command %s", name) } @@ -160,3 +167,35 @@ func TestStatsWithAndWithoutPrice(t *testing.T) { func modDepsForTest() modules.Deps { return modules.Deps{KV: storage.NewMemoryKVStore()} } + +func TestHandlePrice(t *testing.T) { + s := newTestState(90_000_000, nil) + rb := testutil.NewRecordingBot(t) + if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/gold_price")); err != nil { + t.Fatalf("handlePrice: %v", err) + } + text := rb.LastSent().Text() + for _, want := range []string{"Gold Spot Price", "XAU:", "USD/oz", "VND:", "/luong"} { + if !strings.Contains(text, want) { + t.Fatalf("price missing %q in %q", want, text) + } + } +} + +func TestHandlePriceRejectsArgs(t *testing.T) { + s := newTestState(90_000_000, nil) + rb := testutil.NewRecordingBot(t) + if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/gold_price USD")); err != nil { + t.Fatalf("handlePrice: %v", err) + } + rb.AssertSentText(t, "Usage: /gold_price") +} + +func TestHandlePriceFetchError(t *testing.T) { + s := newTestState(0, errors.New("upstream down")) + rb := testutil.NewRecordingBot(t) + if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/gold_price")); err != nil { + t.Fatalf("handlePrice: %v", err) + } + rb.AssertSentText(t, "Could not fetch gold price") +} diff --git a/internal/modules/gold/helpers.go b/internal/modules/gold/helpers.go index 99378c5..c4d7dd5 100644 --- a/internal/modules/gold/helpers.go +++ b/internal/modules/gold/helpers.go @@ -19,6 +19,7 @@ import ( type priceFetcher interface { FetchLuongPrice(ctx context.Context) (float64, error) + FetchPrice(ctx context.Context) (GoldPrice, error) } type state struct { diff --git a/internal/modules/gold/prices.go b/internal/modules/gold/prices.go index 8408320..2e8e29a 100644 --- a/internal/modules/gold/prices.go +++ b/internal/modules/gold/prices.go @@ -24,6 +24,12 @@ const ( var ErrNoGoldPrice = errors.New("gold: no price available") +type GoldPrice struct { + XAUUSD float64 + USDVND float64 + VNDPerLuong float64 +} + type GoldPriceClient struct { HTTP *http.Client GoldURL string @@ -45,20 +51,28 @@ func NewGoldPriceClientFromEnv() *GoldPriceClient { } } -func (c *GoldPriceClient) FetchLuongPrice(ctx context.Context) (float64, error) { +func (c *GoldPriceClient) FetchPrice(ctx context.Context) (GoldPrice, error) { xauUSD, err := c.fetchXAUUSD(ctx) if err != nil { - return 0, err + return GoldPrice{}, err } usdToVND, err := c.fetchUSDVND(ctx) + if err != nil { + return GoldPrice{}, err + } + vndPerLuong := xauUSD * usdToVND * (gramsPerLuong / gramsPerTroyOunce) + if vndPerLuong <= 0 || math.IsNaN(vndPerLuong) || math.IsInf(vndPerLuong, 0) { + return GoldPrice{}, ErrNoGoldPrice + } + return GoldPrice{XAUUSD: xauUSD, USDVND: usdToVND, VNDPerLuong: vndPerLuong}, nil +} + +func (c *GoldPriceClient) FetchLuongPrice(ctx context.Context) (float64, error) { + p, err := c.FetchPrice(ctx) if err != nil { return 0, err } - price := xauUSD * usdToVND * (gramsPerLuong / gramsPerTroyOunce) - if price <= 0 || math.IsNaN(price) || math.IsInf(price, 0) { - return 0, ErrNoGoldPrice - } - return price, nil + return p.VNDPerLuong, nil } func (c *GoldPriceClient) httpClient() *http.Client { diff --git a/internal/modules/gold/prices_test.go b/internal/modules/gold/prices_test.go index 326b2e9..110317e 100644 --- a/internal/modules/gold/prices_test.go +++ b/internal/modules/gold/prices_test.go @@ -105,6 +105,35 @@ func TestGoldPriceClient_FXRateLimitedIsRetryable(t *testing.T) { } } +func TestGoldPriceClient_FetchPrice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/gold": + _, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":3000}]}`)) + case "/fx": + _, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"} + p, err := c.FetchPrice(context.Background()) + if err != nil { + t.Fatalf("FetchPrice: %v", err) + } + if p.XAUUSD != 3000 { + t.Errorf("XAUUSD: got %v, want 3000", p.XAUUSD) + } + if p.USDVND != 25000 { + t.Errorf("USDVND: got %v, want 25000", p.USDVND) + } + want := 3000 * 25000 * (gramsPerLuong / gramsPerTroyOunce) + if math.Abs(p.VNDPerLuong-want) > 0.01 { + t.Errorf("VNDPerLuong: got %v, want %v", p.VNDPerLuong, want) + } +} + func TestValidateEndpoint(t *testing.T) { if err := validateEndpoint("https://example.com/path"); err != nil { t.Fatalf("https should pass: %v", err) diff --git a/plans/260611-gold-price-command/phase-01-implement-gold-price-command.md b/plans/260611-gold-price-command/phase-01-implement-gold-price-command.md new file mode 100644 index 0000000..77b30cf --- /dev/null +++ b/plans/260611-gold-price-command/phase-01-implement-gold-price-command.md @@ -0,0 +1,79 @@ +--- +phase: 1 +title: "Implement gold_price command" +status: completed +priority: P2 +effort: "30min" +dependencies: [] +--- + +# Phase 1: Implement gold_price command + +## Overview + +Add `/gold_price` — a read-only command that fetches and displays spot XAU in USD/oz and VND/luong. No arguments, no portfolio state, no lock needed. + +## Requirements + +- Functional: `/gold_price` with no arguments displays current gold price in USD per troy ounce and VND per luong. +- Functional: on price fetch failure, show user-friendly error (same pattern as `replyPriceError`). +- Non-functional: no portfolio mutation, no keylock acquisition. +- Non-functional: reject extra arguments (`len(args) != 0`). + +## Architecture + +Add a `GoldPrice` struct and `FetchPrice(ctx)` method to `GoldPriceClient` that returns all three components (XAU USD, USD/VND rate, VND/luong) in one call. Reuses existing `fetchXAUUSD` and `fetchUSDVND` internals. The `priceFetcher` interface gains `FetchPrice` so tests can mock it. + +Output format: +``` +Gold Spot Price +XAU: $3,285.50 USD/oz +Rate: 25,000 VND/USD +VND: 99,450,000 VND/luong +``` + +## Related Code Files + +- Modify: `internal/modules/gold/prices.go` — add `GoldPrice` struct + `FetchPrice` method +- Modify: `internal/modules/gold/helpers.go` — update `priceFetcher` interface +- Modify: `internal/modules/gold/handlers.go` — add `handlePrice` handler +- Modify: `internal/modules/gold/gold.go` — register `gold_price` command +- Modify: `internal/modules/gold/format.go` — add `FormatUSD` helper +- Modify: `internal/modules/gold/handlers_test.go` — add handler test +- Modify: `internal/modules/gold/prices_test.go` — add `FetchPrice` test + +## Implementation Steps + +1. Add `GoldPrice` struct to `prices.go`: + ```go + type GoldPrice struct { + XAUUSD float64 // USD per troy ounce + USDVND float64 // VND per USD + VNDPerLuong float64 // VND per luong + } + ``` +2. Add `FetchPrice(ctx context.Context) (GoldPrice, error)` to `GoldPriceClient` — calls `fetchXAUUSD` + `fetchUSDVND`, computes VNDPerLuong, returns struct. +3. Update `priceFetcher` interface in `helpers.go` to include `FetchPrice`. +4. Update `fakePriceFetcher` in `handlers_test.go` to implement `FetchPrice`. +5. Add `FormatUSD(n float64) string` to `format.go` — e.g. `$3,285.50`. +6. Add `handlePrice` to `handlers.go`: + - Reject if `len(args) != 0` → usage message. + - Call `s.prices.FetchPrice(ctx)`. + - On error → `replyPriceError`. + - Format and reply with 3-line price summary. +7. Register `gold_price` command in `gold.go` with description `"Show current gold spot price (USD & VND)"`. +8. Add tests: + - `TestHandlePrice` — verify output contains USD and VND lines. + - `TestHandlePriceRejectsArgs` — verify extra args rejected. + - `TestHandlePriceFetchError` — verify error path. + - `TestGoldPriceClient_FetchPrice` — verify struct fields from httptest server. +9. Run `go test -count=1 ./internal/modules/gold/...` and `go vet ./...`. + +## Success Criteria + +- [ ] `/gold_price` returns USD/oz and VND/luong prices. +- [ ] Extra arguments rejected with usage message. +- [ ] Price fetch errors handled gracefully. +- [ ] `priceFetcher` interface updated; `fakePriceFetcher` implements both methods. +- [ ] All existing + new tests pass. +- [ ] `go vet` clean. diff --git a/plans/260611-gold-price-command/plan.md b/plans/260611-gold-price-command/plan.md new file mode 100644 index 0000000..42eeffd --- /dev/null +++ b/plans/260611-gold-price-command/plan.md @@ -0,0 +1,33 @@ +--- +title: "Gold price command" +description: "Add /gold_price command showing spot XAU in USD/oz and VND/luong" +status: completed +priority: P2 +branch: "main" +tags: [gold] +blockedBy: [] +blocks: [] +created: "2026-06-11T10:44:35.588Z" +createdBy: "ck:plan" +source: skill +--- + +# Gold price command + +## Overview + +Add `/gold_price` to the gold module. Displays current spot gold price in both USD per troy ounce and VND per luong. No arguments, no portfolio mutation — read-only price lookup. + +## Context + +`GoldPriceClient` already fetches XAU USD/oz and USD/VND internally via unexported `fetchXAUUSD` and `fetchUSDVND`. Currently only `FetchLuongPrice` (combined VND/luong) is exposed. Need a new method returning all components. + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Implement gold_price command](./phase-01-implement-gold-price-command.md) | Pending | + +## Dependencies + +None. Builds on committed gold module (`2304657`).