| commit: | 208355ddf9e82e346f78286397c0f15de2271292 |
| date: | Mon Feb 23 16:15:26 2026 +1100 |
| parents: | ee23b35aabd928bfe8370953ca7153e14d53141e |
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 +}