LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
RubyIntermediate~2 hours

Expense Tracker with CSV Reports

Record expenses in a CSV file and generate monthly reports with category totals, percentages and spending trends.

CSVData ObjectsEnumerableDate Handling

Overview

Tracking money is one of the most common reasons people write their first useful script, and it is a great fit for Ruby: the standard library can read and write spreadsheets (CSV) and parse dates, and Enumerable methods like group_by, sum and max_by turn raw rows into a meaningful report in a few lines.

Expenses are stored in expenses.csv, which you can also open in Excel or Google Sheets. The tracker adds new expenses with validation, then prints a monthly report: total spent, a breakdown by category with percentages and a simple bar chart, the biggest single expense, and a month-by-month trend.

What You'll Build
  • An immutable Expense value object created with Data.define.
  • An ExpenseTracker class that loads and saves a CSV file with headers.
  • Validation that rejects unknown categories, non-positive amounts and invalid dates.
  • A monthly report with group_by, sum, sort_by and max_by.
  • A month-by-month totals trend built from date strings.

Prerequisites

  • Ruby 3.2 or newer (for Data.define) — lesson 3.
  • Blocks and Enumerable methods — lessons 8 and 10.
  • Classes and instance variables — lesson 9.
  • Requiring standard libraries and gems — lesson 11.
About the csv Library

Since Ruby 3.4, csv is a "bundled gem" rather than a default library. It is still installed with Ruby, so require "csv" works in a plain script — but if your project uses Bundler, add gem "csv" to your Gemfile.

Project Structure

The program is a single file, expenses.rb, plus the expenses.csv data file it manages. Expense is a plain data record with no behaviour of its own. ExpenseTracker owns the list of expenses and the file, and exposes three kinds of methods: changing data (add), querying it (for_month, monthly_totals) and presenting it (monthly_report). The demo at the bottom seeds a few sample expenses the first time it runs.

Step 1: Model an Expense with Data.define

Data.define (Ruby 3.2+) creates a small immutable class with a constructor, readers, == and a readable inspect — perfect for a record like an expense that should never change after it is created. Unlike a hash, a typo such as expense.amout raises an error immediately instead of silently returning nil.

require "csv"
require "date"
Expense = Data.define(:date, :category, :description, :amount)
coffee = Expense.new(date: Date.new(2026, 10, 1), category: "food", description: "Coffee", amount: 120.0)
coffee.amount # => 120.0
coffee.frozen? # => true — Data objects are immutable

Step 2: Load & Save the CSV File

With headers: true, CSV.foreach yields each row as a CSV::Row you can read by column name, like row["amount"]. Everything in a CSV file is text, so each value is converted back into the right type — Date.iso8601 for dates and Float for amounts. Saving rewrites the whole file, which is simple and perfectly fast for personal data.

HEADERS = %w[date category description amount].freeze
def load
return [] unless File.exist?(@file)
CSV.foreach(@file, headers: true).map do |row|
Expense.new(
date: Date.iso8601(row["date"]),
category: row["category"],
description: row["description"],
amount: Float(row["amount"])
)
end
end
def save
CSV.open(@file, "w") do |csv|
csv << HEADERS
@expenses.each do |e|
csv << [e.date.iso8601, e.category, e.description, format("%.2f", e.amount)]
end
end
end

Step 3: Add Expenses Safely

Validation happens before anything is saved. Date.iso8601 raises Date::Error for an impossible date like 2026-02-30; the method converts that into an ArgumentError with a clear message, so callers only ever have to rescue one error type.

CATEGORIES = %w[food transport rent bills shopping fun other].freeze
def add(date:, category:, description:, amount:)
category = category.to_s.downcase
raise ArgumentError, "Unknown category '#{category}'" unless CATEGORIES.include?(category)
raise ArgumentError, "Amount must be greater than zero" unless amount.to_f.positive?
expense = Expense.new(
date: Date.iso8601(date.to_s),
category: category,
description: description.strip,
amount: amount.to_f.round(2)
)
@expenses << expense
save
expense
rescue Date::Error
raise ArgumentError, "Invalid date '#{date}', use YYYY-MM-DD"
end

Step 4: Monthly Reports with group_by & sum

group_by(&:category) turns a flat list into a hash of category => [expenses]; transform_values { |list| list.sum(&:amount) } then replaces each list with its total. Sorting by the negated total puts the biggest category first, and "#" * (percent / 5) draws a quick text bar chart — one # per 5%.

def monthly_report(year, month)
expenses = for_month(year, month)
title = Date.new(year, month, 1).strftime("%B %Y")
return "No expenses recorded for #{title}." if expenses.empty?
total = expenses.sum(&:amount)
by_category = expenses
.group_by(&:category)
.transform_values { |list| list.sum(&:amount) }
.sort_by { |_, amount| -amount }
lines = ["=== #{title} ===", format("Total spent: Rs %.2f", total), ""]
by_category.each do |category, amount|
percent = amount / total * 100
lines << format(" %-10s Rs %9.2f %5.1f%% %s", category, amount, percent, "#" * (percent / 5).round)
end
biggest = expenses.max_by(&:amount)
lines << ""
lines << format("Biggest expense: %s (Rs %.2f on %s)", biggest.description, biggest.amount, biggest.date.strftime("%d %b"))
lines.join("\n")
end

