mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-18 01:39:03 +00:00
feat: bill repeat
This commit is contained in:
@@ -183,6 +183,10 @@ cospend add "Lunch" 15.00 -p myproject -d 2026-03-15
|
||||
cospend add "Lunch" 15.00 -p myproject -d 03-15 # assumes current year
|
||||
cospend add "Lunch" 15.00 -p myproject -d -1d # yesterday
|
||||
cospend add "Lunch" 15.00 -p myproject -d +2d # 2 days from now
|
||||
|
||||
# Add a recurring expense
|
||||
cospend add "Rent" 1200.00 -p myproject -r m # monthly
|
||||
cospend add "Gym" 50.00 -p myproject -r w # weekly
|
||||
```
|
||||
|
||||
#### Add Command Flags
|
||||
@@ -197,6 +201,7 @@ cospend add "Lunch" 15.00 -p myproject -d +2d # 2 days from now
|
||||
| `-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 |
|
||||
|
||||
---
|
||||
@@ -311,6 +316,7 @@ cospend edit 123 -p myproject -d 2026-06-15 -o "corrected date"
|
||||
| `-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 |
|
||||
|
||||
---
|
||||
|
||||
13
cmd/add.go
13
cmd/add.go
@@ -21,6 +21,7 @@ var (
|
||||
paymentMethod string
|
||||
comment string
|
||||
addDate string
|
||||
repeat string
|
||||
)
|
||||
|
||||
// NewAddCommand creates the add command
|
||||
@@ -44,6 +45,7 @@ Examples:
|
||||
cmd.Flags().StringVarP(&paymentMethod, "method", "m", "", "Payment method by ID or name")
|
||||
cmd.Flags().StringVarP(&comment, "comment", "o", "", "Additional details about the bill")
|
||||
cmd.Flags().StringVarP(&addDate, "date", "d", "", "Date of expense (YYYY-MM-DD, MM-DD, or relative like -1d, +2w)")
|
||||
cmd.Flags().StringVarP(&repeat, "repeat", "r", "", "Repeat frequency: d (daily), w (weekly), b (biweekly), s (semi-monthly), m (monthly), y (yearly)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -189,6 +191,14 @@ func runAdd(cmd *cobra.Command, args []string) error {
|
||||
bill.Comment = comment
|
||||
}
|
||||
|
||||
// Set repeat frequency
|
||||
if repeat != "" {
|
||||
if _, ok := api.ValidRepeatFrequencies[repeat]; !ok {
|
||||
return fmt.Errorf("invalid repeat frequency: %s (valid: d, w, b, s, m, y)", repeat)
|
||||
}
|
||||
bill.Repeat = repeat
|
||||
}
|
||||
|
||||
// Create the bill
|
||||
if err := client.CreateBill(ProjectID, bill); err != nil {
|
||||
return fmt.Errorf("creating bill: %w", err)
|
||||
@@ -230,6 +240,9 @@ func runAdd(cmd *cobra.Command, args []string) error {
|
||||
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])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
148
cmd/add_test.go
148
cmd/add_test.go
@@ -44,6 +44,7 @@ func resetFlags() {
|
||||
paymentMethod = ""
|
||||
comment = ""
|
||||
addDate = ""
|
||||
repeat = ""
|
||||
editName = ""
|
||||
editAmount = ""
|
||||
editCategory = ""
|
||||
@@ -52,6 +53,7 @@ func resetFlags() {
|
||||
editPaymentMethod = ""
|
||||
editComment = ""
|
||||
editDate = ""
|
||||
editRepeat = ""
|
||||
infoCached = false
|
||||
}
|
||||
|
||||
@@ -585,3 +587,149 @@ func TestAddCommandWithDate(t *testing.T) {
|
||||
t.Errorf("Output should show date, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandWithRepeat(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
var receivedBill map[string]string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ocs/v2.php/cloud/user" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills" {
|
||||
_ = r.ParseForm()
|
||||
receivedBill = make(map[string]string)
|
||||
for k, v := range r.Form {
|
||||
if len(v) > 0 {
|
||||
receivedBill[k] = v[0]
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]int{"id": 1}))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"Rent", "1200.00", "-r", "m"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if receivedBill["repeat"] != "m" {
|
||||
t.Errorf("Wrong repeat: got %s, want m", receivedBill["repeat"])
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("Repeat: monthly")) {
|
||||
t.Errorf("Output should show repeat, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandWithInvalidRepeat(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ocs/v2.php/cloud/user" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
cmd.SetArgs([]string{"Test", "10.00", "-r", "x"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid repeat frequency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommandDefaultRepeat(t *testing.T) {
|
||||
project := api.Project{
|
||||
ID: "test-project",
|
||||
Name: "Test Project",
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "testuser", UserID: "testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
var receivedBill map[string]string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ocs/v2.php/cloud/user" {
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills" {
|
||||
_ = r.ParseForm()
|
||||
receivedBill = make(map[string]string)
|
||||
for k, v := range r.Form {
|
||||
if len(v) > 0 {
|
||||
receivedBill[k] = v[0]
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]int{"id": 1}))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup := setupTestEnv(t, server.URL)
|
||||
defer cleanup()
|
||||
|
||||
ProjectID = "test-project"
|
||||
cmd := NewAddCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"Groceries", "25.50"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Default should be "n"
|
||||
if receivedBill["repeat"] != "n" {
|
||||
t.Errorf("Default repeat should be 'n', got %s", receivedBill["repeat"])
|
||||
}
|
||||
// Output should NOT show repeat for default
|
||||
if bytes.Contains(stdout.Bytes(), []byte("Repeat:")) {
|
||||
t.Errorf("Output should not show repeat for default, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
13
cmd/edit.go
13
cmd/edit.go
@@ -21,6 +21,7 @@ var (
|
||||
editPaymentMethod string
|
||||
editComment string
|
||||
editDate string
|
||||
editRepeat string
|
||||
)
|
||||
|
||||
// NewEditCommand creates the edit command
|
||||
@@ -49,6 +50,7 @@ Examples:
|
||||
cmd.Flags().StringVarP(&editPaymentMethod, "method", "m", "", "Payment method by ID or name")
|
||||
cmd.Flags().StringVarP(&editComment, "comment", "o", "", "Comment")
|
||||
cmd.Flags().StringVarP(&editDate, "date", "d", "", "Date (YYYY-MM-DD, MM-DD, or relative like -1d, +2w)")
|
||||
cmd.Flags().StringVarP(&editRepeat, "repeat", "r", "", "Repeat frequency: n (none), d (daily), w (weekly), b (biweekly), s (semi-monthly), m (monthly), y (yearly)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -119,6 +121,7 @@ func runEdit(cmd *cobra.Command, args []string) error {
|
||||
Comment: existing.Comment,
|
||||
PaymentModeID: existing.PaymentModeID,
|
||||
CategoryID: existing.CategoryID,
|
||||
Repeat: existing.Repeat,
|
||||
}
|
||||
for _, o := range existing.Owers {
|
||||
bill.OwedTo = append(bill.OwedTo, o.ID)
|
||||
@@ -185,6 +188,13 @@ func runEdit(cmd *cobra.Command, args []string) error {
|
||||
bill.Comment = editComment
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("repeat") {
|
||||
if _, ok := api.ValidRepeatFrequencies[editRepeat]; !ok {
|
||||
return fmt.Errorf("invalid repeat frequency: %s (valid: n, d, w, b, s, m, y)", editRepeat)
|
||||
}
|
||||
bill.Repeat = editRepeat
|
||||
}
|
||||
|
||||
// Edit the bill
|
||||
if err := client.EditBill(ProjectID, billID, bill); err != nil {
|
||||
return fmt.Errorf("editing bill: %w", err)
|
||||
@@ -236,6 +246,9 @@ func runEdit(cmd *cobra.Command, args []string) error {
|
||||
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])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func resetEditFlags() {
|
||||
editPaymentMethod = ""
|
||||
editComment = ""
|
||||
editDate = ""
|
||||
editRepeat = ""
|
||||
}
|
||||
|
||||
func TestNewEditCommand(t *testing.T) {
|
||||
|
||||
@@ -176,6 +176,18 @@ type Bill struct {
|
||||
PaymentModeID int `json:"paymentmodeid,omitempty"`
|
||||
CategoryID int `json:"categoryid,omitempty"`
|
||||
OriginalCurrencyID int `json:"original_currency_id,omitempty"`
|
||||
Repeat string `json:"repeat,omitempty"`
|
||||
}
|
||||
|
||||
// Valid repeat frequencies for bills
|
||||
var ValidRepeatFrequencies = map[string]string{
|
||||
"n": "none",
|
||||
"d": "daily",
|
||||
"w": "weekly",
|
||||
"b": "biweekly",
|
||||
"s": "semi-monthly",
|
||||
"m": "monthly",
|
||||
"y": "yearly",
|
||||
}
|
||||
|
||||
// BillResponse represents a bill returned from the API
|
||||
@@ -346,7 +358,11 @@ func (c *Client) CreateBill(projectID string, bill Bill) error {
|
||||
data.Set("payer", strconv.Itoa(bill.PayerID))
|
||||
data.Set("date", bill.Date)
|
||||
data.Set("timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
data.Set("repeat", "n")
|
||||
repeat := bill.Repeat
|
||||
if repeat == "" {
|
||||
repeat = "n"
|
||||
}
|
||||
data.Set("repeat", repeat)
|
||||
|
||||
// Format owed member IDs as comma-separated string
|
||||
owedIDs := make([]string, len(bill.OwedTo))
|
||||
@@ -480,6 +496,11 @@ func (c *Client) EditBill(projectID string, billID int, bill Bill) error {
|
||||
data.Set("payer", strconv.Itoa(bill.PayerID))
|
||||
data.Set("date", bill.Date)
|
||||
data.Set("timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
editRepeat := bill.Repeat
|
||||
if editRepeat == "" {
|
||||
editRepeat = "n"
|
||||
}
|
||||
data.Set("repeat", editRepeat)
|
||||
|
||||
// Format owed member IDs as comma-separated string
|
||||
owedIDs := make([]string, len(bill.OwedTo))
|
||||
|
||||
Reference in New Issue
Block a user