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..6c75fcc 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,13 @@ sure-cli propose rules --months 3 --apply --min-confidence 0.8 # Export sure-cli export transactions --months 12 --format csv --out transactions.csv +# Financial history +sure-cli balance-sheet show +sure-cli balances list --account-id --start-date 2026-01-01 +sure-cli family-settings show +sure-cli valuations create --account-id --amount 123.45 --date 2026-05-01 +sure-cli valuations create --account-id --amount 123.45 --date 2026-05-01 --upsert --apply + # Status (financial snapshot) sure-cli status diff --git a/cmd/sure-cli/root/financial_cmds.go b/cmd/sure-cli/root/financial_cmds.go new file mode 100644 index 0000000..1a0062e --- /dev/null +++ b/cmd/sure-cli/root/financial_cmds.go @@ -0,0 +1,310 @@ +package root + +import ( + "fmt" + "net/url" + "time" + + "github.com/spf13/cobra" + "github.com/we-promise/sure-cli/internal/api" + "github.com/we-promise/sure-cli/internal/output" +) + +func newBalanceSheetCmd() *cobra.Command { + cmd := &cobra.Command{Use: "balance-sheet", Short: "Balance sheet"} + cmd.AddCommand(&cobra.Command{ + Use: "show", + Short: "Show balance sheet", + Run: func(cmd *cobra.Command, args []string) { + printFinancialGet("/api/v1/balance_sheet") + }, + }) + return cmd +} + +func newBalancesCmd() *cobra.Command { + cmd := &cobra.Command{Use: "balances", Short: "Balance history"} + + var page, perPage int + var accountID, currency, startDate, endDate string + list := &cobra.Command{ + Use: "list", + Short: "List balance history records", + Run: func(cmd *cobra.Command, args []string) { + q := url.Values{} + addFinancialPagingQuery(q, page, perPage) + if accountID != "" { + q.Set("account_id", accountID) + } + if currency != "" { + q.Set("currency", currency) + } + if startDate != "" { + q.Set("start_date", startDate) + } + if endDate != "" { + q.Set("end_date", endDate) + } + printFinancialGet(financialPathWithQuery("/api/v1/balances", q)) + }, + } + addFinancialPagingFlags(list, &page, &perPage) + list.Flags().StringVar(&accountID, "account-id", "", "account 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)") + cmd.AddCommand(list) + + cmd.AddCommand(&cobra.Command{ + Use: "show ", + Short: "Show balance history record", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + printFinancialGet(fmt.Sprintf("/api/v1/balances/%s", url.PathEscape(args[0]))) + }, + }) + return cmd +} + +func newFamilySettingsCmd() *cobra.Command { + cmd := &cobra.Command{Use: "family-settings", Short: "Family settings"} + cmd.AddCommand(&cobra.Command{ + Use: "show", + Short: "Show family settings", + Run: func(cmd *cobra.Command, args []string) { + printFinancialGet("/api/v1/family_settings") + }, + }) + return cmd +} + +type valuationCreateOpts struct { + AccountID string + Amount string + Date string + Notes string + Upsert bool + Apply bool +} + +type valuationUpdateOpts struct { + Amount string + Date string + Notes string + Apply bool +} + +func newValuationsCmd() *cobra.Command { + cmd := &cobra.Command{Use: "valuations", Short: "Valuations"} + + var page, perPage int + var accountID, startDate, endDate string + list := &cobra.Command{ + Use: "list", + Short: "List valuations", + Run: func(cmd *cobra.Command, args []string) { + q := url.Values{} + addFinancialPagingQuery(q, page, perPage) + if accountID != "" { + q.Set("account_id", accountID) + } + if startDate != "" { + q.Set("start_date", startDate) + } + if endDate != "" { + q.Set("end_date", endDate) + } + printFinancialGet(financialPathWithQuery("/api/v1/valuations", q)) + }, + } + addFinancialPagingFlags(list, &page, &perPage) + list.Flags().StringVar(&accountID, "account-id", "", "account id") + list.Flags().StringVar(&startDate, "start-date", "", "start date (YYYY-MM-DD)") + list.Flags().StringVar(&endDate, "end-date", "", "end date (YYYY-MM-DD)") + cmd.AddCommand(list) + + cmd.AddCommand(&cobra.Command{ + Use: "show ", + Short: "Show valuation", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + printFinancialGet(fmt.Sprintf("/api/v1/valuations/%s", url.PathEscape(args[0]))) + }, + }) + + cmd.AddCommand(newValuationsCreateCmd()) + cmd.AddCommand(newValuationsUpdateCmd()) + return cmd +} + +func newValuationsCreateCmd() *cobra.Command { + var o valuationCreateOpts + cmd := &cobra.Command{ + Use: "create", + Short: "Create valuation (default dry-run; use --apply to execute)", + Run: func(cmd *cobra.Command, args []string) { + payload, err := buildValuationCreatePayload(o) + if err != nil { + output.Fail("validation_failed", err.Error(), nil) + } + path := "/api/v1/valuations" + if !o.Apply { + printFinancialDryRun("POST", path, payload) + return + } + printFinancialPost(path, payload) + }, + } + cmd.Flags().StringVar(&o.AccountID, "account-id", "", "account id (required)") + cmd.Flags().StringVar(&o.Amount, "amount", "", "valuation amount (required)") + cmd.Flags().StringVar(&o.Date, "date", time.Now().Format("2006-01-02"), "date YYYY-MM-DD") + cmd.Flags().StringVar(&o.Notes, "notes", "", "notes") + cmd.Flags().BoolVar(&o.Upsert, "upsert", false, "request upsert response semantics") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the create (otherwise dry-run)") + return cmd +} + +func newValuationsUpdateCmd() *cobra.Command { + var o valuationUpdateOpts + cmd := &cobra.Command{ + Use: "update ", + Short: "Update valuation (default dry-run; use --apply to execute)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + payload, err := buildValuationUpdatePayload(o) + if err != nil { + output.Fail("validation_failed", err.Error(), nil) + } + path := fmt.Sprintf("/api/v1/valuations/%s", url.PathEscape(args[0])) + if !o.Apply { + printFinancialDryRun("PATCH", path, payload) + return + } + printFinancialPatch(path, payload) + }, + } + cmd.Flags().StringVar(&o.Amount, "amount", "", "valuation amount") + cmd.Flags().StringVar(&o.Date, "date", "", "date YYYY-MM-DD") + cmd.Flags().StringVar(&o.Notes, "notes", "", "notes") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the update (otherwise dry-run)") + return cmd +} + +func buildValuationCreatePayload(o valuationCreateOpts) (map[string]any, error) { + if o.AccountID == "" { + return nil, fmt.Errorf("account-id is required") + } + if o.Amount == "" { + return nil, fmt.Errorf("amount is required") + } + if _, err := time.Parse("2006-01-02", o.Date); err != nil { + return nil, fmt.Errorf("invalid date (expected YYYY-MM-DD): %w", err) + } + valuation := map[string]any{ + "account_id": o.AccountID, + "amount": o.Amount, + "date": o.Date, + } + if o.Notes != "" { + valuation["notes"] = o.Notes + } + payload := map[string]any{"valuation": valuation} + if o.Upsert { + payload["upsert"] = true + } + return payload, nil +} + +func buildValuationUpdatePayload(o valuationUpdateOpts) (map[string]any, error) { + if o.Amount == "" && o.Date == "" && o.Notes == "" { + return nil, fmt.Errorf("no fields provided to update") + } + if (o.Amount == "") != (o.Date == "") { + return nil, fmt.Errorf("amount and date must both be provided when updating valuation amount") + } + valuation := map[string]any{} + if o.Amount != "" { + if _, err := time.Parse("2006-01-02", o.Date); err != nil { + return nil, fmt.Errorf("invalid date (expected YYYY-MM-DD): %w", err) + } + valuation["amount"] = o.Amount + valuation["date"] = o.Date + } + if o.Notes != "" { + valuation["notes"] = o.Notes + } + return map[string]any{"valuation": valuation}, nil +} + +func addFinancialPagingFlags(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 addFinancialPagingQuery(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 financialPathWithQuery(path string, q url.Values) string { + if encoded := q.Encode(); encoded != "" { + return path + "?" + encoded + } + return path +} + +func printFinancialGet(path string) { + client := api.New() + 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) + } +} + +func printFinancialPost(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) + } + if err := output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}); err != nil { + output.Fail("output_failed", err.Error(), nil) + } +} + +func printFinancialPatch(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) + } + if err := output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}}); err != nil { + output.Fail("output_failed", err.Error(), nil) + } +} + +func printFinancialDryRun(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) + } +} diff --git a/cmd/sure-cli/root/financial_cmds_test.go b/cmd/sure-cli/root/financial_cmds_test.go new file mode 100644 index 0000000..5b7ec0a --- /dev/null +++ b/cmd/sure-cli/root/financial_cmds_test.go @@ -0,0 +1,51 @@ +package root + +import "testing" + +func TestBuildValuationCreatePayloadUpsert(t *testing.T) { + payload, err := buildValuationCreatePayload(valuationCreateOpts{ + AccountID: "acc_123", + Amount: "123.45", + Date: "2026-05-01", + Notes: "month end", + Upsert: true, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if payload["upsert"] != true { + t.Fatalf("expected upsert=true") + } + valuation := payload["valuation"].(map[string]any) + if valuation["account_id"] != "acc_123" || valuation["amount"] != "123.45" { + t.Fatalf("unexpected valuation payload: %#v", valuation) + } +} + +func TestBuildValuationUpdatePayloadRequiresAmountAndDateTogether(t *testing.T) { + if _, err := buildValuationUpdatePayload(valuationUpdateOpts{Amount: "1.23"}); err == nil { + t.Fatal("expected missing date error") + } + payload, err := buildValuationUpdatePayload(valuationUpdateOpts{Notes: "only notes"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + valuation := payload["valuation"].(map[string]any) + if valuation["notes"] != "only notes" { + t.Fatalf("unexpected valuation payload: %#v", valuation) + } +} + +func TestFinancialCommandsRegistered(t *testing.T) { + cmd := New() + for _, args := range [][]string{ + {"balance-sheet", "show"}, + {"balances", "list"}, + {"family-settings", "show"}, + {"valuations", "create"}, + } { + 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..959e23f 100644 --- a/cmd/sure-cli/root/root.go +++ b/cmd/sure-cli/root/root.go @@ -48,6 +48,10 @@ func New() *cobra.Command { cmd.AddCommand(newAccountsCmd()) cmd.AddCommand(newTransactionsCmd()) cmd.AddCommand(newImportsCmd()) + cmd.AddCommand(newBalanceSheetCmd()) + cmd.AddCommand(newBalancesCmd()) + cmd.AddCommand(newFamilySettingsCmd()) + cmd.AddCommand(newValuationsCmd()) cmd.AddCommand(newSyncCmd()) cmd.AddCommand(newInsightsCmd()) cmd.AddCommand(newPlanCmd()) diff --git a/cmd/sure-cli/root/status_cmd.go b/cmd/sure-cli/root/status_cmd.go index 0e0677c..2e41a31 100644 --- a/cmd/sure-cli/root/status_cmd.go +++ b/cmd/sure-cli/root/status_cmd.go @@ -4,11 +4,11 @@ import ( "fmt" "time" + "github.com/spf13/cobra" "github.com/we-promise/sure-cli/internal/api" "github.com/we-promise/sure-cli/internal/insights" "github.com/we-promise/sure-cli/internal/output" "github.com/we-promise/sure-cli/internal/plan" - "github.com/spf13/cobra" ) func newStatusCmd() *cobra.Command { @@ -29,26 +29,37 @@ func newStatusCmd() *cobra.Command { var totalBalance float64 var cashBalance float64 var accountSummaries []map[string]any - _ = "" // unused vars placeholder + primaryCurrency := "" for _, acc := range accounts { a, _ := acc.(map[string]any) name := fmt.Sprint(a["name"]) balance := fmt.Sprint(a["balance"]) + cashBalanceText := fmt.Sprint(a["cash_balance"]) accType := fmt.Sprint(a["account_type"]) + currency := fmt.Sprint(a["currency"]) + if primaryCurrency == "" && currency != "" && currency != "" { + primaryCurrency = currency + } - bal, _ := insights.ParseAmountEUR(balance) + bal, _ := amountFromAPI(a, "balance", "balance_cents") totalBalance += bal accountSummaries = append(accountSummaries, map[string]any{ - "name": name, - "type": accType, - "balance": balance, + "name": name, + "type": accType, + "balance": balance, + "cash_balance": cashBalanceText, + "currency": currency, }) // Track cash accounts for runway if accType == "depository" || accType == "checking" || accType == "savings" { - cashBalance += bal + cash, ok := amountFromAPIOK(a, "cash_balance", "cash_balance_cents") + if !ok { + cash = bal + } + cashBalance += cash } } @@ -64,7 +75,7 @@ func newStatusCmd() *cobra.Command { var monthlySpend float64 var monthlyIncome float64 for _, tx := range txs { - amt, err := insights.ParseAmountEUR(tx.AmountText) + amt, err := insights.ParseAmount(tx.AmountText) if err != nil { continue } @@ -117,27 +128,27 @@ func newStatusCmd() *cobra.Command { "as_of": time.Now().UTC().Format(time.RFC3339), "accounts": map[string]any{ "count": len(accounts), - "total_balance": fmt.Sprintf("€%.2f", totalBalance), - "cash_balance": fmt.Sprintf("€%.2f", cashBalance), + "total_balance": formatMoneyValue(totalBalance, primaryCurrency), + "cash_balance": formatMoneyValue(cashBalance, primaryCurrency), "list": accountSummaries, }, "monthly": map[string]any{ - "income": fmt.Sprintf("€%.2f", monthlyIncome), - "expenses": fmt.Sprintf("€%.2f", monthlySpend), - "net": fmt.Sprintf("€%.2f", monthlyIncome-monthlySpend), - "subscriptions": fmt.Sprintf("€%.2f", monthlySubscriptions), + "income": formatMoneyValue(monthlyIncome, primaryCurrency), + "expenses": formatMoneyValue(monthlySpend, primaryCurrency), + "net": formatMoneyValue(monthlyIncome-monthlySpend, primaryCurrency), + "subscriptions": formatMoneyValue(monthlySubscriptions, primaryCurrency), }, "runway": map[string]any{ "months": runwayMonths, - "cash_balance": fmt.Sprintf("€%.2f", cashBalance), - "burn_rate": fmt.Sprintf("€%.2f/month", monthlySpend), + "cash_balance": formatMoneyValue(cashBalance, primaryCurrency), + "burn_rate": fmt.Sprintf("%s/month", formatMoneyValue(monthlySpend, primaryCurrency)), }, "budget_pacing": map[string]any{ "month": budgetResult.Month, "days_elapsed": budgetResult.DaysElapsed, - "spent": fmt.Sprintf("€%.2f", budgetResult.Spent), - "projected": fmt.Sprintf("€%.2f", budgetResult.Projected), - "avg_per_day": fmt.Sprintf("€%.2f", budgetResult.AvgPerDay), + "spent": formatMoneyValue(budgetResult.Spent, primaryCurrency), + "projected": formatMoneyValue(budgetResult.Projected, primaryCurrency), + "avg_per_day": formatMoneyValue(budgetResult.AvgPerDay, primaryCurrency), }, "alerts": alerts, "alert_count": len(alerts), @@ -155,3 +166,40 @@ func absFloat(v float64) float64 { } return v } + +func amountFromAPI(m map[string]any, formattedKey, centsKey string) (float64, error) { + amount, ok := amountFromAPIOK(m, formattedKey, centsKey) + if ok { + return amount, nil + } + return 0, fmt.Errorf("missing amount") +} + +func amountFromAPIOK(m map[string]any, formattedKey, centsKey string) (float64, bool) { + switch v := m[centsKey].(type) { + case float64: + return v / 100.0, true + case int: + return float64(v) / 100.0, true + case int64: + return float64(v) / 100.0, true + case string: + var n float64 + if _, err := fmt.Sscanf(v, "%f", &n); err == nil { + return n / 100.0, true + } + } + text := fmt.Sprint(m[formattedKey]) + if text == "" || text == "" { + return 0, false + } + amount, err := insights.ParseAmount(text) + return amount, err == nil +} + +func formatMoneyValue(value float64, currency string) string { + if currency == "" || currency == "" { + return fmt.Sprintf("%.2f", value) + } + return fmt.Sprintf("%.2f %s", value, currency) +} 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/insights/amount.go b/internal/insights/amount.go index 33f8c60..cff0e89 100644 --- a/internal/insights/amount.go +++ b/internal/insights/amount.go @@ -4,34 +4,43 @@ import ( "errors" "strconv" "strings" + "unicode" ) -// ParseAmountEUR parses Sure API amount strings like "€112.00", "-€2.00", "€1,23". -// Returns the numeric value as float64. -func ParseAmountEUR(s string) (float64, error) { +// ParseAmount parses formatted money strings such as "$112.00", "€1,23", or "-£2.00". +// Currency symbols and spaces are ignored; the returned value is numeric only. +func ParseAmount(s string) (float64, error) { s = strings.TrimSpace(s) if s == "" { return 0, errors.New("empty amount") } + var b strings.Builder + for _, r := range s { + if unicode.IsDigit(r) || r == '-' || r == '+' || r == '.' || r == ',' { + b.WriteRune(r) + } + } + cleaned := b.String() + if cleaned == "" || cleaned == "-" || cleaned == "+" { + return 0, errors.New("empty amount") + } + neg := false - if strings.HasPrefix(s, "-") { + if strings.HasPrefix(cleaned, "-") { neg = true - s = strings.TrimPrefix(s, "-") + cleaned = strings.TrimPrefix(cleaned, "-") + } else { + cleaned = strings.TrimPrefix(cleaned, "+") } - // remove currency symbol and spaces - s = strings.ReplaceAll(s, "€", "") - s = strings.ReplaceAll(s, " ", "") - // Handle separators: - // - If both ',' and '.' present, assume ',' is thousands separator (US style: 2,000.00) -> remove commas. - // - If only ',' present, assume decimal comma -> replace with '.' - if strings.Contains(s, ",") && strings.Contains(s, ".") { - s = strings.ReplaceAll(s, ",", "") + + if strings.Contains(cleaned, ",") && strings.Contains(cleaned, ".") { + cleaned = strings.ReplaceAll(cleaned, ",", "") } else { - s = strings.ReplaceAll(s, ",", ".") + cleaned = strings.ReplaceAll(cleaned, ",", ".") } - v, err := strconv.ParseFloat(s, 64) + v, err := strconv.ParseFloat(cleaned, 64) if err != nil { return 0, err } @@ -41,10 +50,16 @@ func ParseAmountEUR(s string) (float64, error) { return v, nil } +// ParseAmountEUR parses Sure API amount strings like "€112.00", "-€2.00", "€1,23". +// Returns the numeric value as float64. +func ParseAmountEUR(s string) (float64, error) { + return ParseAmount(s) +} + // SignedAmount normalizes to agent-friendly sign: expense negative, income positive. // (Sure stores expenses as positive entries internally; API amount strings appear inverted vs UI.) func SignedAmount(t Transaction) (float64, error) { - v, err := ParseAmountEUR(t.AmountText) + v, err := ParseAmount(t.AmountText) if err != nil { return 0, err }