From a4468f8a11926d3b9b3ac6745123d67d13e4d430 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 5 May 2026 19:23:55 -0700 Subject: [PATCH] feat(investments): expand holdings trades and recurring commands Add holdings show and current filters, securities and security price reads, trade writes, and recurring transaction commands with dry-run-first writes. Remove the stale holdings performance surface and cover payload builders plus command registration. --- .gitignore | 2 +- README.md | 16 +- cmd/sure-cli/root/holdings_cmd.go | 83 +++--- cmd/sure-cli/root/investment_helpers.go | 112 ++++++++ cmd/sure-cli/root/investments_cmds.go | 284 +++++++++++++++++++++ cmd/sure-cli/root/investments_cmds_test.go | 41 +++ cmd/sure-cli/root/root.go | 3 + cmd/sure-cli/root/trades_cmd.go | 246 ++++++++++++++---- cmd/sure-cli/root/trades_cmd_test.go | 31 ++- internal/api/client.go | 11 + 10 files changed, 729 insertions(+), 100 deletions(-) create mode 100644 cmd/sure-cli/root/investment_helpers.go create mode 100644 cmd/sure-cli/root/investments_cmds.go create mode 100644 cmd/sure-cli/root/investments_cmds_test.go 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..bdad687 100644 --- a/README.md +++ b/README.md @@ -114,12 +114,24 @@ sure-cli export transactions --months 12 --format csv --out transactions.csv sure-cli status # Holdings (requires Sure investment API) -sure-cli holdings list -sure-cli holdings performance --period 1m +sure-cli holdings list --account-id --date 2026-05-01 +sure-cli holdings show + +# Securities and prices +sure-cli securities list --ticker AAPL +sure-cli securities show +sure-cli security-prices list --security-id --start-date 2026-01-01 # Trades (requires Sure investment API) sure-cli trades list sure-cli trades show +sure-cli trades create --account-id --date 2026-05-01 --type buy --qty 1 --price 100 --security-id +sure-cli trades create --account-id --date 2026-05-01 --type buy --qty 1 --price 100 --security-id --apply + +# Recurring transactions +sure-cli recurring-transactions list --status active +sure-cli recurring-transactions create --name Rent --last-occurrence-date 2026-04-01 --next-expected-date 2026-05-01 +sure-cli recurring-transactions create --name Rent --last-occurrence-date 2026-04-01 --next-expected-date 2026-05-01 --apply # Sync sure-cli sync diff --git a/cmd/sure-cli/root/holdings_cmd.go b/cmd/sure-cli/root/holdings_cmd.go index c95c38d..8f4eef3 100644 --- a/cmd/sure-cli/root/holdings_cmd.go +++ b/cmd/sure-cli/root/holdings_cmd.go @@ -2,77 +2,60 @@ package root import ( "fmt" + "net/url" - "github.com/we-promise/sure-cli/internal/api" - "github.com/we-promise/sure-cli/internal/output" "github.com/spf13/cobra" ) func newHoldingsCmd() *cobra.Command { cmd := &cobra.Command{Use: "holdings", Short: "Investment holdings (requires Sure API support)"} cmd.AddCommand(newHoldingsListCmd()) - cmd.AddCommand(newHoldingsPerformanceCmd()) + cmd.AddCommand(&cobra.Command{ + Use: "show ", + Short: "Show investment holding", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + printInvestmentGet(fmt.Sprintf("/api/v1/holdings/%s", url.PathEscape(args[0]))) + }, + }) return cmd } func newHoldingsListCmd() *cobra.Command { + var page, perPage int + var accountID, date, startDate, endDate, securityID string + var accountIDs []string cmd := &cobra.Command{ Use: "list", Short: "List investment holdings", Run: func(cmd *cobra.Command, args []string) { - client := api.New() - - // Try the holdings endpoint (may not exist in all Sure versions) - var res any - r, err := client.Get("/api/v1/holdings", &res) - if err != nil { - output.Fail("request_failed", err.Error(), nil) + q := url.Values{} + addInvestmentPagingQuery(q, page, perPage) + if accountID != "" { + q.Set("account_id", accountID) } - - if r.StatusCode() == 404 { - output.Fail("not_implemented", "Holdings API not available. This requires Sure with investment account support.", map[string]any{ - "endpoint": "/api/v1/holdings", - "hint": "Ensure your Sure instance supports investment accounts", - }) + addRepeatedInvestmentQuery(q, "account_ids", accountIDs) + if date != "" { + q.Set("date", date) } - - _ = output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}) - }, - } - return cmd -} - -func newHoldingsPerformanceCmd() *cobra.Command { - var period string - - cmd := &cobra.Command{ - Use: "performance", - Short: "Investment performance summary", - Run: func(cmd *cobra.Command, args []string) { - client := api.New() - - // Try the performance endpoint - path := "/api/v1/holdings/performance" - if period != "" { - path = fmt.Sprintf("%s?period=%s", path, period) + if startDate != "" { + q.Set("start_date", startDate) } - - var res any - r, err := client.Get(path, &res) - if err != nil { - output.Fail("request_failed", err.Error(), nil) + if endDate != "" { + q.Set("end_date", endDate) } - - if r.StatusCode() == 404 { - output.Fail("not_implemented", "Holdings performance API not available.", map[string]any{ - "endpoint": path, - "hint": "This feature requires Sure with investment tracking enabled", - }) + if securityID != "" { + q.Set("security_id", securityID) } - - _ = output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}) + printInvestmentGet(investmentPathWithQuery("/api/v1/holdings", q)) }, } - cmd.Flags().StringVar(&period, "period", "1m", "performance period (1w|1m|3m|6m|1y|ytd|all)") + addInvestmentPagingFlags(cmd, &page, &perPage) + cmd.Flags().StringVar(&accountID, "account-id", "", "account id") + cmd.Flags().StringSliceVar(&accountIDs, "account-ids", nil, "account ids (repeat or comma-separated)") + cmd.Flags().StringVar(&date, "date", "", "exact holding date (YYYY-MM-DD)") + cmd.Flags().StringVar(&startDate, "start-date", "", "start date (YYYY-MM-DD)") + cmd.Flags().StringVar(&endDate, "end-date", "", "end date (YYYY-MM-DD)") + cmd.Flags().StringVar(&securityID, "security-id", "", "security id") return cmd } diff --git a/cmd/sure-cli/root/investment_helpers.go b/cmd/sure-cli/root/investment_helpers.go new file mode 100644 index 0000000..ef59739 --- /dev/null +++ b/cmd/sure-cli/root/investment_helpers.go @@ -0,0 +1,112 @@ +package root + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + "github.com/we-promise/sure-cli/internal/api" + "github.com/we-promise/sure-cli/internal/output" +) + +func addInvestmentPagingFlags(cmd *cobra.Command, page, perPage *int) { + cmd.Flags().IntVar(page, "page", 1, "page number") + cmd.Flags().IntVar(perPage, "per-page", 25, "items per page (maps to per_page)") +} + +func addInvestmentPagingQuery(q url.Values, page, perPage int) { + if page > 0 { + q.Set("page", fmt.Sprintf("%d", page)) + } + if perPage > 0 { + q.Set("per_page", fmt.Sprintf("%d", perPage)) + } +} + +func addRepeatedInvestmentQuery(q url.Values, key string, values []string) { + for _, v := range values { + if v != "" { + q.Add(key+"[]", v) + } + } +} + +func investmentPathWithQuery(path string, q url.Values) string { + if encoded := q.Encode(); encoded != "" { + return path + "?" + encoded + } + return path +} + +func printInvestmentGet(path string) { + client := api.New() + var res any + r, err := client.Get(path, &res) + if err != nil { + output.Fail("request_failed", err.Error(), nil) + return + } + if err := output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}); err != nil { + output.Fail("output_failed", err.Error(), nil) + return + } +} + +func printInvestmentPost(path string, body any) { + client := api.New() + var res any + r, err := client.Post(path, body, &res) + if err != nil { + output.Fail("request_failed", err.Error(), nil) + return + } + if err := output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}); err != nil { + output.Fail("output_failed", err.Error(), nil) + return + } +} + +func printInvestmentPatch(path string, body any) { + client := api.New() + var res any + r, err := client.Patch(path, body, &res) + if err != nil { + output.Fail("request_failed", err.Error(), nil) + return + } + if err := output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}); err != nil { + output.Fail("output_failed", err.Error(), nil) + return + } +} + +func printInvestmentDelete(path string) { + client := api.New() + var res any + r, err := client.Delete(path, &res) + if err != nil { + output.Fail("request_failed", err.Error(), nil) + return + } + if err := output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}); err != nil { + output.Fail("output_failed", err.Error(), nil) + return + } +} + +func printInvestmentDryRun(method, path string, body any) { + request := map[string]any{ + "method": method, + "path": path, + } + if body != nil { + request["body"] = body + } + if err := output.Print(format, output.Envelope{Data: map[string]any{ + "dry_run": true, + "request": request, + }}); err != nil { + output.Fail("output_failed", err.Error(), nil) + return + } +} diff --git a/cmd/sure-cli/root/investments_cmds.go b/cmd/sure-cli/root/investments_cmds.go new file mode 100644 index 0000000..819709b --- /dev/null +++ b/cmd/sure-cli/root/investments_cmds.go @@ -0,0 +1,284 @@ +package root + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + "github.com/we-promise/sure-cli/internal/output" +) + +func newSecuritiesCmd() *cobra.Command { + cmd := &cobra.Command{Use: "securities", Short: "Securities"} + + var page, perPage int + var ticker, exchangeOperatingMIC, kind, offline string + list := &cobra.Command{ + Use: "list", + Short: "List securities", + Run: func(cmd *cobra.Command, args []string) { + q := url.Values{} + addInvestmentPagingQuery(q, page, perPage) + if ticker != "" { + q.Set("ticker", ticker) + } + if exchangeOperatingMIC != "" { + q.Set("exchange_operating_mic", exchangeOperatingMIC) + } + if kind != "" { + q.Set("kind", kind) + } + if offline != "" { + q.Set("offline", offline) + } + printInvestmentGet(investmentPathWithQuery("/api/v1/securities", q)) + }, + } + addInvestmentPagingFlags(list, &page, &perPage) + list.Flags().StringVar(&ticker, "ticker", "", "ticker filter") + list.Flags().StringVar(&exchangeOperatingMIC, "exchange-operating-mic", "", "exchange operating MIC filter") + list.Flags().StringVar(&kind, "kind", "", "security kind filter") + list.Flags().StringVar(&offline, "offline", "", "offline filter: true|false") + cmd.AddCommand(list) + + cmd.AddCommand(&cobra.Command{ + Use: "show ", + Short: "Show security", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + printInvestmentGet(fmt.Sprintf("/api/v1/securities/%s", url.PathEscape(args[0]))) + }, + }) + return cmd +} + +func newSecurityPricesCmd() *cobra.Command { + cmd := &cobra.Command{Use: "security-prices", Short: "Security prices"} + + var page, perPage int + var securityID, currency, startDate, endDate, provisional string + list := &cobra.Command{ + Use: "list", + Short: "List security price history", + Run: func(cmd *cobra.Command, args []string) { + q := url.Values{} + addInvestmentPagingQuery(q, page, perPage) + if securityID != "" { + q.Set("security_id", securityID) + } + if currency != "" { + q.Set("currency", currency) + } + if startDate != "" { + q.Set("start_date", startDate) + } + if endDate != "" { + q.Set("end_date", endDate) + } + if provisional != "" { + q.Set("provisional", provisional) + } + printInvestmentGet(investmentPathWithQuery("/api/v1/security_prices", q)) + }, + } + addInvestmentPagingFlags(list, &page, &perPage) + list.Flags().StringVar(&securityID, "security-id", "", "security id") + list.Flags().StringVar(¤cy, "currency", "", "currency") + list.Flags().StringVar(&startDate, "start-date", "", "start date (YYYY-MM-DD)") + list.Flags().StringVar(&endDate, "end-date", "", "end date (YYYY-MM-DD)") + list.Flags().StringVar(&provisional, "provisional", "", "provisional filter: true|false") + cmd.AddCommand(list) + + cmd.AddCommand(&cobra.Command{ + Use: "show ", + Short: "Show security price", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + printInvestmentGet(fmt.Sprintf("/api/v1/security_prices/%s", url.PathEscape(args[0]))) + }, + }) + return cmd +} + +type recurringCreateOpts struct { + Name string + Amount string + Currency string + AccountID string + MerchantID string + ExpectedDayOfMonth string + LastOccurrenceDate string + NextExpectedDate string + Status string + OccurrenceCount string + Manual string + ExpectedAmountMin string + ExpectedAmountMax string + ExpectedAmountAvg string + Apply bool +} + +type recurringUpdateOpts struct { + Status string + ExpectedDayOfMonth string + NextExpectedDate string + Apply bool +} + +func newRecurringTransactionsCmd() *cobra.Command { + cmd := &cobra.Command{Use: "recurring-transactions", Short: "Recurring transactions"} + + var page, perPage int + var status, accountID string + list := &cobra.Command{ + Use: "list", + Short: "List recurring transactions", + Run: func(cmd *cobra.Command, args []string) { + q := url.Values{} + addInvestmentPagingQuery(q, page, perPage) + if status != "" { + q.Set("status", status) + } + if accountID != "" { + q.Set("account_id", accountID) + } + printInvestmentGet(investmentPathWithQuery("/api/v1/recurring_transactions", q)) + }, + } + addInvestmentPagingFlags(list, &page, &perPage) + list.Flags().StringVar(&status, "status", "", "status filter") + list.Flags().StringVar(&accountID, "account-id", "", "account id filter") + cmd.AddCommand(list) + + cmd.AddCommand(&cobra.Command{ + Use: "show ", + Short: "Show recurring transaction", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + printInvestmentGet(fmt.Sprintf("/api/v1/recurring_transactions/%s", url.PathEscape(args[0]))) + }, + }) + cmd.AddCommand(newRecurringTransactionsCreateCmd()) + cmd.AddCommand(newRecurringTransactionsUpdateCmd()) + cmd.AddCommand(newRecurringTransactionsDeleteCmd()) + return cmd +} + +func newRecurringTransactionsCreateCmd() *cobra.Command { + var o recurringCreateOpts + cmd := &cobra.Command{ + Use: "create", + Short: "Create recurring transaction (default dry-run; use --apply to execute)", + Run: func(cmd *cobra.Command, args []string) { + payload, err := buildRecurringCreatePayload(o) + if err != nil { + output.Fail("validation_failed", err.Error(), nil) + return + } + path := "/api/v1/recurring_transactions" + if !o.Apply { + printInvestmentDryRun("POST", path, payload) + return + } + printInvestmentPost(path, payload) + }, + } + cmd.Flags().StringVar(&o.Name, "name", "", "name") + cmd.Flags().StringVar(&o.Amount, "amount", "", "amount") + cmd.Flags().StringVar(&o.Currency, "currency", "", "currency") + cmd.Flags().StringVar(&o.AccountID, "account-id", "", "account id") + cmd.Flags().StringVar(&o.MerchantID, "merchant-id", "", "merchant id") + cmd.Flags().StringVar(&o.ExpectedDayOfMonth, "expected-day-of-month", "", "expected day of month") + cmd.Flags().StringVar(&o.LastOccurrenceDate, "last-occurrence-date", "", "last occurrence date YYYY-MM-DD") + cmd.Flags().StringVar(&o.NextExpectedDate, "next-expected-date", "", "next expected date YYYY-MM-DD") + cmd.Flags().StringVar(&o.Status, "status", "", "status") + cmd.Flags().StringVar(&o.OccurrenceCount, "occurrence-count", "", "occurrence count") + cmd.Flags().StringVar(&o.Manual, "manual", "", "manual flag") + cmd.Flags().StringVar(&o.ExpectedAmountMin, "expected-amount-min", "", "expected amount min") + cmd.Flags().StringVar(&o.ExpectedAmountMax, "expected-amount-max", "", "expected amount max") + cmd.Flags().StringVar(&o.ExpectedAmountAvg, "expected-amount-avg", "", "expected amount avg") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the create (otherwise dry-run)") + return cmd +} + +func newRecurringTransactionsUpdateCmd() *cobra.Command { + var o recurringUpdateOpts + cmd := &cobra.Command{ + Use: "update ", + Short: "Update recurring transaction (default dry-run; use --apply to execute)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + payload, err := buildRecurringUpdatePayload(o) + if err != nil { + output.Fail("validation_failed", err.Error(), nil) + return + } + path := fmt.Sprintf("/api/v1/recurring_transactions/%s", url.PathEscape(args[0])) + if !o.Apply { + printInvestmentDryRun("PATCH", path, payload) + return + } + printInvestmentPatch(path, payload) + }, + } + cmd.Flags().StringVar(&o.Status, "status", "", "status") + cmd.Flags().StringVar(&o.ExpectedDayOfMonth, "expected-day-of-month", "", "expected day of month") + cmd.Flags().StringVar(&o.NextExpectedDate, "next-expected-date", "", "next expected date YYYY-MM-DD") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the update (otherwise dry-run)") + return cmd +} + +func newRecurringTransactionsDeleteCmd() *cobra.Command { + var apply bool + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete recurring transaction (default dry-run; use --apply to execute)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + path := fmt.Sprintf("/api/v1/recurring_transactions/%s", url.PathEscape(args[0])) + if !apply { + printInvestmentDryRun("DELETE", path, nil) + return + } + printInvestmentDelete(path) + }, + } + cmd.Flags().BoolVar(&apply, "apply", false, "execute the delete (otherwise dry-run)") + return cmd +} + +func buildRecurringCreatePayload(o recurringCreateOpts) (map[string]any, error) { + if o.LastOccurrenceDate == "" { + return nil, fmt.Errorf("last-occurrence-date is required") + } + if o.NextExpectedDate == "" { + return nil, fmt.Errorf("next-expected-date is required") + } + recurring := map[string]any{} + addAny(recurring, "name", o.Name) + addAny(recurring, "amount", o.Amount) + addAny(recurring, "currency", o.Currency) + addAny(recurring, "account_id", o.AccountID) + addAny(recurring, "merchant_id", o.MerchantID) + addAny(recurring, "expected_day_of_month", o.ExpectedDayOfMonth) + addAny(recurring, "last_occurrence_date", o.LastOccurrenceDate) + addAny(recurring, "next_expected_date", o.NextExpectedDate) + addAny(recurring, "status", o.Status) + addAny(recurring, "occurrence_count", o.OccurrenceCount) + addAny(recurring, "manual", o.Manual) + addAny(recurring, "expected_amount_min", o.ExpectedAmountMin) + addAny(recurring, "expected_amount_max", o.ExpectedAmountMax) + addAny(recurring, "expected_amount_avg", o.ExpectedAmountAvg) + return map[string]any{"recurring_transaction": recurring}, nil +} + +func buildRecurringUpdatePayload(o recurringUpdateOpts) (map[string]any, error) { + recurring := map[string]any{} + addAny(recurring, "status", o.Status) + addAny(recurring, "expected_day_of_month", o.ExpectedDayOfMonth) + addAny(recurring, "next_expected_date", o.NextExpectedDate) + if len(recurring) == 0 { + return nil, fmt.Errorf("no fields provided to update") + } + return map[string]any{"recurring_transaction": recurring}, nil +} diff --git a/cmd/sure-cli/root/investments_cmds_test.go b/cmd/sure-cli/root/investments_cmds_test.go new file mode 100644 index 0000000..81e5862 --- /dev/null +++ b/cmd/sure-cli/root/investments_cmds_test.go @@ -0,0 +1,41 @@ +package root + +import "testing" + +func TestBuildRecurringCreatePayloadRequiresDates(t *testing.T) { + if _, err := buildRecurringCreatePayload(recurringCreateOpts{}); err == nil { + t.Fatal("expected missing dates error") + } + payload, err := buildRecurringCreatePayload(recurringCreateOpts{ + Name: "Rent", + Amount: "1200", + LastOccurrenceDate: "2026-04-01", + NextExpectedDate: "2026-05-01", + ExpectedDayOfMonth: "1", + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + recurring, ok := payload["recurring_transaction"].(map[string]any) + if !ok || recurring == nil { + t.Fatalf("expected recurring_transaction payload, got %#v", payload["recurring_transaction"]) + } + if recurring["name"] != "Rent" || recurring["next_expected_date"] != "2026-05-01" { + t.Fatalf("unexpected recurring payload: %#v", recurring) + } +} + +func TestInvestmentCommandsRegistered(t *testing.T) { + cmd := New() + for _, args := range [][]string{ + {"holdings", "show"}, + {"securities", "list"}, + {"security-prices", "show"}, + {"trades", "create"}, + {"recurring-transactions", "delete"}, + } { + if _, _, err := cmd.Find(args); err != nil { + t.Fatalf("expected command %v: %v", args, err) + } + } +} diff --git a/cmd/sure-cli/root/root.go b/cmd/sure-cli/root/root.go index 0343a6c..ea79310 100644 --- a/cmd/sure-cli/root/root.go +++ b/cmd/sure-cli/root/root.go @@ -55,7 +55,10 @@ func New() *cobra.Command { cmd.AddCommand(newExportCmd()) cmd.AddCommand(newStatusCmd()) cmd.AddCommand(newHoldingsCmd()) + cmd.AddCommand(newSecuritiesCmd()) + cmd.AddCommand(newSecurityPricesCmd()) cmd.AddCommand(newTradesCmd()) + cmd.AddCommand(newRecurringTransactionsCmd()) cmd.AddCommand(&cobra.Command{ Use: "version", Short: "Print version information", diff --git a/cmd/sure-cli/root/trades_cmd.go b/cmd/sure-cli/root/trades_cmd.go index 53df7fd..23ba357 100644 --- a/cmd/sure-cli/root/trades_cmd.go +++ b/cmd/sure-cli/root/trades_cmd.go @@ -5,7 +5,6 @@ import ( "net/url" "github.com/spf13/cobra" - "github.com/we-promise/sure-cli/internal/api" "github.com/we-promise/sure-cli/internal/output" ) @@ -13,60 +12,50 @@ 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", Short: "List trades", Run: func(cmd *cobra.Command, args []string) { - client := api.New() - q := url.Values{} - if from != "" { - q.Set("from", from) - } - if to != "" { - q.Set("to", to) + if startDate == "" { + startDate = from } - if account != "" { - q.Set("account", account) + if endDate == "" { + endDate = to } - if symbol != "" { - q.Set("symbol", symbol) + if accountID == "" { + accountID = account } - if page > 0 { - q.Set("page", fmt.Sprintf("%d", page)) + if startDate != "" { + q.Set("start_date", startDate) } - if perPage > 0 { - q.Set("per_page", fmt.Sprintf("%d", perPage)) + if endDate != "" { + q.Set("end_date", endDate) } - if limit > 0 { - q.Set("limit", fmt.Sprintf("%d", limit)) + if accountID != "" { + q.Set("account_id", accountID) } + addRepeatedInvestmentQuery(q, "account_ids", accountIDs) + addInvestmentPagingQuery(q, page, perPage) u := url.URL{Path: "/api/v1/trades", RawQuery: q.Encode()} - path := u.String() - - var res any - r, err := client.Get(path, &res) - if err != nil { - output.Fail("request_failed", err.Error(), nil) - } - if err := output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}); err != nil { - output.Fail("output_failed", err.Error(), nil) - } + printInvestmentGet(u.String()) }, } 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().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") + 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)") + addInvestmentPagingFlags(list, &page, &perPage) cmd.AddCommand(list) cmd.AddCommand(&cobra.Command{ @@ -74,18 +63,189 @@ func newTradesCmd() *cobra.Command { Short: "Show trade", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - client := api.New() - var res any - path := fmt.Sprintf("/api/v1/trades/%s", args[0]) - r, err := client.Get(path, &res) + path := fmt.Sprintf("/api/v1/trades/%s", url.PathEscape(args[0])) + printInvestmentGet(path) + }, + }) + + cmd.AddCommand(newTradesCreateCmd()) + cmd.AddCommand(newTradesUpdateCmd()) + cmd.AddCommand(newTradesDeleteCmd()) + + return cmd +} + +type tradeCreateOpts struct { + AccountID string + Date string + Type string + Qty string + Price string + Currency string + SecurityID string + Ticker string + ManualTicker string + InvestmentActivityLabel string + CategoryID string + Apply bool +} + +type tradeUpdateOpts struct { + Name string + Date string + Amount string + Currency string + Notes string + Nature string + Type string + Qty string + Price string + InvestmentActivityLabel string + CategoryID string + Apply bool +} + +func newTradesCreateCmd() *cobra.Command { + var o tradeCreateOpts + cmd := &cobra.Command{ + Use: "create", + Short: "Create trade (default dry-run; use --apply to execute)", + Run: func(cmd *cobra.Command, args []string) { + payload, err := buildTradeCreatePayload(o) if err != nil { - output.Fail("request_failed", err.Error(), nil) + output.Fail("validation_failed", err.Error(), nil) + return } - if err := output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}); err != nil { - output.Fail("output_failed", err.Error(), nil) + path := "/api/v1/trades" + if !o.Apply { + printInvestmentDryRun("POST", path, payload) + return } + printInvestmentPost(path, payload) }, - }) + } + cmd.Flags().StringVar(&o.AccountID, "account-id", "", "account id (required)") + cmd.Flags().StringVar(&o.Date, "date", "", "date YYYY-MM-DD (required)") + cmd.Flags().StringVar(&o.Type, "type", "", "trade type: buy|sell (required)") + cmd.Flags().StringVar(&o.Qty, "qty", "", "quantity (required)") + cmd.Flags().StringVar(&o.Price, "price", "", "price (required)") + cmd.Flags().StringVar(&o.Currency, "currency", "", "currency") + cmd.Flags().StringVar(&o.SecurityID, "security-id", "", "security id") + cmd.Flags().StringVar(&o.Ticker, "ticker", "", "ticker") + cmd.Flags().StringVar(&o.ManualTicker, "manual-ticker", "", "manual ticker") + cmd.Flags().StringVar(&o.InvestmentActivityLabel, "investment-activity-label", "", "investment activity label") + cmd.Flags().StringVar(&o.CategoryID, "category-id", "", "category id") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the create (otherwise dry-run)") + return cmd +} +func newTradesUpdateCmd() *cobra.Command { + var o tradeUpdateOpts + cmd := &cobra.Command{ + Use: "update ", + Short: "Update trade (default dry-run; use --apply to execute)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + payload, err := buildTradeUpdatePayload(o) + if err != nil { + output.Fail("validation_failed", err.Error(), nil) + return + } + path := fmt.Sprintf("/api/v1/trades/%s", url.PathEscape(args[0])) + if !o.Apply { + printInvestmentDryRun("PATCH", path, payload) + return + } + printInvestmentPatch(path, payload) + }, + } + cmd.Flags().StringVar(&o.Name, "name", "", "name") + cmd.Flags().StringVar(&o.Date, "date", "", "date YYYY-MM-DD") + cmd.Flags().StringVar(&o.Amount, "amount", "", "amount") + cmd.Flags().StringVar(&o.Currency, "currency", "", "currency") + cmd.Flags().StringVar(&o.Notes, "notes", "", "notes") + cmd.Flags().StringVar(&o.Nature, "nature", "", "nature: inflow|outflow") + cmd.Flags().StringVar(&o.Type, "type", "", "trade type: buy|sell") + cmd.Flags().StringVar(&o.Qty, "qty", "", "quantity") + cmd.Flags().StringVar(&o.Price, "price", "", "price") + cmd.Flags().StringVar(&o.InvestmentActivityLabel, "investment-activity-label", "", "investment activity label") + cmd.Flags().StringVar(&o.CategoryID, "category-id", "", "category id") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the update (otherwise dry-run)") return cmd } + +func newTradesDeleteCmd() *cobra.Command { + var apply bool + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete trade (default dry-run; use --apply to execute)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + path := fmt.Sprintf("/api/v1/trades/%s", url.PathEscape(args[0])) + if !apply { + printInvestmentDryRun("DELETE", path, nil) + return + } + printInvestmentDelete(path) + }, + } + cmd.Flags().BoolVar(&apply, "apply", false, "execute the delete (otherwise dry-run)") + return cmd +} + +func buildTradeCreatePayload(o tradeCreateOpts) (map[string]any, error) { + if o.AccountID == "" { + return nil, fmt.Errorf("account-id is required") + } + if o.Date == "" { + return nil, fmt.Errorf("date is required") + } + if o.Type != "buy" && o.Type != "sell" { + return nil, fmt.Errorf("type must be buy or sell") + } + if o.Qty == "" || o.Price == "" { + return nil, fmt.Errorf("qty and price are required") + } + if o.SecurityID == "" && o.Ticker == "" && o.ManualTicker == "" { + return nil, fmt.Errorf("one of security-id, ticker, or manual-ticker is required") + } + trade := map[string]any{ + "account_id": o.AccountID, + "date": o.Date, + "type": o.Type, + "qty": o.Qty, + "price": o.Price, + } + addAny(trade, "currency", o.Currency) + addAny(trade, "security_id", o.SecurityID) + addAny(trade, "ticker", o.Ticker) + addAny(trade, "manual_ticker", o.ManualTicker) + addAny(trade, "investment_activity_label", o.InvestmentActivityLabel) + addAny(trade, "category_id", o.CategoryID) + return map[string]any{"trade": trade}, nil +} + +func buildTradeUpdatePayload(o tradeUpdateOpts) (map[string]any, error) { + trade := map[string]any{} + addAny(trade, "name", o.Name) + addAny(trade, "date", o.Date) + addAny(trade, "amount", o.Amount) + addAny(trade, "currency", o.Currency) + addAny(trade, "notes", o.Notes) + addAny(trade, "nature", o.Nature) + addAny(trade, "type", o.Type) + addAny(trade, "qty", o.Qty) + addAny(trade, "price", o.Price) + addAny(trade, "investment_activity_label", o.InvestmentActivityLabel) + addAny(trade, "category_id", o.CategoryID) + if len(trade) == 0 { + return nil, fmt.Errorf("no fields provided to update") + } + return map[string]any{"trade": trade}, nil +} + +func addAny(m map[string]any, key, value string) { + if value != "" { + m[key] = value + } +} diff --git a/cmd/sure-cli/root/trades_cmd_test.go b/cmd/sure-cli/root/trades_cmd_test.go index 3c92702..f317a9f 100644 --- a/cmd/sure-cli/root/trades_cmd_test.go +++ b/cmd/sure-cli/root/trades_cmd_test.go @@ -13,8 +13,7 @@ func TestTradesList_Flags(t *testing.T) { t.Fatalf("find list subcommand: %v", err) } - // 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 +25,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") } } @@ -44,3 +43,27 @@ func TestTradesShow_Args(t *testing.T) { t.Fatal("expected Args validator to be set") } } + +func TestBuildTradeCreatePayloadRequiresSecurityIdentifier(t *testing.T) { + _, err := buildTradeCreatePayload(tradeCreateOpts{ + AccountID: "acc_123", + Date: "2026-05-01", + Type: "buy", + Qty: "1", + Price: "10", + }) + if err == nil { + t.Fatal("expected missing security identifier error") + } +} + +func TestBuildTradeUpdatePayload(t *testing.T) { + payload, err := buildTradeUpdatePayload(tradeUpdateOpts{Qty: "2", Price: "11.50", Type: "sell"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + trade := payload["trade"].(map[string]any) + if trade["qty"] != "2" || trade["price"] != "11.50" || trade["type"] != "sell" { + t.Fatalf("unexpected trade payload: %#v", trade) + } +} 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