Files
cospend-cli/cmd/list.go
2026-02-10 10:28:13 +02:00

613 lines
16 KiB
Go

package cmd
import (
"encoding/csv"
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
"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"
)
var (
listPaidBy string
listPaidFor []string
listAmount string
listName string
listPaymentMethod string
listCategory string
listLimit int
listDate string
listToday bool
listThisMonth bool
listThisWeek bool
listRecent string
listFormat string
)
// amountFilter holds parsed amount filter criteria
type amountFilter struct {
operator string
value float64
}
// NewListCommand creates the list command
func NewListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List expenses in a Cospend project",
Long: `List expenses in a Cospend project with optional filters.
Examples:
cospend list -p myproject
cospend list -p myproject -b alice
cospend list -p myproject -c groceries
cospend list -p myproject --amount ">50"
cospend list -p myproject --amount "<=100" -n dinner
cospend list -p myproject --today
cospend list -p myproject --date ">=2026-01-01"
cospend list -p myproject --date "<=01-15"
cospend list -p myproject --this-month
cospend list -p myproject --this-week
cospend list -p myproject --recent 7d
cospend list -p myproject --recent 2w`,
RunE: runList,
}
cmd.Flags().StringVarP(&listPaidBy, "by", "b", "", "Filter by paying member username")
cmd.Flags().StringArrayVarP(&listPaidFor, "for", "f", nil, "Filter by owed member username (repeatable)")
cmd.Flags().StringVarP(&listAmount, "amount", "a", "", "Filter by amount (e.g., 50, >30, <=100, =25)")
cmd.Flags().StringVarP(&listName, "name", "n", "", "Filter by name (case-insensitive, contains)")
cmd.Flags().StringVarP(&listPaymentMethod, "method", "m", "", "Filter by payment method")
cmd.Flags().StringVarP(&listCategory, "category", "c", "", "Filter by category")
cmd.Flags().IntVarP(&listLimit, "limit", "l", 0, "Limit number of results (0 = no limit)")
cmd.Flags().StringVar(&listDate, "date", "", "Filter by date (e.g., 2026-01-15, >=2026-01-01, <=01-15)")
cmd.Flags().BoolVar(&listToday, "today", false, "Filter bills from today")
cmd.Flags().BoolVar(&listThisMonth, "this-month", false, "Filter bills from the current month")
cmd.Flags().BoolVar(&listThisWeek, "this-week", false, "Filter bills from the current calendar week")
cmd.Flags().StringVar(&listRecent, "recent", "", "Filter recent bills (e.g., 7d, 2w, 1m)")
cmd.Flags().StringVar(&listFormat, "format", "table", "Output format: table, csv, json")
return cmd
}
func runList(cmd *cobra.Command, _ []string) error {
if ProjectID == "" {
return fmt.Errorf("project is required (use -p or --project)")
}
switch listFormat {
case "table", "csv", "json":
default:
return fmt.Errorf("unsupported format: %s (expected table, csv, or json)", listFormat)
}
// Parameters validated, silence usage for subsequent errors
cmd.SilenceUsage = true
// Load configuration
cfg, err := config.Load()
if err != nil {
return err
}
// Get API client
client := api.NewClient(cfg)
client.Debug = Debug
client.DebugWriter = cmd.ErrOrStderr()
// Get project (from cache or API)
project, ok := cache.Load(ProjectID)
if !ok {
project, err = client.GetProject(ProjectID)
if err != nil {
return fmt.Errorf("fetching project: %w", err)
}
if err := cache.Save(ProjectID, project); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to cache project: %v\n", err)
}
}
// Fetch bills
bills, err := client.GetBills(ProjectID)
if err != nil {
return fmt.Errorf("fetching bills: %w", err)
}
// Fetch user info for locale (with cache, graceful fallback)
locale := "en_US"
userInfo, ok := cache.LoadUserInfo()
if !ok {
userInfo, err = client.GetUserInfo()
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to fetch user info: %v\n", err)
} else {
if err := cache.SaveUserInfo(userInfo); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to cache user info: %v\n", err)
}
}
}
if userInfo != nil && userInfo.Locale != "" {
locale = userInfo.Locale
} else if userInfo != nil && userInfo.Language != "" {
locale = userInfo.Language
}
// Build filters
filters, err := buildFilters(project)
if err != nil {
return err
}
// Apply filters
filteredBills := applyFilters(bills, filters)
// Output results
formatter := format.NewAmountFormatter(locale, project.CurrencyName)
resolved := resolveBillNames(project, filteredBills)
switch listFormat {
case "csv":
printBillsCSV(cmd, resolved)
case "json":
printBillsJSON(cmd, resolved)
default:
printBillsTable(cmd, resolved, formatter)
}
return nil
}
// billFilter is a function that returns true if a bill should be included
type billFilter func(bill api.BillResponse) bool
func buildFilters(project *api.Project) ([]billFilter, error) {
var filters []billFilter
// Filter by payer
if listPaidBy != "" {
payerID, err := cache.ResolveMember(project, listPaidBy)
if err != nil {
return nil, fmt.Errorf("resolving payer filter: %w", err)
}
filters = append(filters, func(bill api.BillResponse) bool {
return bill.PayerID == payerID
})
}
// Filter by owed members
if len(listPaidFor) > 0 {
var owedIDs []int
for _, username := range listPaidFor {
memberID, err := cache.ResolveMember(project, username)
if err != nil {
return nil, fmt.Errorf("resolving owed member filter: %w", err)
}
owedIDs = append(owedIDs, memberID)
}
filters = append(filters, func(bill api.BillResponse) bool {
for _, requiredID := range owedIDs {
found := false
for _, ower := range bill.Owers {
if ower.ID == requiredID {
found = true
break
}
}
if !found {
return false
}
}
return true
})
}
// Filter by amount
if listAmount != "" {
af, err := parseAmountFilter(listAmount)
if err != nil {
return nil, fmt.Errorf("parsing amount filter: %w", err)
}
filters = append(filters, func(bill api.BillResponse) bool {
return matchAmount(bill.Amount, af)
})
}
// Filter by name (case-insensitive contains)
if listName != "" {
lowerName := strings.ToLower(listName)
filters = append(filters, func(bill api.BillResponse) bool {
return strings.Contains(strings.ToLower(bill.What), lowerName)
})
}
// Filter by payment method
if listPaymentMethod != "" {
methodID, err := cache.ResolvePaymentMode(project, listPaymentMethod)
if err != nil {
return nil, fmt.Errorf("resolving payment method filter: %w", err)
}
filters = append(filters, func(bill api.BillResponse) bool {
return bill.PaymentModeID == methodID
})
}
// Filter by category
if listCategory != "" {
categoryID, err := cache.ResolveCategory(project, listCategory)
if err != nil {
return nil, fmt.Errorf("resolving category filter: %w", err)
}
filters = append(filters, func(bill api.BillResponse) bool {
return bill.CategoryID == categoryID
})
}
// Filter by today
if listToday {
today := time.Now().Format("2006-01-02")
filters = append(filters, func(bill api.BillResponse) bool {
return bill.Date == today
})
}
// Filter by date
if listDate != "" {
df, err := parseDateFilter(listDate)
if err != nil {
return nil, fmt.Errorf("parsing date filter: %w", err)
}
filters = append(filters, func(bill api.BillResponse) bool {
return matchDate(bill.Date, df)
})
}
// Filter by this month
if listThisMonth {
now := time.Now()
prefix := now.Format("2006-01")
filters = append(filters, func(bill api.BillResponse) bool {
return strings.HasPrefix(bill.Date, prefix)
})
}
// Filter by this week
if listThisWeek {
now := time.Now()
weekday := now.Weekday()
if weekday == time.Sunday {
weekday = 7
}
startOfWeek := now.AddDate(0, 0, -int(weekday-time.Monday))
endOfWeek := startOfWeek.AddDate(0, 0, 6)
startStr := startOfWeek.Format("2006-01-02")
endStr := endOfWeek.Format("2006-01-02")
filters = append(filters, func(bill api.BillResponse) bool {
return bill.Date >= startStr && bill.Date <= endStr
})
}
// Filter by recent duration
if listRecent != "" {
cutoff, err := parseRecent(listRecent)
if err != nil {
return nil, fmt.Errorf("parsing recent filter: %w", err)
}
cutoffStr := cutoff.Format("2006-01-02")
filters = append(filters, func(bill api.BillResponse) bool {
return bill.Date >= cutoffStr
})
}
return filters, nil
}
func applyFilters(bills []api.BillResponse, filters []billFilter) []api.BillResponse {
if len(filters) == 0 {
return bills
}
var result []api.BillResponse
for _, bill := range bills {
include := true
for _, filter := range filters {
if !filter(bill) {
include = false
break
}
}
if include {
result = append(result, bill)
}
}
return result
}
func parseAmountFilter(s string) (amountFilter, error) {
s = strings.TrimSpace(s)
// Match operators: >=, <=, >, <, =, or just a number
re := regexp.MustCompile(`^(>=|<=|>|<|=)?(.+)$`)
matches := re.FindStringSubmatch(s)
if matches == nil {
return amountFilter{}, fmt.Errorf("invalid amount filter format: %s", s)
}
operator := matches[1]
if operator == "" {
operator = "="
}
value, err := strconv.ParseFloat(strings.TrimSpace(matches[2]), 64)
if err != nil {
return amountFilter{}, fmt.Errorf("invalid amount value: %s", matches[2])
}
return amountFilter{operator: operator, value: value}, nil
}
func matchAmount(amount float64, af amountFilter) bool {
switch af.operator {
case "=":
return amount == af.value
case ">":
return amount > af.value
case "<":
return amount < af.value
case ">=":
return amount >= af.value
case "<=":
return amount <= af.value
default:
return false
}
}
// dateFilter holds parsed date filter criteria
type dateFilter struct {
operator string
date string // YYYY-MM-DD format for string comparison
}
func parseDateFilter(s string) (dateFilter, error) {
s = strings.TrimSpace(s)
re := regexp.MustCompile(`^(>=|<=|>|<|=)?(.+)$`)
matches := re.FindStringSubmatch(s)
if matches == nil {
return dateFilter{}, fmt.Errorf("invalid date filter format: %s", s)
}
operator := matches[1]
if operator == "" {
operator = "="
}
dateStr := strings.TrimSpace(matches[2])
// Try full date format YYYY-MM-DD
if _, err := time.Parse("2006-01-02", dateStr); err == nil {
return dateFilter{operator: operator, date: dateStr}, nil
}
// Try short format MM-DD (assume current year)
if t, err := time.Parse("01-02", dateStr); err == nil {
dateStr = fmt.Sprintf("%d-%s", time.Now().Year(), t.Format("01-02"))
return dateFilter{operator: operator, date: dateStr}, nil
}
return dateFilter{}, fmt.Errorf("invalid date format: %s (expected YYYY-MM-DD or MM-DD)", dateStr)
}
func matchDate(billDate string, df dateFilter) bool {
switch df.operator {
case "=":
return billDate == df.date
case ">":
return billDate > df.date
case "<":
return billDate < df.date
case ">=":
return billDate >= df.date
case "<=":
return billDate <= df.date
default:
return false
}
}
func parseRecent(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if len(s) < 2 {
return time.Time{}, fmt.Errorf("invalid recent format: %s (expected e.g. 7d, 2w, 1m)", s)
}
unit := s[len(s)-1]
valueStr := s[:len(s)-1]
value, err := strconv.Atoi(valueStr)
if err != nil {
return time.Time{}, fmt.Errorf("invalid recent value: %s", valueStr)
}
now := time.Now()
switch unit {
case 'd':
return now.AddDate(0, 0, -value), nil
case 'w':
return now.AddDate(0, 0, -value*7), nil
case 'm':
return now.AddDate(0, -value, 0), nil
default:
return time.Time{}, fmt.Errorf("invalid recent unit: %c (expected d, w, or m)", unit)
}
}
// resolvedBill holds a bill with human-readable names resolved from IDs
type resolvedBill struct {
ID int `json:"id"`
Date string `json:"date"`
Name string `json:"name"`
Amount float64 `json:"amount"`
PaidBy string `json:"paid_by"`
PaidFor []string `json:"paid_for"`
Category string `json:"category"`
PaymentMethod string `json:"payment_method"`
}
func resolveBillNames(project *api.Project, bills []api.BillResponse) []resolvedBill {
// Sort by date (newest first), then by timestamp for same-date entries
sort.Slice(bills, func(i, j int) bool {
if bills[i].Date != bills[j].Date {
return bills[i].Date > bills[j].Date
}
return bills[i].Timestamp > bills[j].Timestamp
})
// Apply limit if set
if listLimit > 0 && len(bills) > listLimit {
bills = bills[:listLimit]
}
// Build lookup maps
memberNames := make(map[int]string)
for _, m := range project.Members {
memberNames[m.ID] = m.Name
}
categoryNames := make(map[int]string)
for _, c := range project.Categories {
categoryNames[c.ID] = c.Name
}
paymentModeNames := make(map[int]string)
for _, pm := range project.PaymentModes {
paymentModeNames[pm.ID] = pm.Name
}
var result []resolvedBill
for _, bill := range bills {
payerName := memberNames[bill.PayerID]
if payerName == "" {
payerName = fmt.Sprintf("#%d", bill.PayerID)
}
var owerNames []string
for _, ower := range bill.Owers {
name := memberNames[ower.ID]
if name == "" {
name = fmt.Sprintf("#%d", ower.ID)
}
owerNames = append(owerNames, name)
}
catName := categoryNames[bill.CategoryID]
if catName == "" && bill.CategoryID != 0 {
catName = fmt.Sprintf("#%d", bill.CategoryID)
}
methodName := paymentModeNames[bill.PaymentModeID]
if methodName == "" && bill.PaymentModeID != 0 {
methodName = fmt.Sprintf("#%d", bill.PaymentModeID)
}
name := strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
return r
}, strings.TrimSpace(bill.What))
result = append(result, resolvedBill{
ID: bill.ID,
Date: bill.Date,
Name: name,
Amount: bill.Amount,
PaidBy: payerName,
PaidFor: owerNames,
Category: catName,
PaymentMethod: methodName,
})
}
return result
}
func printBillsTable(cmd *cobra.Command, bills []resolvedBill, formatter *format.AmountFormatter) {
if len(bills) == 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No bills found.")
return
}
table := NewTable("ID", "DATE", "NAME", "AMOUNT", "PAID BY", "PAID FOR", "CATEGORY", "METHOD")
var totalAmount float64
for _, bill := range bills {
totalAmount += bill.Amount
catName := bill.Category
if catName == "" {
catName = "-"
}
methodName := bill.PaymentMethod
if methodName == "" {
methodName = "-"
}
name := bill.Name
if len(name) > 30 {
name = name[:27] + "..."
}
table.AddRow(
fmt.Sprintf("%d", bill.ID),
bill.Date,
name,
formatter.Format(bill.Amount),
bill.PaidBy,
strings.Join(bill.PaidFor, ", "),
catName,
methodName,
)
}
out := cmd.OutOrStdout()
table.Render(out)
_, _ = fmt.Fprintf(out, "\nTotal: %d bill(s), %s\n", len(bills), formatter.Format(totalAmount))
}
func printBillsCSV(cmd *cobra.Command, bills []resolvedBill) {
out := cmd.OutOrStdout()
w := csv.NewWriter(out)
_ = w.Write([]string{"ID", "Date", "Name", "Amount", "Paid By", "Paid For", "Category", "Payment Method"})
for _, bill := range bills {
_ = w.Write([]string{
strconv.Itoa(bill.ID),
bill.Date,
bill.Name,
strconv.FormatFloat(bill.Amount, 'f', 2, 64),
bill.PaidBy,
strings.Join(bill.PaidFor, ", "),
bill.Category,
bill.PaymentMethod,
})
}
w.Flush()
}
func printBillsJSON(cmd *cobra.Command, bills []resolvedBill) {
out := cmd.OutOrStdout()
if bills == nil {
bills = []resolvedBill{}
}
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
_ = enc.Encode(bills)
}