summary history files

commit:208355ddf9e82e346f78286397c0f15de2271292
date:Mon Feb 23 16:15:26 2026 +1100
parents:ee23b35aabd928bfe8370953ca7153e14d53141e
initial support for reports, including a income and expense report
diff --git a/internal/cli/report.go b/internal/cli/report.go
line changes: +118/-0
index 0000000..4cd79d6
--- /dev/null
+++ b/internal/cli/report.go
@@ -0,0 +1,118 @@
+package cli
+
+import (
+	"gt/internal/render"
+	"gt/internal/report"
+	"gt/internal/store"
+	"time"
+
+	"github.com/spf13/cobra"
+)
+
+func reportCmd(cli *cli) *cobra.Command {
+	var cmd = &cobra.Command{
+		Use: "report",
+	}
+	cmd.AddCommand(incomeExpenseReportCmd(cli))
+	return cmd
+}
+
+type AccountType string
+
+const (
+	AccountTypeLiability AccountType = "LIABILITY"
+	AccountTypeExpense   AccountType = "EXPENSE"
+	AccountTypeIncome    AccountType = "INCOME"
+)
+
+func incomeExpenseReportCmd(cli *cli) *cobra.Command {
+	var flags struct {
+		sortBy    string
+		startDate string
+		endDate   string
+	}
+
+	var cmd = &cobra.Command{
+		Use:   "income-expense [flags]",
+		Short: "Income Expense Report",
+		Example: `
+* Print report between 2 dates:
+
+  $ gt report income-expense --start-date 2026-01-01 --end-date 2026-01-31
+
+* Print report sorting by 'value':
+
+  $ gt report income-expense --sort-by value
+
+`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			var err error
+			s := store.NewStore(cli.db)
+			transactionQuery := store.NewTransactionQuery().
+				Where("transactions.post_date > ?", flags.startDate).
+				Where("transactions.post_date < ?", flags.endDate)
+			transactions, err := s.Transactions.All(cmd.Context(), transactionQuery)
+			if err != nil {
+				return err
+			}
+
+			p := report.IncomeExpenseReport{
+				Income:  []*report.AccountItem{},
+				Expense: []*report.AccountItem{},
+			}
+
+			for _, t := range transactions {
+				for _, s := range t.Splits {
+					switch s.Account.AccountType {
+					case string(AccountTypeExpense):
+						exists := false
+						for _, i := range p.Expense {
+							if i.Account.GUID == s.Account.GUID {
+								i.ValueNum = i.ValueNum + s.ValueNum
+								exists = true
+								break
+							}
+						}
+						if !exists {
+							item := &report.AccountItem{
+								Account:    *s.Account,
+								ValueNum:   s.ValueNum,
+								ValueDenom: s.ValueDenom,
+							}
+							p.Expense = append(p.Expense, item)
+						}
+					case string(AccountTypeIncome):
+						exists := false
+						for _, i := range p.Income {
+							if i.Account.GUID == s.Account.GUID {
+								i.ValueNum = i.ValueNum + s.ValueNum
+								exists = true
+								break
+							}
+						}
+						if !exists {
+							item := &report.AccountItem{
+								Account:    *s.Account,
+								ValueNum:   s.ValueNum,
+								ValueDenom: s.ValueDenom,
+							}
+							p.Income = append(p.Income, item)
+						}
+					}
+				}
+			}
+
+			r, err := render.New("table")
+			if err != nil {
+				return err
+			}
+
+			renderOpts := []render.RendererOptsFunc{render.WithAccountShortName(true), render.WithSortBy(flags.sortBy)}
+			return r.Render(cmd.OutOrStdout(), p, renderOpts...)
+		},
+	}
+	cmd.Flags().StringVar(&flags.sortBy, "sort-by", "name", "Sort report items by name or value")
+	cmd.Flags().StringVar(&flags.startDate, "start-date", time.Now().AddDate(0, 0, -31).Format("2006-01-02 15:04:05"), "Start date of the report")
+	cmd.Flags().StringVar(&flags.endDate, "end-date", time.Now().Format("2006-01-02 15:04:05"), "End date of the report")
+	return cmd
+}

diff --git a/internal/cli/root.go b/internal/cli/root.go
line changes: +1/-0
index 20083d5..2425c75
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -29,6 +29,7 @@ func Execute() {
 
 	rootCmd.AddCommand(accountCmd(cli))
 	rootCmd.AddCommand(transactionCmd(cli))
+	rootCmd.AddCommand(reportCmd(cli))
 
 	if err := rootCmd.ExecuteContext(context.TODO()); err != nil {
 		fmt.Fprintf(os.Stderr, err.Error())

diff --git a/internal/render/render.go b/internal/render/render.go
line changes: +111/-0
index fff3584..de6c55e
--- a/internal/render/render.go
+++ b/internal/render/render.go
@@ -1,12 +1,18 @@
 package render
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
+	"gt/internal/report"
 	"gt/internal/store"
 	"io"
+	"regexp"
+	"sort"
+	"strings"
 
 	"github.com/olekukonko/tablewriter"
+	"github.com/olekukonko/tablewriter/renderer"
 	"github.com/olekukonko/tablewriter/tw"
 )
 
@@ -50,12 +56,17 @@ type RendererOpts struct {
 	// use accounts short name (i.e. pizza) when rendering instead of full name
 	// (i.e expenses:dining:pizza)
 	accountShortName bool
+
+	// sortBy determines how rows are sorted when being rendered within the
+	// table.
+	sortBy string
 }
 
 func defaultRendererOpts() *RendererOpts {
 	return &RendererOpts{
 		includeTotals:    false,
 		accountShortName: false,
+		sortBy:           "name",
 	}
 }
 
@@ -67,12 +78,110 @@ func WithAccountShortName(b bool) RendererOptsFunc {
 	}
 }
 
