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.
- An immutable
Expensevalue object created withData.define. - An
ExpenseTrackerclass 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_byandmax_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.
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.0coffee.frozen? # => true — Data objects are immutableStep 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"]) ) endend
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 endendStep 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 expenserescue Date::Error raise ArgumentError, "Invalid date '#{date}', use YYYY-MM-DD"endStep 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")endComplete Code
# expenses.rb — run with: ruby expenses.rbrequire "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 endend
# ---- 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) endend
# 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}"endputs
puts tracker.monthly_report(2026, 10)putsputs "Monthly trend:"tracker.monthly_totals.each do |month, total| puts format(" %s Rs %9.2f", month, total)endSample 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:
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) orBigDecimalinstead ofFloatto 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-09usingARGV.first. - Package the tracker as a small gem with a Gemfile and a
bin/expensesexecutable (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.