diff --git a/.gitignore b/.gitignore index a42dfd3..314d145 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -sure-cli +/sure-cli *.exe *.exe~ *.dll diff --git a/README.md b/README.md index e92a56f..2c59e33 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ sure-cli --help sure-cli config set api_url http://localhost:3000 # Auth -sure-cli config set api_key +sure-cli config set auth.mode api_key +sure-cli config set auth.api_key # or OAuth: sure-cli login --email you@example.com --password "..." [--otp 123456] @@ -83,14 +84,14 @@ sure-cli accounts list --format=json sure-cli accounts show # Transactions -sure-cli transactions list --from 2026-01-01 --to 2026-02-01 --per-page 50 --format=table +sure-cli transactions list --start-date 2026-01-01 --end-date 2026-02-01 --per-page 50 --format=table sure-cli transactions show # Safe writes (default is --dry-run) sure-cli transactions create --amount "-12.34" --date 2026-02-04 --name "Coffee" --account-id sure-cli transactions create --amount "-12.34" --date 2026-02-04 --name "Coffee" --account-id --apply -sure-cli transactions update --name "Coffee (fixed)" --dry-run +sure-cli transactions update --name "Coffee (fixed)" sure-cli transactions delete --apply # Phase 4 (read-only heuristics) @@ -194,8 +195,4 @@ sure-cli config heuristics # show all heuristic settings sure-cli config fee-keywords # show active fee keywords (60+ defaults) ``` -## Known Upstream Limitations - -- **`GET /api/v1/accounts/:id`** returns 404 upstream. `accounts show` falls back to list lookup. - See `docs/ROADMAP.md` for planned features. diff --git a/cmd/sure-cli/root/accounts_cmd.go b/cmd/sure-cli/root/accounts_cmd.go index ea7cecf..afec83d 100644 --- a/cmd/sure-cli/root/accounts_cmd.go +++ b/cmd/sure-cli/root/accounts_cmd.go @@ -4,9 +4,9 @@ import ( "fmt" "net/url" + "github.com/spf13/cobra" "github.com/we-promise/sure-cli/internal/api" "github.com/we-promise/sure-cli/internal/output" - "github.com/spf13/cobra" ) func newAccountsCmd() *cobra.Command { @@ -43,29 +43,17 @@ func newAccountsCmd() *cobra.Command { cmd.AddCommand(&cobra.Command{ Use: "show ", - Short: "Show account (client-side lookup; API show is not implemented upstream yet)", + Short: "Show account", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - // NOTE: Sure currently does not implement GET /api/v1/accounts/:id (route exists but controller/view missing). - // Workaround: fetch list and find by id. client := api.New() - var res map[string]any - r, err := client.Get("/api/v1/accounts", &res) + var res any + path := fmt.Sprintf("/api/v1/accounts/%s", url.PathEscape(args[0])) + r, err := client.Get(path, &res) if err != nil { output.Fail("request_failed", err.Error(), nil) } - accounts, _ := res["accounts"].([]any) - for _, a := range accounts { - m, ok := a.(map[string]any) - if !ok { - continue - } - if m["id"] == args[0] { - _ = output.Print(format, output.Envelope{Data: m, Meta: &output.Meta{Status: r.StatusCode()}}) - return - } - } - output.Fail("not_found", fmt.Sprintf("account %s not found", args[0]), map[string]any{"hint": "API endpoint GET /api/v1/accounts/:id is not implemented upstream; using list lookup"}) + _ = output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}) }, }) diff --git a/cmd/sure-cli/root/trades_cmd.go b/cmd/sure-cli/root/trades_cmd.go index 53df7fd..d9c9045 100644 --- a/cmd/sure-cli/root/trades_cmd.go +++ b/cmd/sure-cli/root/trades_cmd.go @@ -3,6 +3,7 @@ package root import ( "fmt" "net/url" + "strings" "github.com/spf13/cobra" "github.com/we-promise/sure-cli/internal/api" @@ -13,9 +14,10 @@ func newTradesCmd() *cobra.Command { cmd := &cobra.Command{Use: "trades", Short: "Trades"} var from, to string - var account, symbol string + var startDate, endDate string + var account, accountID string + var accountIDs []string var page, perPage int - var limit int list := &cobra.Command{ Use: "list", @@ -24,17 +26,26 @@ func newTradesCmd() *cobra.Command { client := api.New() q := url.Values{} - if from != "" { - q.Set("from", from) + if startDate == "" { + startDate = from } - if to != "" { - q.Set("to", to) + if endDate == "" { + endDate = to } - if account != "" { - q.Set("account", account) + if accountID == "" { + accountID = account } - if symbol != "" { - q.Set("symbol", symbol) + if startDate != "" { + q.Set("start_date", startDate) + } + if endDate != "" { + q.Set("end_date", endDate) + } + if accountID != "" { + q.Set("account_id", accountID) + } + for _, id := range splitTradeFlagValues(accountIDs) { + q.Add("account_ids[]", id) } if page > 0 { q.Set("page", fmt.Sprintf("%d", page)) @@ -42,9 +53,6 @@ func newTradesCmd() *cobra.Command { if perPage > 0 { q.Set("per_page", fmt.Sprintf("%d", perPage)) } - if limit > 0 { - q.Set("limit", fmt.Sprintf("%d", limit)) - } u := url.URL{Path: "/api/v1/trades", RawQuery: q.Encode()} path := u.String() @@ -62,11 +70,13 @@ func newTradesCmd() *cobra.Command { list.Flags().StringVar(&from, "from", "", "start date (YYYY-MM-DD)") list.Flags().StringVar(&to, "to", "", "end date (YYYY-MM-DD)") - list.Flags().StringVar(&account, "account", "", "account id") - list.Flags().StringVar(&symbol, "symbol", "", "symbol/ticker") + list.Flags().StringVar(&startDate, "start-date", "", "start date (YYYY-MM-DD, maps to start_date)") + list.Flags().StringVar(&endDate, "end-date", "", "end date (YYYY-MM-DD, maps to end_date)") + list.Flags().StringVar(&account, "account", "", "account id (alias for --account-id)") + list.Flags().StringVar(&accountID, "account-id", "", "account id") + list.Flags().StringSliceVar(&accountIDs, "account-ids", nil, "account ids (repeat or comma-separated)") list.Flags().IntVar(&page, "page", 1, "page number") list.Flags().IntVar(&perPage, "per-page", 25, "items per page (maps to per_page)") - list.Flags().IntVar(&limit, "limit", 50, "max results") cmd.AddCommand(list) cmd.AddCommand(&cobra.Command{ @@ -76,7 +86,7 @@ func newTradesCmd() *cobra.Command { Run: func(cmd *cobra.Command, args []string) { client := api.New() var res any - path := fmt.Sprintf("/api/v1/trades/%s", args[0]) + path := fmt.Sprintf("/api/v1/trades/%s", url.PathEscape(args[0])) r, err := client.Get(path, &res) if err != nil { output.Fail("request_failed", err.Error(), nil) @@ -89,3 +99,16 @@ func newTradesCmd() *cobra.Command { return cmd } + +func splitTradeFlagValues(values []string) []string { + var out []string + for _, value := range values { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + } + return out +} diff --git a/cmd/sure-cli/root/trades_cmd_test.go b/cmd/sure-cli/root/trades_cmd_test.go index 3c92702..b4d4729 100644 --- a/cmd/sure-cli/root/trades_cmd_test.go +++ b/cmd/sure-cli/root/trades_cmd_test.go @@ -14,7 +14,7 @@ func TestTradesList_Flags(t *testing.T) { } // Verify expected flags exist - expectedFlags := []string{"from", "to", "account", "symbol", "page", "per-page", "limit"} + expectedFlags := []string{"from", "to", "start-date", "end-date", "account", "account-id", "account-ids", "page", "per-page"} for _, name := range expectedFlags { if list.Flags().Lookup(name) == nil { t.Fatalf("expected flag %q to exist", name) @@ -26,8 +26,8 @@ func TestTradesList_Flags(t *testing.T) { if !strings.Contains(s, "from") { t.Fatalf("expected from in usage") } - if !strings.Contains(s, "symbol") { - t.Fatalf("expected symbol in usage") + if !strings.Contains(s, "account-id") { + t.Fatalf("expected account-id in usage") } } diff --git a/cmd/sure-cli/root/transactions_cmd.go b/cmd/sure-cli/root/transactions_cmd.go index f5f787c..0644b70 100644 --- a/cmd/sure-cli/root/transactions_cmd.go +++ b/cmd/sure-cli/root/transactions_cmd.go @@ -5,18 +5,22 @@ import ( "net/url" "strings" + "github.com/spf13/cobra" "github.com/we-promise/sure-cli/internal/api" "github.com/we-promise/sure-cli/internal/output" - "github.com/spf13/cobra" ) func newTransactionsCmd() *cobra.Command { cmd := &cobra.Command{Use: "transactions", Short: "Transactions"} var from, to string + var startDate, endDate string var account, category, merchant string + var accountID, categoryID, merchantID string + var typ, search string + var accountIDs, categoryIDs, merchantIDs, tagIDs []string + var minAmount, maxAmount string var page, perPage int - var limit int list := &cobra.Command{ Use: "list", @@ -25,20 +29,59 @@ func newTransactionsCmd() *cobra.Command { client := api.New() q := url.Values{} - if from != "" { - q.Set("from", from) + if startDate == "" { + startDate = from + } + if endDate == "" { + endDate = to + } + if accountID == "" { + accountID = account + } + if categoryID == "" { + categoryID = category + } + if merchantID == "" { + merchantID = merchant + } + if startDate != "" { + q.Set("start_date", startDate) + } + if endDate != "" { + q.Set("end_date", endDate) + } + if accountID != "" { + q.Set("account_id", accountID) + } + if categoryID != "" { + q.Set("category_id", categoryID) } - if to != "" { - q.Set("to", to) + if merchantID != "" { + q.Set("merchant_id", merchantID) } - if account != "" { - q.Set("account", account) + if minAmount != "" { + q.Set("min_amount", minAmount) } - if category != "" { - q.Set("category", category) + if maxAmount != "" { + q.Set("max_amount", maxAmount) } - if merchant != "" { - q.Set("merchant", merchant) + if typ != "" { + q.Set("type", typ) + } + if search != "" { + q.Set("search", search) + } + for _, id := range splitFlagValues(accountIDs) { + q.Add("account_ids[]", id) + } + for _, id := range splitFlagValues(categoryIDs) { + q.Add("category_ids[]", id) + } + for _, id := range splitFlagValues(merchantIDs) { + q.Add("merchant_ids[]", id) + } + for _, id := range splitFlagValues(tagIDs) { + q.Add("tag_ids[]", id) } if page > 0 { q.Set("page", fmt.Sprintf("%d", page)) @@ -46,9 +89,6 @@ func newTransactionsCmd() *cobra.Command { if perPage > 0 { q.Set("per_page", fmt.Sprintf("%d", perPage)) } - if limit > 0 { - q.Set("limit", fmt.Sprintf("%d", limit)) - } path := "/api/v1/transactions" if enc := strings.TrimPrefix(q.Encode(), ""); enc != "" { @@ -66,12 +106,24 @@ func newTransactionsCmd() *cobra.Command { list.Flags().StringVar(&from, "from", "", "start date (YYYY-MM-DD)") list.Flags().StringVar(&to, "to", "", "end date (YYYY-MM-DD)") - list.Flags().StringVar(&account, "account", "", "account id") - list.Flags().StringVar(&category, "category", "", "category id") - list.Flags().StringVar(&merchant, "merchant", "", "merchant id") + list.Flags().StringVar(&startDate, "start-date", "", "start date (YYYY-MM-DD, maps to start_date)") + list.Flags().StringVar(&endDate, "end-date", "", "end date (YYYY-MM-DD, maps to end_date)") + list.Flags().StringVar(&account, "account", "", "account id (alias for --account-id)") + list.Flags().StringVar(&category, "category", "", "category id (alias for --category-id)") + list.Flags().StringVar(&merchant, "merchant", "", "merchant id (alias for --merchant-id)") + list.Flags().StringVar(&accountID, "account-id", "", "account id") + list.Flags().StringVar(&categoryID, "category-id", "", "category id") + list.Flags().StringVar(&merchantID, "merchant-id", "", "merchant id") + list.Flags().StringVar(&minAmount, "min-amount", "", "minimum amount") + list.Flags().StringVar(&maxAmount, "max-amount", "", "maximum amount") + list.Flags().StringVar(&typ, "type", "", "transaction type: income|expense") + list.Flags().StringVar(&search, "search", "", "search name, notes, or merchant") + list.Flags().StringSliceVar(&accountIDs, "account-ids", nil, "account ids (repeat or comma-separated)") + list.Flags().StringSliceVar(&categoryIDs, "category-ids", nil, "category ids (repeat or comma-separated)") + list.Flags().StringSliceVar(&merchantIDs, "merchant-ids", nil, "merchant ids (repeat or comma-separated)") + list.Flags().StringSliceVar(&tagIDs, "tag-ids", nil, "tag ids (repeat or comma-separated)") list.Flags().IntVar(&page, "page", 1, "page number") list.Flags().IntVar(&perPage, "per-page", 25, "items per page (maps to per_page)") - list.Flags().IntVar(&limit, "limit", 50, "max results") cmd.AddCommand(list) cmd.AddCommand(&cobra.Command{ @@ -81,7 +133,7 @@ func newTransactionsCmd() *cobra.Command { Run: func(cmd *cobra.Command, args []string) { client := api.New() var res any - path := fmt.Sprintf("/api/v1/transactions/%s", args[0]) + path := fmt.Sprintf("/api/v1/transactions/%s", url.PathEscape(args[0])) r, err := client.Get(path, &res) if err != nil { output.Fail("request_failed", err.Error(), nil) @@ -96,3 +148,16 @@ func newTransactionsCmd() *cobra.Command { return cmd } + +func splitFlagValues(values []string) []string { + var out []string + for _, value := range values { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + } + return out +} diff --git a/cmd/sure-cli/root/transactions_cmd_test.go b/cmd/sure-cli/root/transactions_cmd_test.go index 7d6d72b..b20dde0 100644 --- a/cmd/sure-cli/root/transactions_cmd_test.go +++ b/cmd/sure-cli/root/transactions_cmd_test.go @@ -14,14 +14,14 @@ func TestTransactionsList_QueryString(t *testing.T) { } // build flags - _ = list.Flags().Set("from", "2026-01-01") - _ = list.Flags().Set("to", "2026-01-31") - _ = list.Flags().Set("account", "1") - _ = list.Flags().Set("limit", "10") + _ = list.Flags().Set("start-date", "2026-01-01") + _ = list.Flags().Set("end-date", "2026-01-31") + _ = list.Flags().Set("account-id", "1") + _ = list.Flags().Set("type", "expense") // We don't call Run (would hit network), but we can ensure flags exist and are set. // This test mainly guards against accidental flag removal/renaming. - for _, name := range []string{"from", "to", "account", "category", "merchant", "page", "per-page", "limit"} { + for _, name := range []string{"from", "to", "start-date", "end-date", "account", "account-id", "category-id", "merchant-id", "type", "search", "page", "per-page"} { if list.Flags().Lookup(name) == nil { t.Fatalf("expected flag %q to exist", name) } diff --git a/cmd/sure-cli/root/transactions_update_cmd.go b/cmd/sure-cli/root/transactions_update_cmd.go index 509509a..f918517 100644 --- a/cmd/sure-cli/root/transactions_update_cmd.go +++ b/cmd/sure-cli/root/transactions_update_cmd.go @@ -5,9 +5,9 @@ import ( "fmt" "time" + "github.com/spf13/cobra" "github.com/we-promise/sure-cli/internal/api" "github.com/we-promise/sure-cli/internal/output" - "github.com/spf13/cobra" ) type txUpdateOpts struct { @@ -41,7 +41,7 @@ func newTransactionsUpdateCmd() *cobra.Command { _ = output.Print(format, output.Envelope{Data: map[string]any{ "dry_run": true, "request": map[string]any{ - "method": "PUT", + "method": "PATCH", "path": path, "body": payload, }, @@ -51,8 +51,7 @@ func newTransactionsUpdateCmd() *cobra.Command { client := api.New() var res any - // resty supports Put via R().Put - r, err := client.Put(path, payload, &res) + r, err := client.Patch(path, payload, &res) if err != nil { output.Fail("request_failed", err.Error(), nil) } diff --git a/internal/api/client.go b/internal/api/client.go index 23cb2bd..a745864 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -142,6 +142,17 @@ func (c *Client) Put(path string, body any, out any) (*resty.Response, error) { return req.Put(path) } +func (c *Client) Patch(path string, body any, out any) (*resty.Response, error) { + if err := c.ensureFreshToken(); err != nil { + return nil, err + } + req := c.http.R().SetBody(body) + if out != nil { + req = req.SetResult(out) + } + return req.Patch(path) +} + func (c *Client) Delete(path string, out any) (*resty.Response, error) { if err := c.ensureFreshToken(); err != nil { return nil, err diff --git a/internal/api/client_test.go b/internal/api/client_test.go index b8d191d..66b8deb 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -201,3 +201,31 @@ func TestClient_PostMultipart_MismatchedArgs(t *testing.T) { t.Fatal("expected error for mismatched file arguments") } } + +func TestClient_Patch(t *testing.T) { + viper.Reset() + viper.Set("auth.mode", "api_key") + viper.Set("auth.api_key", "key_456") + _ = config.Init("/tmp/does-not-exist.yaml") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Fatalf("expected PATCH, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + viper.Set("api_url", srv.URL) + c := New() + var out map[string]any + _, err := c.Patch("/api/v1/transactions/tx_123", map[string]any{"transaction": map[string]any{"name": "x"}}, &out) + if err != nil { + t.Fatalf("Patch failed: %v", err) + } + if out["ok"] != true { + t.Fatalf("expected ok response, got %#v", out) + } +}