feat: bill repeat

This commit is contained in:
2026-03-23 22:33:06 +02:00
parent bb31850bf3
commit 05fa043a36
6 changed files with 203 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ func resetEditFlags() {
editPaymentMethod = ""
editComment = ""
editDate = ""
editRepeat = ""
}
func TestNewEditCommand(t *testing.T) {

View File

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