From 66173d9ff02e5595e03fc8f285281f5801011917 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Tue, 24 Mar 2026 16:40:25 +0200 Subject: [PATCH] feat: add action confirmations --- .prettierignore | 2 + .prettierrc | 15 +++++ README.md | 62 +++++++++++-------- cmd/add.go | 92 ++++++++++++++++------------ cmd/common.go | 19 ++++++ cmd/common_test.go | 38 ++++++++++++ cmd/config.go | 40 ++++++++++++- cmd/config_test.go | 123 ++++++++++++++++++++++++++++++++++++++ cmd/delete.go | 62 +++++++++++++++++++ cmd/edit.go | 88 ++++++++++++++++----------- internal/config/config.go | 12 ++++ 11 files changed, 451 insertions(+), 102 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 cmd/common_test.go diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c72ceb4 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +gen/ +CHANGELOG.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..548c817 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "overrides": [ + { + "files": "*.md", + "options": { + "printWidth": 100, + "proseWrap": "always" + } + } + ] +} diff --git a/README.md b/README.md index 5797520..bdaf5ea 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ add and list expenses directly from your terminal without opening the web interf - **Currency code support** (e.g., `usd`, `eur`, `gbp`) with automatic symbol resolution - **Local caching** of project data with 1-hour TTL for faster subsequent calls - **Default project** - set once with `config set`, no need to pass `-p` every time +- **Optional confirmations** - preview and confirm before adding, deleting, or updating expenses - **Global project flag** - set `-p` before the command for easy shell aliases - **Secure browser login** - OAuth-style authentication with 2FA support - Cross-platform support: **macOS**, **Linux**, and **Windows** @@ -204,18 +205,18 @@ cospend add "Gym" 50.00 -p myproject -r w # weekly #### Add Command Flags -| Short | Long | Description | -| ----- | ------------ | --------------------------------------------------------- | -| `-p` | `--project` | Project ID (required) | -| `-c` | `--category` | Category by ID or case-insensitive name | -| `-b` | `--by` | Paying member username (defaults to authenticated user) | -| `-f` | `--for` | Owed member username (repeatable; defaults to payer only) | -| `-C` | `--convert` | Currency to convert to (by ID, name, or code like `usd`) | -| `-m` | `--method` | Payment method by ID or case-insensitive name | -| `-o` | `--comment` | Additional details about the bill | -| `-d` | `--date` | Date of expense (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) | +| Short | Long | Description | +| ----- | ------------ | ------------------------------------------------------------------------------------------------------------ | +| `-p` | `--project` | Project ID (required) | +| `-c` | `--category` | Category by ID or case-insensitive name | +| `-b` | `--by` | Paying member username (defaults to authenticated user) | +| `-f` | `--for` | Owed member username (repeatable; defaults to payer only) | +| `-C` | `--convert` | Currency to convert to (by ID, name, or code like `usd`) | +| `-m` | `--method` | Payment method by ID or case-insensitive name | +| `-o` | `--comment` | Additional details about the bill | +| `-d` | `--date` | Date of expense (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) | | `-r` | `--repeat` | Repeat frequency: `d` (daily), `w` (weekly), `b` (biweekly), `s` (semi-monthly), `m` (monthly), `y` (yearly) | -| `-h` | `--help` | Display help information | +| `-h` | `--help` | Display help information | --- @@ -318,19 +319,19 @@ cospend edit 123 -p myproject -d 2026-06-15 -o "corrected date" #### Edit Command Flags -| Short | Long | Description | -| ----- | ------------ | ---------------------------------------------------------------------- | -| `-p` | `--project` | Project ID (required) | -| `-n` | `--name` | New name/description | -| `-a` | `--amount` | New amount | -| `-c` | `--category` | Category by ID or case-insensitive name | -| `-b` | `--by` | Paying member username | -| `-f` | `--for` | Owed member username (repeatable) | -| `-m` | `--method` | Payment method by ID or case-insensitive name | -| `-o` | `--comment` | Comment | -| `-d` | `--date` | Date (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) | +| Short | Long | Description | +| ----- | ------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `-p` | `--project` | Project ID (required) | +| `-n` | `--name` | New name/description | +| `-a` | `--amount` | New amount | +| `-c` | `--category` | Category by ID or case-insensitive name | +| `-b` | `--by` | Paying member username | +| `-f` | `--for` | Owed member username (repeatable) | +| `-m` | `--method` | Payment method by ID or case-insensitive name | +| `-o` | `--comment` | Comment | +| `-d` | `--date` | Date (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) | | `-r` | `--repeat` | Repeat frequency: `n` (none), `d` (daily), `w` (weekly), `b` (biweekly), `s` (semi-monthly), `m` (monthly), `y` (yearly) | -| `-h` | `--help` | Display help information | +| `-h` | `--help` | Display help information | --- @@ -392,9 +393,12 @@ cospend config get #### Supported Keys -| Key | Description | -| ------------------- | -------------------------------------------------- | -| `default-project` | Default project ID (used when `-p` is not specified) | +| Key | Description | Default | +| ----------------- | ----------------------------------------------------- | ------- | +| `default-project` | Default project ID (used when `-p` is not specified) | (none) | +| `confirm-add` | Ask for confirmation before adding (`true`/`false`) | `false` | +| `confirm-delete` | Ask for confirmation before deleting (`true`/`false`) | `false` | +| `confirm-update` | Ask for confirmation before updating (`true`/`false`) | `false` | #### Examples @@ -404,6 +408,12 @@ cospend config set default-project myproject # View the current default project cospend config get default-project + +# Enable confirmation before deleting +cospend config set confirm-delete true + +# Show all current settings +cospend config list ``` --- diff --git a/cmd/add.go b/cmd/add.go index 2c903b7..3db4770 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "strconv" "strings" "time" @@ -199,50 +200,65 @@ func runAdd(cmd *cobra.Command, args []string) error { bill.Repeat = repeat } + formatter := format.NewAmountFormatter(locale, project.CurrencyName) + out := cmd.OutOrStdout() + + printBillSummary := func() { + _, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount)) + if convertTo != "" { + origFormatter := format.NewAmountFormatter(locale, convertTo) + _, _ = fmt.Fprintf(out, " Original: %s\n", origFormatter.Format(amount)) + } + _, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[payerID]) + var owerNames []string + for _, id := range owedIDs { + owerNames = append(owerNames, memberNames[id]) + } + _, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", ")) + if bill.CategoryID != 0 { + for _, c := range project.Categories { + if c.ID == bill.CategoryID { + _, _ = fmt.Fprintf(out, " Category: %s\n", c.Name) + break + } + } + } + if bill.PaymentModeID != 0 { + for _, pm := range project.PaymentModes { + if pm.ID == bill.PaymentModeID { + _, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name) + break + } + } + } + if bill.Comment != "" { + _, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment) + } + if addDate != "" { + _, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date) + } + if bill.Repeat != "" && bill.Repeat != "n" { + _, _ = fmt.Fprintf(out, " Repeat: %s\n", api.ValidRepeatFrequencies[bill.Repeat]) + } + } + + // Confirm if configured + if cfg.ConfirmAdd { + _, _ = fmt.Fprintf(out, "New expense: %s\n", expenseName) + printBillSummary() + if !confirm(os.Stdin, out, "Add bill?") { + _, _ = fmt.Fprintln(out, "Cancelled.") + return nil + } + } + // Create the bill if err := client.CreateBill(ProjectID, bill); err != nil { return fmt.Errorf("creating bill: %w", err) } - formatter := format.NewAmountFormatter(locale, project.CurrencyName) - out := cmd.OutOrStdout() _, _ = fmt.Fprintf(out, "Added expense: %s\n", expenseName) - _, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount)) - if convertTo != "" { - origFormatter := format.NewAmountFormatter(locale, convertTo) - _, _ = fmt.Fprintf(out, " Original: %s\n", origFormatter.Format(amount)) - } - _, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[payerID]) - var owerNames []string - for _, id := range owedIDs { - owerNames = append(owerNames, memberNames[id]) - } - _, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", ")) - if bill.CategoryID != 0 { - for _, c := range project.Categories { - if c.ID == bill.CategoryID { - _, _ = fmt.Fprintf(out, " Category: %s\n", c.Name) - break - } - } - } - if bill.PaymentModeID != 0 { - for _, pm := range project.PaymentModes { - if pm.ID == bill.PaymentModeID { - _, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name) - break - } - } - } - if bill.Comment != "" { - _, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment) - } - if addDate != "" { - _, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date) - } - if bill.Repeat != "" && bill.Repeat != "n" { - _, _ = fmt.Fprintf(out, " Repeat: %s\n", api.ValidRepeatFrequencies[bill.Repeat]) - } + printBillSummary() return nil } diff --git a/cmd/common.go b/cmd/common.go index fba6698..bec239e 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -1,7 +1,26 @@ package cmd +import ( + "bufio" + "fmt" + "io" + "strings" +) + // Debug enables debug output when true var Debug bool // ProjectID is the project to operate on (shared across commands) var ProjectID string + +// confirm prompts the user with a [Y/n] question and returns true if confirmed. +// Defaults to yes (empty input = yes). +func confirm(in io.Reader, out io.Writer, prompt string) bool { + _, _ = fmt.Fprintf(out, "%s [Y/n] ", prompt) + scanner := bufio.NewScanner(in) + if !scanner.Scan() { + return false + } + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "" || answer == "y" || answer == "yes" +} diff --git a/cmd/common_test.go b/cmd/common_test.go new file mode 100644 index 0000000..f2a1efd --- /dev/null +++ b/cmd/common_test.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +func TestConfirm(t *testing.T) { + tests := []struct { + name string + input string + want bool + prompt string + }{ + {"empty input defaults to yes", "\n", true, "Do it?"}, + {"y confirms", "y\n", true, "Do it?"}, + {"Y confirms", "Y\n", true, "Do it?"}, + {"yes confirms", "yes\n", true, "Do it?"}, + {"n declines", "n\n", false, "Do it?"}, + {"no declines", "no\n", false, "Do it?"}, + {"random text declines", "maybe\n", false, "Do it?"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := strings.NewReader(tt.input) + var out bytes.Buffer + got := confirm(in, &out, tt.prompt) + if got != tt.want { + t.Errorf("confirm(%q) = %v, want %v", tt.input, got, tt.want) + } + if !bytes.Contains(out.Bytes(), []byte("[Y/n]")) { + t.Errorf("Expected [Y/n] prompt, got: %s", out.String()) + } + }) + } +} diff --git a/cmd/config.go b/cmd/config.go index b3ead32..52f44a0 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strconv" "github.com/chenasraf/cospend-cli/internal/config" "github.com/spf13/cobra" @@ -32,11 +33,15 @@ Supported keys: domain Nextcloud instance URL user Nextcloud username default-project Default project ID (used when -p is not specified) + confirm-add Ask for confirmation before adding (true/false) + confirm-delete Ask for confirmation before deleting (true/false) + confirm-update Ask for confirmation before updating (true/false) Examples: cospend config set domain https://cloud.example.com cospend config set user alice - cospend config set default-project myproject`, + cospend config set default-project myproject + cospend config set confirm-delete true`, Args: cobra.ExactArgs(2), RunE: runConfigSet, } @@ -52,11 +57,15 @@ Supported keys: domain Nextcloud instance URL user Nextcloud username default-project Default project ID (used when -p is not specified) + confirm-add Ask for confirmation before adding (true/false) + confirm-delete Ask for confirmation before deleting (true/false) + confirm-update Ask for confirmation before updating (true/false) Examples: cospend config get domain cospend config get user - cospend config get default-project`, + cospend config get default-project + cospend config get confirm-delete`, Args: cobra.ExactArgs(1), RunE: runConfigGet, } @@ -106,6 +115,9 @@ func runConfigList(cmd *cobra.Command, _ []string) error { if cfg.DefaultProject != "" { _, _ = fmt.Fprintf(out, " default-project: %s\n", cfg.DefaultProject) } + _, _ = fmt.Fprintf(out, " confirm-add: %v\n", cfg.ConfirmAdd) + _, _ = fmt.Fprintf(out, " confirm-delete: %v\n", cfg.ConfirmDelete) + _, _ = fmt.Fprintf(out, " confirm-update: %v\n", cfg.ConfirmUpdate) return nil } @@ -133,6 +145,24 @@ func runConfigSet(cmd *cobra.Command, args []string) error { cfg.User = value case "default-project": cfg.DefaultProject = value + case "confirm-add": + b, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid boolean value: %s (use true or false)", value) + } + cfg.ConfirmAdd = b + case "confirm-delete": + b, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid boolean value: %s (use true or false)", value) + } + cfg.ConfirmDelete = b + case "confirm-update": + b, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid boolean value: %s (use true or false)", value) + } + cfg.ConfirmUpdate = b default: return fmt.Errorf("unknown config key: %s", key) } @@ -168,6 +198,12 @@ func runConfigGet(cmd *cobra.Command, args []string) error { value = cfg.User case "default-project": value = cfg.DefaultProject + case "confirm-add": + value = strconv.FormatBool(cfg.ConfirmAdd) + case "confirm-delete": + value = strconv.FormatBool(cfg.ConfirmDelete) + case "confirm-update": + value = strconv.FormatBool(cfg.ConfirmUpdate) default: return fmt.Errorf("unknown config key: %s", key) } diff --git a/cmd/config_test.go b/cmd/config_test.go index 249d479..c974dcf 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -351,6 +351,10 @@ func TestConfigListNoDefaultProject(t *testing.T) { if bytes.Contains([]byte(output), []byte("default-project")) { t.Errorf("Should not show default-project when not set, got: %s", output) } + // Confirm settings should always show, even when false + if !bytes.Contains([]byte(output), []byte("confirm-add: false")) { + t.Errorf("Should show confirm-add even when false, got: %s", output) + } } func TestConfigListNoConfigFile(t *testing.T) { @@ -367,6 +371,125 @@ func TestConfigListNoConfigFile(t *testing.T) { } } +func TestConfigSetConfirmAdd(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + if err := os.WriteFile(configPath, []byte(`{"domain":"https://example.com","user":"alice","password":"pass"}`), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"set", "confirm-add", "true"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !bytes.Contains(stdout.Bytes(), []byte("Set confirm-add = true")) { + t.Errorf("Expected success message, got: %s", stdout.String()) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + if !bytes.Contains(data, []byte("confirm_add")) { + t.Errorf("Config should contain 'confirm_add', got: %s", string(data)) + } +} + +func TestConfigGetConfirmDelete(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + if err := os.WriteFile(configPath, []byte(`{"domain":"https://example.com","user":"alice","password":"pass","confirm_delete":true}`), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"get", "confirm-delete"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !bytes.Contains(stdout.Bytes(), []byte("true")) { + t.Errorf("Expected 'true', got: %s", stdout.String()) + } +} + +func TestConfigSetConfirmInvalidBool(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + if err := os.WriteFile(configPath, []byte(`{"domain":"x","user":"u","password":"p"}`), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + cmd.SetArgs([]string{"set", "confirm-add", "notabool"}) + + if err := cmd.Execute(); err == nil { + t.Error("Expected error for invalid boolean value") + } +} + +func TestConfigListShowsConfirmations(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + configDir := filepath.Join(tempDir, "cospend") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(configDir, "cospend.json") + configContent := `{"domain":"https://example.com","user":"alice","password":"pass","confirm_add":true,"confirm_delete":true}` + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + cmd := NewConfigCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"list"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := stdout.String() + if !bytes.Contains([]byte(output), []byte("confirm-add")) { + t.Errorf("Should show confirm-add, got: %s", output) + } + if !bytes.Contains([]byte(output), []byte("confirm-delete")) { + t.Errorf("Should show confirm-delete, got: %s", output) + } +} + func TestMaskPassword(t *testing.T) { tests := []struct { input string diff --git a/cmd/delete.go b/cmd/delete.go index 7cd11a1..4fa8e6f 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -2,10 +2,13 @@ package cmd import ( "fmt" + "os" "strconv" "github.com/chenasraf/cospend-cli/internal/api" + "github.com/chenasraf/cospend-cli/internal/cache" "github.com/chenasraf/cospend-cli/internal/config" + "github.com/chenasraf/cospend-cli/internal/format" "github.com/spf13/cobra" ) @@ -55,6 +58,65 @@ func runDelete(cmd *cobra.Command, args []string) error { client.Debug = Debug client.DebugWriter = cmd.ErrOrStderr() + // Confirm if configured + if cfg.ConfirmDelete { + // Fetch bill details for preview + bills, err := client.GetBills(ProjectID) + if err != nil { + return fmt.Errorf("fetching bills: %w", err) + } + + var bill *api.BillResponse + for i := range bills { + if bills[i].ID == billID { + bill = &bills[i] + break + } + } + + out := cmd.OutOrStdout() + if bill != nil { + // Fetch project for member names and currency + project, ok := cache.Load(ProjectID) + if !ok { + project, err = client.GetProject(ProjectID) + if err != nil { + return fmt.Errorf("fetching project: %w", err) + } + } + memberNames := make(map[int]string) + for _, m := range project.Members { + memberNames[m.ID] = m.Name + } + + locale := "en_US" + userInfo, ok := cache.LoadUserInfo() + if !ok { + userInfo, err = client.GetUserInfo() + if err == nil { + _ = cache.SaveUserInfo(userInfo) + } + } + if userInfo != nil && userInfo.Locale != "" { + locale = userInfo.Locale + } else if userInfo != nil && userInfo.Language != "" { + locale = userInfo.Language + } + + formatter := format.NewAmountFormatter(locale, project.CurrencyName) + _, _ = fmt.Fprintf(out, "Bill #%d:\n", billID) + _, _ = fmt.Fprintf(out, " Name: %s\n", bill.What) + _, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount)) + _, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date) + _, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[bill.PayerID]) + } + + if !confirm(os.Stdin, out, fmt.Sprintf("Delete bill #%d?", billID)) { + _, _ = fmt.Fprintln(out, "Cancelled.") + return nil + } + } + // Delete the bill if err := client.DeleteBill(ProjectID, billID); err != nil { return fmt.Errorf("deleting bill: %w", err) diff --git a/cmd/edit.go b/cmd/edit.go index 833d6fd..2432beb 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "strconv" "strings" @@ -195,11 +196,6 @@ func runEdit(cmd *cobra.Command, args []string) error { bill.Repeat = editRepeat } - // Edit the bill - if err := client.EditBill(ProjectID, billID, bill); err != nil { - return fmt.Errorf("editing bill: %w", err) - } - // Fetch user info for locale-aware formatting locale := "en_US" userInfo, ok := cache.LoadUserInfo() @@ -217,38 +213,58 @@ func runEdit(cmd *cobra.Command, args []string) error { formatter := format.NewAmountFormatter(locale, project.CurrencyName) out := cmd.OutOrStdout() + + printBillSummary := func() { + _, _ = fmt.Fprintf(out, " Name: %s\n", bill.What) + _, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount)) + _, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date) + _, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[bill.PayerID]) + var owerNames []string + for _, id := range bill.OwedTo { + owerNames = append(owerNames, memberNames[id]) + } + _, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", ")) + if bill.CategoryID != 0 { + for _, c := range project.Categories { + if c.ID == bill.CategoryID { + _, _ = fmt.Fprintf(out, " Category: %s\n", c.Name) + break + } + } + } + if bill.PaymentModeID != 0 { + for _, pm := range project.PaymentModes { + if pm.ID == bill.PaymentModeID { + _, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name) + break + } + } + } + if bill.Comment != "" { + _, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment) + } + if bill.Repeat != "" && bill.Repeat != "n" { + _, _ = fmt.Fprintf(out, " Repeat: %s\n", api.ValidRepeatFrequencies[bill.Repeat]) + } + } + + // Confirm if configured + if cfg.ConfirmUpdate { + _, _ = fmt.Fprintf(out, "Update bill #%d:\n", billID) + printBillSummary() + if !confirm(os.Stdin, out, "Update bill?") { + _, _ = fmt.Fprintln(out, "Cancelled.") + return nil + } + } + + // Edit the bill + if err := client.EditBill(ProjectID, billID, bill); err != nil { + return fmt.Errorf("editing bill: %w", err) + } + _, _ = fmt.Fprintf(out, "Updated bill #%d\n", billID) - _, _ = fmt.Fprintf(out, " Name: %s\n", bill.What) - _, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount)) - _, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date) - _, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[bill.PayerID]) - var owerNames []string - for _, id := range bill.OwedTo { - owerNames = append(owerNames, memberNames[id]) - } - _, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", ")) - if bill.CategoryID != 0 { - for _, c := range project.Categories { - if c.ID == bill.CategoryID { - _, _ = fmt.Fprintf(out, " Category: %s\n", c.Name) - break - } - } - } - if bill.PaymentModeID != 0 { - for _, pm := range project.PaymentModes { - if pm.ID == bill.PaymentModeID { - _, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name) - break - } - } - } - if bill.Comment != "" { - _, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment) - } - if bill.Repeat != "" && bill.Repeat != "n" { - _, _ = fmt.Fprintf(out, " Repeat: %s\n", api.ValidRepeatFrequencies[bill.Repeat]) - } + printBillSummary() return nil } diff --git a/internal/config/config.go b/internal/config/config.go index c0ba746..721ec8d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,9 @@ type Config struct { User string `json:"user" yaml:"user" toml:"user"` Password string `json:"password" yaml:"password" toml:"password"` DefaultProject string `json:"default_project,omitempty" yaml:"default_project,omitempty" toml:"default_project,omitempty"` + ConfirmAdd bool `json:"confirm_add,omitempty" yaml:"confirm_add,omitempty" toml:"confirm_add,omitempty"` + ConfirmDelete bool `json:"confirm_delete,omitempty" yaml:"confirm_delete,omitempty" toml:"confirm_delete,omitempty"` + ConfirmUpdate bool `json:"confirm_update,omitempty" yaml:"confirm_update,omitempty" toml:"confirm_update,omitempty"` } // configExtensions lists supported config file extensions in order of preference @@ -240,5 +243,14 @@ password = %q if cfg.DefaultProject != "" { content += fmt.Sprintf("default_project = %q\n", cfg.DefaultProject) } + if cfg.ConfirmAdd { + content += "confirm_add = true\n" + } + if cfg.ConfirmDelete { + content += "confirm_delete = true\n" + } + if cfg.ConfirmUpdate { + content += "confirm_update = true\n" + } return []byte(content), nil }