feat: add action confirmations

This commit is contained in:
2026-03-24 16:40:25 +02:00
parent b694cb1eb9
commit 66173d9ff0
11 changed files with 451 additions and 102 deletions

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
gen/
CHANGELOG.md

15
.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"printWidth": 100,
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"overrides": [
{
"files": "*.md",
"options": {
"printWidth": 100,
"proseWrap": "always"
}
}
]
}

View File

@@ -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 <key>
#### 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
```
---

View File

@@ -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
}

View File

@@ -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"
}

38
cmd/common_test.go Normal file
View File

@@ -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())
}
})
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}