Complete Code

expenses.rb
# expenses.rb — run with: ruby expenses.rb
require "csv"
require "date"
Expense = Data.define(:date, :category, :description, :amount)
class ExpenseTracker
HEADERS = %w[date category description amount].freeze
CATEGORIES = %w[food transport rent bills shopping fun other].freeze
attr_reader :expenses
def initialize(file)
@file = file
@expenses = load
end
def add(date:, category:, description:, amount:)
category = category.to_s.downcase
raise ArgumentError, "Unknown category '#{category}'" unless CATEGORIES.include?(category)
raise ArgumentError, "Amount must be greater than zero" unless amount.to_f.positive?
expense = Expense.new(
date: Date.iso8601(date.to_s),
category: category,
description: description.strip,
amount: amount.to_f.round(2)
)
@expenses << expense
save
expense
rescue Date::Error
raise ArgumentError, "Invalid date '#{date}', use YYYY-MM-DD"
end
def for_month(year, month)
@expenses.select { |e| e.date.year == year && e.date.month == month }
end
def monthly_totals
@expenses
.group_by { |e| e.date.strftime("%Y-%m") }
.transform_values { |list| list.sum(&:amount) }
.sort
.to_h
end
def monthly_report(year, month)
expenses = for_month(year, month)
title = Date.new(year, month, 1).strftime("%B %Y")
return "No expenses recorded for #{title}." if expenses.empty?
total = expenses.sum(&:amount)
by_category = expenses
.group_by(&:category)
.transform_values { |list| list.sum(&:amount) }
.sort_by { |_, amount| -amount }
lines = ["=== #{title} ===", format("Total spent: Rs %.2f", total), ""]
by_category.each do |category, amount|
percent = amount / total * 100
lines << format(" %-10s Rs %9.2f %5.1f%% %s", category, amount, percent, "#" * (percent / 5).round)
end
biggest = expenses.max_by(&:amount)
lines << ""
lines << format("Biggest expense: %s (Rs %.2f on %s)", biggest.description, biggest.amount, biggest.date.strftime("%d %b"))
lines.join("\n")
end
private
def load
return [] unless File.exist?(@file)
CSV.foreach(@file, headers: true).map do |row|
Expense.new(
date: Date.iso8601(row["date"]),
category: row["category"],
description: row["description"],
amount: Float(row["amount"])
)
end
end
def save
CSV.open(@file, "w") do |csv|
csv << HEADERS
@expenses.each do |e|
csv << [e.date.iso8601, e.category, e.description, format("%.2f", e.amount)]
end
end
end
end
# ---- Demo ----
tracker = ExpenseTracker.new("expenses.csv")
if tracker.expenses.empty?
puts "No data yet - adding sample expenses..."
[
["2026-09-03", "rent", "September rent", 12000],
["2026-09-10", "food", "Groceries", 2350.50],
["2026-09-21", "transport", "Metro card", 600],
["2026-10-01", "rent", "October rent", 12000],
["2026-10-02", "food", "Groceries", 1890.75],
["2026-10-04", "transport", "Cab to airport", 780],
["2026-10-05", "fun", "Movie night", 450],
["2026-10-06", "food", "Dinner with friends", 1240],
["2026-10-07", "bills", "Mobile recharge", 299],
].each do |date, category, description, amount|
tracker.add(date: date, category: category, description: description, amount: amount)
end
end
# Validation in action
[
{ date: "2026-10-08", category: "travel", description: "Train", amount: 300 },
{ date: "2026-10-08", category: "food", description: "Refund?", amount: -50 },
{ date: "2026-02-30", category: "food", description: "Lunch", amount: 200 },
].each do |attrs|
tracker.add(**attrs)
rescue ArgumentError => e
puts "Rejected: #{e.message}"
end
puts
puts tracker.monthly_report(2026, 10)
puts
puts "Monthly trend:"
tracker.monthly_totals.each do |month, total|
puts format(" %s Rs %9.2f", month, total)
end

Sample Run

ruby expenses.rb (first run)

Click Run to see what this code prints.

The data file is plain CSV, so you can open it in any spreadsheet app or edit it by hand:

expenses.csv (first lines)

Click Run to see what this code prints.

Extend This Project

  • Add an interactive menu (like the Contact Book) so you can add expenses and pick the report month from the keyboard.
  • Store money as whole paise (Integer) or BigDecimal instead of Float to avoid rounding surprises with currency.
  • Add monthly budgets per category and warn when spending goes over, e.g. "food: 95% of budget used".
  • Accept a month on the command line: ruby expenses.rb 2026-09 using ARGV.first.
  • Package the tracker as a small gem with a Gemfile and a bin/expenses executable (lesson 11).

Summary

You built a practical personal-finance tool on top of Ruby's standard library: Data.define for safe, immutable records, CSV for spreadsheet-friendly storage, Date for real calendar handling, and a chain of Enumerable methods — select, group_by, transform_values, sum, sort_by, max_by — that turns raw rows into a readable report. That chain-of-small-transformations style is the essence of idiomatic Ruby.