+func WithSortBy(s string) RendererOptsFunc {
+	return func(o *RendererOpts) {
+		o.sortBy = s
+	}
+}
+
 func WithIncludeTotals(b bool) RendererOptsFunc {
 	return func(o *RendererOpts) {
 		o.includeTotals = b
 	}
 }
 
+func renderReportProfitLoss(table *tablewriter.Table, opts RendererOpts, r report.IncomeExpenseReport) {
+
+	fn := func(cfg *tablewriter.Config) {
+		cfg.Header = tw.CellConfig{Alignment: tw.CellAlignment{Global: tw.AlignCenter}}
+		cfg.Row = tw.CellConfig{
+			Merging:   tw.CellMerging{Mode: tw.MergeHierarchical},
+			Alignment: tw.CellAlignment{Global: tw.AlignLeft},
+		}
+	}
+	table.Configure(fn)
+	o := []tablewriter.Option{
+		tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{Borders: tw.BorderNone})),
+	}
+	table.Options(o...)
+
+	createSubTable := func(items []*report.AccountItem) string {
+		var buf bytes.Buffer
+		table := tablewriter.NewTable(&buf,
+			tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{
+				Settings: tw.Settings{
+					Lines: tw.Lines{ShowFooterLine: tw.On},
+				},
+			})),
+			tablewriter.WithConfig(tablewriter.Config{
+				Header: tw.CellConfig{
+					Alignment: tw.CellAlignment{
+						Global: tw.AlignLeft,
+					},
+				},
+				Row: tw.CellConfig{
+					Formatting: tw.CellFormatting{
+						AutoWrap: int(tw.Off),
+					},
+					ColMaxWidths: tw.CellWidth{Global: 80},
+				},
+			}),
+		)
+
+		num := int64(0)
+		denom := int64(0)
+
+		table.Header([]string{"Account", "Value"})
+
+		switch opts.sortBy {
+		case "value":
+			sort.Slice(items, func(i, j int) bool {
+				return items[i].ValueNum > items[j].ValueNum
+			})
+		default:
+			// NOTE(rene): default to sorting by name
+			sort.Slice(items, func(i, j int) bool {
+				return strings.Compare(
+					strings.ToLower(items[i].Account.FullName),
+					strings.ToLower(items[j].Account.FullName),
+				) < 0
+			})
+		}
+
+		for _, i := range items {
+			value := ""
+			debit, credit := formatAmount(i.ValueNum, i.ValueDenom)
+			value = credit
+			if debit != "" {
+				value = debit
+			}
+			re := regexp.MustCompile(`^(Income|Expenses)\:`)
+			accountName := re.ReplaceAllString(i.Account.FullName, "")
+			num = num + i.ValueNum
+			denom = i.ValueDenom
+
+			table.Append([]string{
+				accountName,
+				value,
+			})
+		}
+
+		d, c := formatAmount(num, denom)
+		v := c
+		if d != "" {
+			v = d
+		}
+
+		table.Footer([]string{"Total", v})
+
+		table.Render()
+		return buf.String()
+	}
+
+	table.Append([]string{createSubTable(r.Income)})
+	table.Append([]string{createSubTable(r.Expense)})
+}
+
 func renderAccounts(table *tablewriter.Table, opts RendererOpts, accounts []*store.Account) {
 	table.Header([]string{"Name", "Account Type", "Description"})
 	for _, account := range accounts {
@@ -215,6 +324,8 @@ func (t *TableRenderer) Render(w io.Writer, data any, opts ...RendererOptsFunc) 
 		renderTransactions(table, *o, []*store.Transaction{v})
 	case []*store.Transaction:
 		renderTransactions(table, *o, v)
+	case report.IncomeExpenseReport:
+		renderReportProfitLoss(table, *o, v)
 	default:
 		return fmt.Errorf("unsupported model type: %T", data)
 	}

diff --git a/internal/report/report.go b/internal/report/report.go
line changes: +14/-0
index 0000000..2919f7a
--- /dev/null
+++ b/internal/report/report.go
@@ -0,0 +1,14 @@
+package report
+
+import "gt/internal/store"
+
+type IncomeExpenseReport struct {
+	Income  []*AccountItem
+	Expense []*AccountItem
+}
+
+type AccountItem struct {
+	Account    store.Account
+	ValueNum   int64
+	ValueDenom int64
+}