21 min read

> "A program is a model of reality, and a good program is a good model."

Learning Objectives

  • Design and implement a complete, multi-unit Pascal application from requirements to deployment
  • Apply object-oriented design principles to decompose a real-world problem into classes and units
  • Integrate a SQLite database backend with a Lazarus GUI frontend
  • Implement CSV import/export, background threading, and system tray integration in a single application
  • Write unit tests with FPCUnit and create a cross-platform build and deployment pipeline
  • Reflect on the full arc of learning Pascal — from Hello World to professional-grade software

Chapter 38: Capstone Project: A Complete Cross-Platform Application from Design to Deployment

"The purpose of computing is insight, not numbers." — Richard Hamming

"A program is a model of reality, and a good program is a good model." — Niklaus Wirth (paraphrased)


You have arrived.

Thirty-seven chapters ago, you typed WriteLn('Hello, World!') and watched it appear on your screen. You did not know what a variable was. You did not know what a loop did. You did not know what a class, a unit, a thread, or a database was. You wrote one line, compiled it, ran it, and something happened. That was the beginning.

Since then, you have learned an entire programming language — not just its syntax, but its philosophy. You have learned to think about types before you think about code. You have learned that procedures are for clarity and functions are for computation. You have built data structures from scratch, sorted them with algorithms you implemented by hand, connected them to graphical interfaces, persisted them in databases, transmitted them over networks, and run them on multiple threads simultaneously. You have done all of this in Pascal, a language designed from its first specification to teach you to think clearly, design carefully, and write code that works.

Now we build something real.

This chapter is the capstone. We are going to take PennyWise — the personal finance manager that has grown alongside you since Chapter 1, accumulating features chapter by chapter like a snowball rolling downhill — and assemble its final form: PennyWise 2.0. This is not a toy. This is not a demonstration. This is a complete, cross-platform, database-backed, network-capable desktop application that a real user could download, install, and use to manage their finances.

Every technique you have learned will appear in this chapter. Object-oriented design from Part III. Algorithms and data structures from Part IV. The Lazarus GUI from Part V. Units, file formats, networking, threading, and OS integration from Part VI. All of it, integrated into a single coherent system.

Rosa is here. Tomas is here. They have been with you since the beginning, and their versions of PennyWise have grown just as yours has. Rosa's version tracks her freelance income alongside her expenses and generates the monthly reports her accountant needs. Tomas's version alerts him when he approaches his food budget limit — a feature that has prevented two overdrafts this semester. Same codebase, different configurations, different emphasis. That is the power of well-architected software.

Let us build it.


38.1 The Capstone Challenge

What We Are Building

PennyWise 2.0 is a personal finance manager with the following capabilities:

  1. Expense tracking. Add, edit, and delete expenses with date, amount, category, and description.
  2. Income tracking. Record income entries with source, date, and amount.
  3. Budget management. Set monthly budgets per category. Receive alerts when spending approaches or exceeds the budget.
  4. Reporting. Generate monthly and yearly summaries. View totals by category. Export reports to CSV.
  5. Persistent storage. All data stored in a local SQLite database. Automatic backup on exit.
  6. Import/Export. Import bank transactions from CSV files. Export all data to CSV or JSON.
  7. REST sync (optional). A local HTTP endpoint that serves expense data as JSON, enabling integration with a companion web dashboard or mobile app.
  8. Background operations. Auto-save runs on a background thread. Network sync (if enabled) runs asynchronously.
  9. System tray integration. PennyWise minimizes to the system tray with a budget-status icon.
  10. Cross-platform. The application compiles and runs on Windows, Linux, and macOS without code changes.

The Requirements Specification

A capstone project begins not with code but with a requirements document. In professional software development, requirements capture what the software must do before anyone writes a line of implementation. Here is ours, expressed as user stories:

Core Functional Requirements:

ID User Story Priority
FR-01 As a user, I can add an expense with date, amount, category, and description Must
FR-02 As a user, I can add an income entry with date, amount, source, and description Must
FR-03 As a user, I can edit or delete any existing transaction Must
FR-04 As a user, I can set a monthly budget for each expense category Must
FR-05 As a user, I can view a summary of expenses for any month, grouped by category Must
FR-06 As a user, I can view a summary of income vs. expenses for any month Must
FR-07 As a user, I receive a visual warning when spending in any category exceeds 80% of its budget Should
FR-08 As a user, I can import transactions from a CSV file exported by my bank Should
FR-09 As a user, I can export all my data to CSV or JSON format Should
FR-10 As a user, the application saves my data automatically in the background Must
FR-11 As a user, I can minimize the application to the system tray Nice
FR-12 As a user, I can access my expense data via a local REST endpoint Nice

Non-Functional Requirements:

ID Requirement Target
NF-01 Application startup time < 2 seconds
NF-02 Database capacity 100,000+ transactions without degradation
NF-03 Memory usage < 50 MB for typical use
NF-04 Cross-platform Windows, Linux, macOS with single codebase
NF-05 No external runtime Native compiled, no interpreter or VM required

Notice that NF-05 is free — we get it automatically because this is Pascal. Native compilation is not a feature we have to add; it is a feature of the language.

💡 Insight: Why Requirements Matter Rosa learned this the hard way. In an early version of PennyWise, she started coding the CSV import feature without writing down exactly what CSV formats she needed to support. She spent two days building an importer that handled comma-separated values perfectly — and then discovered that her bank exported semicolon-separated values with quoted fields. A thirty-minute requirements analysis would have saved two days of rework.


38.2 Software Design: Architecture and Planning

The Module Architecture

PennyWise 2.0 is organized into five units plus the main program:

PennyWise2.lpr          (main program / Lazarus project file)
  |
  +-- FinanceCore.pas    (data models, business logic, validation)
  +-- FinanceDB.pas      (SQLite database operations, queries, migrations)
  +-- FinanceUI.pas      (Lazarus forms, dialogs, charts, menus)
  +-- FinanceExport.pas  (CSV/JSON import and export)
  +-- FinanceSync.pas    (REST server, background thread, system tray)

This modular structure follows the principle we established in Chapter 33: each unit has a single, well-defined responsibility. FinanceCore knows nothing about databases, the UI, or the network. FinanceDB knows nothing about how data is displayed. FinanceUI knows nothing about how data is stored. Each unit communicates through well-defined interfaces — the same principle that makes the Free Pascal compiler itself maintainable across twenty platforms.

📊 Architecture Principle: Separation of Concerns If you change how expenses are stored (say, migrating from SQLite to PostgreSQL), only FinanceDB changes. If you redesign the user interface, only FinanceUI changes. If you add a new export format, only FinanceExport changes. This is not just good practice — it is the difference between software that can be maintained for years and software that collapses under its own weight the moment you try to modify it.

The Class Hierarchy

At the heart of PennyWise 2.0 is a class hierarchy that models the financial domain:

{ Base class for all financial transactions }
TTransaction = class
  private
    FID: Integer;
    FDate: TDateTime;
    FAmount: Currency;
    FDescription: String;
    FCategory: String;
  public
    constructor Create(ADate: TDateTime; AAmount: Currency;
      ADescription, ACategory: String);
    property ID: Integer read FID write FID;
    property Date: TDateTime read FDate write FDate;
    property Amount: Currency read FAmount write FAmount;
    property Description: String read FDescription write FDescription;
    property Category: String read FCategory write FCategory;
    function ToString: String; override;
end;

{ Expense: money going out }
TExpense = class(TTransaction)
  private
    FIsRecurring: Boolean;
    FRecurrenceInterval: Integer; { days, 0 = not recurring }
  public
    property IsRecurring: Boolean read FIsRecurring write FIsRecurring;
    property RecurrenceInterval: Integer
      read FRecurrenceInterval write FRecurrenceInterval;
end;

{ Income: money coming in }
TIncome = class(TTransaction)
  private
    FSource: String;
  public
    property Source: String read FSource write FSource;
end;

{ Budget: spending limit for a category in a given month }
TBudget = class
  private
    FCategory: String;
    FMonthlyLimit: Currency;
    FMonth: Integer;
    FYear: Integer;
  public
    constructor Create(ACategory: String; ALimit: Currency;
      AMonth, AYear: Integer);
    property Category: String read FCategory write FCategory;
    property MonthlyLimit: Currency read FMonthlyLimit write FMonthlyLimit;
    property Month: Integer read FMonth write FMonth;
    property Year: Integer read FYear write FYear;
    function IsOverBudget(Spent: Currency): Boolean;
    function PercentUsed(Spent: Currency): Double;
end;

Notice the inheritance hierarchy. TExpense and TIncome both derive from TTransaction, sharing the common fields (ID, date, amount, description, category) and adding their specialized attributes. This is not inheritance for its own sake — it models a real relationship. Every expense is a transaction. Every income entry is a transaction. The shared base class means we can write code that operates on any transaction polymorphically:

procedure DisplayTransaction(T: TTransaction);
begin
  WriteLn(Format('%s  %s  %10.2f  %s',
    [DateToStr(T.Date), T.Category, T.Amount, T.Description]));
end;

This procedure works with both expenses and income entries because both are TTransaction descendants. Polymorphism in action — exactly as we learned in Chapter 17.

The Database Schema

PennyWise 2.0 stores all data in a SQLite database. The schema has four tables:

CREATE TABLE IF NOT EXISTS transactions (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    trans_type  TEXT NOT NULL CHECK(trans_type IN ('expense', 'income')),
    date        TEXT NOT NULL,       -- ISO 8601 format: YYYY-MM-DD
    amount      REAL NOT NULL,
    description TEXT,
    category    TEXT NOT NULL,
    source      TEXT,                -- NULL for expenses
    is_recurring INTEGER DEFAULT 0,  -- 0 = false, 1 = true
    recurrence_days INTEGER DEFAULT 0,
    created_at  TEXT DEFAULT (datetime('now')),
    updated_at  TEXT DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS budgets (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    category   TEXT NOT NULL,
    monthly_limit REAL NOT NULL,
    month      INTEGER NOT NULL,     -- 1-12
    year       INTEGER NOT NULL,
    UNIQUE(category, month, year)
);

CREATE TABLE IF NOT EXISTS categories (
    id   INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE,
    parent_category TEXT,             -- for hierarchical categories
    icon TEXT                         -- emoji or icon name
);

CREATE TABLE IF NOT EXISTS app_settings (
    key   TEXT PRIMARY KEY,
    value TEXT
);

The transactions table uses a single-table inheritance pattern: both expenses and income live in the same table, distinguished by the trans_type column. This simplifies queries that need to operate on all transactions (monthly summaries, date-range reports) while still allowing type-specific queries through a WHERE clause. The source column is NULL for expenses and populated for income entries. The is_recurring and recurrence_days columns are meaningful only for expenses.

⚠️ Design Decision: Single Table vs. Separate Tables An alternative design would use separate expenses and income tables. That approach has the advantage of stricter schema enforcement (no nullable columns used only by one type) but the disadvantage of requiring UNION queries for combined reports. For PennyWise's relatively simple data model, single-table inheritance is the pragmatic choice. In a larger application — say, one with dozens of transaction types — separate tables with a common view would be more appropriate.

The State Diagram

PennyWise 2.0 operates as an event-driven application. The main form responds to user actions:

[Start] --> [Load Database] --> [Main Form Displayed]
    |
    +--> [Add Transaction] --> [Validate] --> [Save to DB] --> [Refresh View]
    |
    +--> [Edit Transaction] --> [Load into Form] --> [Validate] --> [Update DB] --> [Refresh View]
    |
    +--> [Delete Transaction] --> [Confirm] --> [Delete from DB] --> [Refresh View]
    |
    +--> [Set Budget] --> [Validate] --> [Save Budget] --> [Check Alerts]
    |
    +--> [Import CSV] --> [Parse File] --> [Map Columns] --> [Insert Batch] --> [Refresh View]
    |
    +--> [Export] --> [Choose Format] --> [Generate File] --> [Save Dialog]
    |
    +--> [View Report] --> [Query DB] --> [Display Chart/Grid]
    |
    +--> [Close/Minimize] --> [Background Auto-Save] --> [System Tray]

Each path through this diagram corresponds to a user story from our requirements. The event-driven architecture means the application is always responsive — the user is never waiting for a blocking operation because long-running tasks (database queries on large datasets, CSV import, network sync) execute on background threads.


38.3 Implementing the Core: FinanceCore Unit

The FinanceCore unit contains the data model classes shown above, plus the business logic that operates on them. It has no dependencies on the database, the UI, or the network — it is pure domain logic.

The Transaction Manager

The central class in FinanceCore is TTransactionManager, which manages an in-memory collection of transactions and provides the business operations:

unit FinanceCore;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, DateUtils, Math, Generics.Collections;

type
  { Forward declarations }
  TTransaction = class;
  TExpense = class;
  TIncome = class;
  TBudget = class;

  { Transaction type enumeration }
  TTransactionType = (ttExpense, ttIncome);

  { Base transaction class }
  TTransaction = class
  private
    FID: Integer;
    FTransType: TTransactionType;
    FDate: TDateTime;
    FAmount: Currency;
    FDescription: String;
    FCategory: String;
  public
    constructor Create(AType: TTransactionType; ADate: TDateTime;
      AAmount: Currency; const ADescription, ACategory: String);
    property ID: Integer read FID write FID;
    property TransType: TTransactionType read FTransType;
    property Date: TDateTime read FDate write FDate;
    property Amount: Currency read FAmount write FAmount;
    property Description: String read FDescription write FDescription;
    property Category: String read FCategory write FCategory;
    function IsExpense: Boolean;
    function IsIncome: Boolean;
    function ToDisplayString: String; virtual;
  end;

  { Expense transaction }
  TExpense = class(TTransaction)
  private
    FIsRecurring: Boolean;
    FRecurrenceDays: Integer;
  public
    constructor Create(ADate: TDateTime; AAmount: Currency;
      const ADescription, ACategory: String;
      ARecurring: Boolean = False; ARecurrenceDays: Integer = 0);
    property IsRecurring: Boolean read FIsRecurring write FIsRecurring;
    property RecurrenceDays: Integer read FRecurrenceDays write FRecurrenceDays;
    function ToDisplayString: String; override;
  end;

  { Income transaction }
  TIncome = class(TTransaction)
  private
    FSource: String;
  public
    constructor Create(ADate: TDateTime; AAmount: Currency;
      const ADescription, ACategory, ASource: String);
    property Source: String read FSource write FSource;
    function ToDisplayString: String; override;
  end;

  { Budget for a category/month }
  TBudget = class
  private
    FID: Integer;
    FCategory: String;
    FMonthlyLimit: Currency;
    FMonth: Integer;
    FYear: Integer;
  public
    constructor Create(const ACategory: String; ALimit: Currency;
      AMonth, AYear: Integer);
    property ID: Integer read FID write FID;
    property Category: String read FCategory write FCategory;
    property MonthlyLimit: Currency read FMonthlyLimit write FMonthlyLimit;
    property Month: Integer read FMonth write FMonth;
    property Year: Integer read FYear write FYear;
    function IsOverBudget(Spent: Currency): Boolean;
    function PercentUsed(Spent: Currency): Double;
    function StatusText(Spent: Currency): String;
  end;

  { Transaction list with typed access }
  TTransactionList = specialize TObjectList<TTransaction>;
  TBudgetList = specialize TObjectList<TBudget>;

  { Category summary record }
  TCategorySummary = record
    Category: String;
    TotalSpent: Currency;
    TotalIncome: Currency;
    TransactionCount: Integer;
    BudgetLimit: Currency;
    PercentUsed: Double;
  end;

  TCategorySummaryArray = array of TCategorySummary;

  { Monthly report record }
  TMonthlyReport = record
    Month: Integer;
    Year: Integer;
    TotalExpenses: Currency;
    TotalIncome: Currency;
    NetBalance: Currency;
    Categories: TCategorySummaryArray;
    TransactionCount: Integer;
  end;

  { Transaction manager — core business logic }
  TTransactionManager = class
  private
    FTransactions: TTransactionList;
    FBudgets: TBudgetList;
    FModified: Boolean;
    function GetTransactionCount: Integer;
  public
    constructor Create;
    destructor Destroy; override;

    { Transaction operations }
    procedure AddTransaction(T: TTransaction);
    procedure RemoveTransaction(AID: Integer);
    function FindTransaction(AID: Integer): TTransaction;

    { Query operations }
    function GetExpensesForMonth(AMonth, AYear: Integer): TTransactionList;
    function GetIncomeForMonth(AMonth, AYear: Integer): TTransactionList;
    function GetTotalExpenses(AMonth, AYear: Integer): Currency;
    function GetTotalIncome(AMonth, AYear: Integer): Currency;
    function GetCategorySummaries(AMonth, AYear: Integer): TCategorySummaryArray;
    function GenerateMonthlyReport(AMonth, AYear: Integer): TMonthlyReport;

    { Budget operations }
    procedure SetBudget(const ACategory: String; ALimit: Currency;
      AMonth, AYear: Integer);
    function GetBudget(const ACategory: String;
      AMonth, AYear: Integer): TBudget;
    function GetOverBudgetCategories(AMonth, AYear: Integer): TStringList;

    { Sorting }
    procedure SortByDate(Ascending: Boolean = True);
    procedure SortByAmount(Ascending: Boolean = True);
    procedure SortByCategory;

    { Validation }
    class function ValidateAmount(AAmount: Currency): Boolean;
    class function ValidateDate(ADate: TDateTime): Boolean;
    class function ValidateCategory(const ACategory: String): Boolean;

    { Properties }
    property TransactionCount: Integer read GetTransactionCount;
    property Transactions: TTransactionList read FTransactions;
    property Budgets: TBudgetList read FBudgets;
    property Modified: Boolean read FModified write FModified;
  end;

implementation

{ ---- TTransaction ---- }

constructor TTransaction.Create(AType: TTransactionType; ADate: TDateTime;
  AAmount: Currency; const ADescription, ACategory: String);
begin
  inherited Create;
  FID := -1; { unassigned until saved to database }
  FTransType := AType;
  FDate := ADate;
  FAmount := AAmount;
  FDescription := ADescription;
  FCategory := ACategory;
end;

function TTransaction.IsExpense: Boolean;
begin
  Result := FTransType = ttExpense;
end;

function TTransaction.IsIncome: Boolean;
begin
  Result := FTransType = ttIncome;
end;

function TTransaction.ToDisplayString: String;
begin
  Result := Format('%s | %-12s | %10.2f | %s',
    [FormatDateTime('yyyy-mm-dd', FDate), FCategory, FAmount, FDescription]);
end;

{ ---- TExpense ---- }

constructor TExpense.Create(ADate: TDateTime; AAmount: Currency;
  const ADescription, ACategory: String;
  ARecurring: Boolean; ARecurrenceDays: Integer);
begin
  inherited Create(ttExpense, ADate, AAmount, ADescription, ACategory);
  FIsRecurring := ARecurring;
  FRecurrenceDays := ARecurrenceDays;
end;

function TExpense.ToDisplayString: String;
var
  RecurStr: String;
begin
  if FIsRecurring then
    RecurStr := Format(' [every %d days]', [FRecurrenceDays])
  else
    RecurStr := '';
  Result := Format('EXP  %s | %-12s | %10.2f | %s%s',
    [FormatDateTime('yyyy-mm-dd', Date), Category, Amount,
     Description, RecurStr]);
end;

{ ---- TIncome ---- }

constructor TIncome.Create(ADate: TDateTime; AAmount: Currency;
  const ADescription, ACategory, ASource: String);
begin
  inherited Create(ttIncome, ADate, AAmount, ADescription, ACategory);
  FSource := ASource;
end;

function TIncome.ToDisplayString: String;
begin
  Result := Format('INC  %s | %-12s | %10.2f | %s (from: %s)',
    [FormatDateTime('yyyy-mm-dd', Date), Category, Amount,
     Description, FSource]);
end;

{ ---- TBudget ---- }

constructor TBudget.Create(const ACategory: String; ALimit: Currency;
  AMonth, AYear: Integer);
begin
  inherited Create;
  FID := -1;
  FCategory := ACategory;
  FMonthlyLimit := ALimit;
  FMonth := AMonth;
  FYear := AYear;
end;

function TBudget.IsOverBudget(Spent: Currency): Boolean;
begin
  Result := Spent > FMonthlyLimit;
end;

function TBudget.PercentUsed(Spent: Currency): Double;
begin
  if FMonthlyLimit > 0 then
    Result := (Spent / FMonthlyLimit) * 100.0
  else
    Result := 0.0;
end;

function TBudget.StatusText(Spent: Currency): String;
var
  Pct: Double;
begin
  Pct := PercentUsed(Spent);
  if Pct >= 100 then
    Result := Format('OVER BUDGET: %s spent %.2f of %.2f (%.0f%%)',
      [FCategory, Spent, FMonthlyLimit, Pct])
  else if Pct >= 80 then
    Result := Format('WARNING: %s spent %.2f of %.2f (%.0f%%)',
      [FCategory, Spent, FMonthlyLimit, Pct])
  else
    Result := Format('OK: %s spent %.2f of %.2f (%.0f%%)',
      [FCategory, Spent, FMonthlyLimit, Pct]);
end;

{ ---- TTransactionManager ---- }

constructor TTransactionManager.Create;
begin
  inherited Create;
  FTransactions := TTransactionList.Create(True); { owns objects }
  FBudgets := TBudgetList.Create(True);
  FModified := False;
end;

destructor TTransactionManager.Destroy;
begin
  FTransactions.Free;
  FBudgets.Free;
  inherited Destroy;
end;

function TTransactionManager.GetTransactionCount: Integer;
begin
  Result := FTransactions.Count;
end;

procedure TTransactionManager.AddTransaction(T: TTransaction);
begin
  FTransactions.Add(T);
  FModified := True;
end;

procedure TTransactionManager.RemoveTransaction(AID: Integer);
var
  I: Integer;
begin
  for I := FTransactions.Count - 1 downto 0 do
    if FTransactions[I].ID = AID then
    begin
      FTransactions.Delete(I);
      FModified := True;
      Exit;
    end;
end;

function TTransactionManager.FindTransaction(AID: Integer): TTransaction;
var
  I: Integer;
begin
  Result := nil;
  for I := 0 to FTransactions.Count - 1 do
    if FTransactions[I].ID = AID then
      Exit(FTransactions[I]);
end;

function TTransactionManager.GetExpensesForMonth(
  AMonth, AYear: Integer): TTransactionList;
var
  I: Integer;
  T: TTransaction;
begin
  Result := TTransactionList.Create(False); { does not own objects }
  for I := 0 to FTransactions.Count - 1 do
  begin
    T := FTransactions[I];
    if T.IsExpense and (MonthOf(T.Date) = AMonth)
       and (YearOf(T.Date) = AYear) then
      Result.Add(T);
  end;
end;

function TTransactionManager.GetIncomeForMonth(
  AMonth, AYear: Integer): TTransactionList;
var
  I: Integer;
  T: TTransaction;
begin
  Result := TTransactionList.Create(False);
  for I := 0 to FTransactions.Count - 1 do
  begin
    T := FTransactions[I];
    if T.IsIncome and (MonthOf(T.Date) = AMonth)
       and (YearOf(T.Date) = AYear) then
      Result.Add(T);
  end;
end;

function TTransactionManager.GetTotalExpenses(
  AMonth, AYear: Integer): Currency;
var
  I: Integer;
  T: TTransaction;
begin
  Result := 0;
  for I := 0 to FTransactions.Count - 1 do
  begin
    T := FTransactions[I];
    if T.IsExpense and (MonthOf(T.Date) = AMonth)
       and (YearOf(T.Date) = AYear) then
      Result := Result + T.Amount;
  end;
end;

function TTransactionManager.GetTotalIncome(
  AMonth, AYear: Integer): Currency;
var
  I: Integer;
  T: TTransaction;
begin
  Result := 0;
  for I := 0 to FTransactions.Count - 1 do
  begin
    T := FTransactions[I];
    if T.IsIncome and (MonthOf(T.Date) = AMonth)
       and (YearOf(T.Date) = AYear) then
      Result := Result + T.Amount;
  end;
end;

function TTransactionManager.GetCategorySummaries(
  AMonth, AYear: Integer): TCategorySummaryArray;
var
  CatMap: specialize TDictionary<String, Integer>;
  I, Idx: Integer;
  T: TTransaction;
  B: TBudget;
begin
  CatMap := specialize TDictionary<String, Integer>.Create;
  try
    SetLength(Result, 0);
    for I := 0 to FTransactions.Count - 1 do
    begin
      T := FTransactions[I];
      if (MonthOf(T.Date) = AMonth) and (YearOf(T.Date) = AYear) then
      begin
        if not CatMap.TryGetValue(T.Category, Idx) then
        begin
          Idx := Length(Result);
          SetLength(Result, Idx + 1);
          Result[Idx].Category := T.Category;
          Result[Idx].TotalSpent := 0;
          Result[Idx].TotalIncome := 0;
          Result[Idx].TransactionCount := 0;
          Result[Idx].BudgetLimit := 0;
          Result[Idx].PercentUsed := 0;
          CatMap.Add(T.Category, Idx);
        end;
        if T.IsExpense then
          Result[Idx].TotalSpent := Result[Idx].TotalSpent + T.Amount
        else
          Result[Idx].TotalIncome := Result[Idx].TotalIncome + T.Amount;
        Inc(Result[Idx].TransactionCount);
      end;
    end;
    { Fill in budget data }
    for I := 0 to Length(Result) - 1 do
    begin
      B := GetBudget(Result[I].Category, AMonth, AYear);
      if Assigned(B) then
      begin
        Result[I].BudgetLimit := B.MonthlyLimit;
        Result[I].PercentUsed := B.PercentUsed(Result[I].TotalSpent);
      end;
    end;
  finally
    CatMap.Free;
  end;
end;

function TTransactionManager.GenerateMonthlyReport(
  AMonth, AYear: Integer): TMonthlyReport;
begin
  Result.Month := AMonth;
  Result.Year := AYear;
  Result.TotalExpenses := GetTotalExpenses(AMonth, AYear);
  Result.TotalIncome := GetTotalIncome(AMonth, AYear);
  Result.NetBalance := Result.TotalIncome - Result.TotalExpenses;
  Result.Categories := GetCategorySummaries(AMonth, AYear);
  Result.TransactionCount := 0;
  { Count transactions for the month }
  for var I := 0 to FTransactions.Count - 1 do
    if (MonthOf(FTransactions[I].Date) = AMonth)
       and (YearOf(FTransactions[I].Date) = AYear) then
      Inc(Result.TransactionCount);
end;

procedure TTransactionManager.SetBudget(const ACategory: String;
  ALimit: Currency; AMonth, AYear: Integer);
var
  B: TBudget;
begin
  B := GetBudget(ACategory, AMonth, AYear);
  if Assigned(B) then
    B.MonthlyLimit := ALimit
  else
  begin
    B := TBudget.Create(ACategory, ALimit, AMonth, AYear);
    FBudgets.Add(B);
  end;
  FModified := True;
end;

function TTransactionManager.GetBudget(const ACategory: String;
  AMonth, AYear: Integer): TBudget;
var
  I: Integer;
begin
  Result := nil;
  for I := 0 to FBudgets.Count - 1 do
    if (FBudgets[I].Category = ACategory) and
       (FBudgets[I].Month = AMonth) and
       (FBudgets[I].Year = AYear) then
      Exit(FBudgets[I]);
end;

function TTransactionManager.GetOverBudgetCategories(
  AMonth, AYear: Integer): TStringList;
var
  Summaries: TCategorySummaryArray;
  I: Integer;
begin
  Result := TStringList.Create;
  Summaries := GetCategorySummaries(AMonth, AYear);
  for I := 0 to Length(Summaries) - 1 do
    if (Summaries[I].BudgetLimit > 0) and
       (Summaries[I].TotalSpent > Summaries[I].BudgetLimit) then
      Result.Add(Format('%s: %.2f / %.2f',
        [Summaries[I].Category, Summaries[I].TotalSpent,
         Summaries[I].BudgetLimit]));
end;

procedure TTransactionManager.SortByDate(Ascending: Boolean);
begin
  FTransactions.Sort(specialize TComparer<TTransaction>.Construct(
    function(const A, B: TTransaction): Integer
    begin
      Result := CompareDateTime(A.Date, B.Date);
      if not Ascending then
        Result := -Result;
    end));
end;

procedure TTransactionManager.SortByAmount(Ascending: Boolean);
begin
  FTransactions.Sort(specialize TComparer<TTransaction>.Construct(
    function(const A, B: TTransaction): Integer
    begin
      if A.Amount < B.Amount then Result := -1
      else if A.Amount > B.Amount then Result := 1
      else Result := 0;
      if not Ascending then
        Result := -Result;
    end));
end;

procedure TTransactionManager.SortByCategory;
begin
  FTransactions.Sort(specialize TComparer<TTransaction>.Construct(
    function(const A, B: TTransaction): Integer
    begin
      Result := CompareText(A.Category, B.Category);
    end));
end;

class function TTransactionManager.ValidateAmount(AAmount: Currency): Boolean;
begin
  Result := AAmount > 0;
end;

class function TTransactionManager.ValidateDate(ADate: TDateTime): Boolean;
begin
  { Allow dates from the year 2000 up to one year in the future }
  Result := (ADate >= EncodeDate(2000, 1, 1)) and
            (ADate <= IncYear(Now, 1));
end;

class function TTransactionManager.ValidateCategory(
  const ACategory: String): Boolean;
begin
  Result := Trim(ACategory) <> '';
end;

end.

This is a substantial unit — roughly 350 lines — and every line does something meaningful. There is no boilerplate, no framework overhead, no dependency injection container. The classes model the domain directly. The business logic is explicit. A new developer reading this code for the first time can understand it in minutes, not hours. This is Pascal's gift: the code says what it means.

💡 Design Pattern: The Manager Class The TTransactionManager follows the Manager (or Service) pattern: a class that coordinates operations on a collection of domain objects. It is not a "God class" — it does not know about the database, the UI, or the network. Its only job is to manage transactions and budgets in memory and answer questions about them. This separation makes it testable: we can create a TTransactionManager, add transactions, and verify that GetTotalExpenses returns the correct value — all without touching a database or drawing a single pixel on screen.


38.4 The Database Layer: FinanceDB Unit

The FinanceDB unit handles all interaction with the SQLite database. It translates between the in-memory objects defined in FinanceCore and the relational tables stored on disk.

Connection Management

unit FinanceDB;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, SQLite3Conn, SQLDB, FinanceCore;

type
  TFinanceDatabase = class
  private
    FConnection: TSQLite3Connection;
    FTransaction: TSQLTransaction;
    FDatabasePath: String;
    procedure InitializeSchema;
    procedure RunMigrations;
  public
    constructor Create(const APath: String);
    destructor Destroy; override;

    { Transaction CRUD }
    procedure SaveTransaction(T: TTransaction);
    procedure UpdateTransaction(T: TTransaction);
    procedure DeleteTransaction(AID: Integer);
    procedure LoadAllTransactions(Manager: TTransactionManager);

    { Budget CRUD }
    procedure SaveBudget(B: TBudget);
    procedure LoadAllBudgets(Manager: TTransactionManager);

    { Queries }
    function GetTransactionsByDateRange(
      StartDate, EndDate: TDateTime): TTransactionList;
    function GetCategoryTotals(AMonth, AYear: Integer): TStringList;

    { Maintenance }
    procedure BackupDatabase(const ABackupPath: String);
    procedure Vacuum;

    { Settings }
    procedure SaveSetting(const AKey, AValue: String);
    function LoadSetting(const AKey, ADefault: String): String;

    property DatabasePath: String read FDatabasePath;
  end;

implementation

constructor TFinanceDatabase.Create(const APath: String);
begin
  inherited Create;
  FDatabasePath := APath;

  FConnection := TSQLite3Connection.Create(nil);
  FConnection.DatabaseName := APath;

  FTransaction := TSQLTransaction.Create(nil);
  FTransaction.Database := FConnection;
  FConnection.Transaction := FTransaction;

  FConnection.Open;
  InitializeSchema;
  RunMigrations;
end;

destructor TFinanceDatabase.Destroy;
begin
  if FConnection.Connected then
    FConnection.Close;
  FTransaction.Free;
  FConnection.Free;
  inherited Destroy;
end;

procedure TFinanceDatabase.InitializeSchema;
begin
  FConnection.ExecuteDirect(
    'CREATE TABLE IF NOT EXISTS transactions (' +
    '  id INTEGER PRIMARY KEY AUTOINCREMENT,' +
    '  trans_type TEXT NOT NULL,' +
    '  date TEXT NOT NULL,' +
    '  amount REAL NOT NULL,' +
    '  description TEXT,' +
    '  category TEXT NOT NULL,' +
    '  source TEXT,' +
    '  is_recurring INTEGER DEFAULT 0,' +
    '  recurrence_days INTEGER DEFAULT 0,' +
    '  created_at TEXT DEFAULT (datetime(''now'')),' +
    '  updated_at TEXT DEFAULT (datetime(''now''))' +
    ')');

  FConnection.ExecuteDirect(
    'CREATE TABLE IF NOT EXISTS budgets (' +
    '  id INTEGER PRIMARY KEY AUTOINCREMENT,' +
    '  category TEXT NOT NULL,' +
    '  monthly_limit REAL NOT NULL,' +
    '  month INTEGER NOT NULL,' +
    '  year INTEGER NOT NULL,' +
    '  UNIQUE(category, month, year)' +
    ')');

  FConnection.ExecuteDirect(
    'CREATE TABLE IF NOT EXISTS categories (' +
    '  id INTEGER PRIMARY KEY AUTOINCREMENT,' +
    '  name TEXT NOT NULL UNIQUE,' +
    '  parent_category TEXT,' +
    '  icon TEXT' +
    ')');

  FConnection.ExecuteDirect(
    'CREATE TABLE IF NOT EXISTS app_settings (' +
    '  key TEXT PRIMARY KEY,' +
    '  value TEXT' +
    ')');

  { Create indexes for common queries }
  FConnection.ExecuteDirect(
    'CREATE INDEX IF NOT EXISTS idx_trans_date ON transactions(date)');
  FConnection.ExecuteDirect(
    'CREATE INDEX IF NOT EXISTS idx_trans_category ON transactions(category)');
  FConnection.ExecuteDirect(
    'CREATE INDEX IF NOT EXISTS idx_trans_type ON transactions(trans_type)');

  FTransaction.Commit;
end;

procedure TFinanceDatabase.RunMigrations;
var
  Version: Integer;
begin
  Version := StrToIntDef(LoadSetting('schema_version', '1'), 1);
  { Future migrations go here }
  { if Version < 2 then begin ... SaveSetting('schema_version', '2'); end; }
  if Version >= 1 then
    Exit; { Current version, nothing to do }
end;

procedure TFinanceDatabase.SaveTransaction(T: TTransaction);
var
  Query: TSQLQuery;
  TypeStr, SourceStr: String;
  IsRec: Integer;
  RecDays: Integer;
begin
  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;

    if T.IsExpense then
    begin
      TypeStr := 'expense';
      SourceStr := '';
      IsRec := Ord((T as TExpense).IsRecurring);
      RecDays := (T as TExpense).RecurrenceDays;
    end
    else
    begin
      TypeStr := 'income';
      SourceStr := (T as TIncome).Source;
      IsRec := 0;
      RecDays := 0;
    end;

    Query.SQL.Text :=
      'INSERT INTO transactions ' +
      '(trans_type, date, amount, description, category, ' +
      ' source, is_recurring, recurrence_days) ' +
      'VALUES (:type, :date, :amount, :desc, :cat, ' +
      ' :source, :recurring, :recdays)';

    Query.ParamByName('type').AsString := TypeStr;
    Query.ParamByName('date').AsString :=
      FormatDateTime('yyyy-mm-dd', T.Date);
    Query.ParamByName('amount').AsCurrency := T.Amount;
    Query.ParamByName('desc').AsString := T.Description;
    Query.ParamByName('cat').AsString := T.Category;
    Query.ParamByName('source').AsString := SourceStr;
    Query.ParamByName('recurring').AsInteger := IsRec;
    Query.ParamByName('recdays').AsInteger := RecDays;

    Query.ExecSQL;
    FTransaction.Commit;

    { Retrieve the auto-assigned ID }
    T.ID := FConnection.GetInsertID;
  finally
    Query.Free;
  end;
end;

procedure TFinanceDatabase.UpdateTransaction(T: TTransaction);
var
  Query: TSQLQuery;
begin
  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;

    Query.SQL.Text :=
      'UPDATE transactions SET ' +
      '  date = :date, amount = :amount, description = :desc, ' +
      '  category = :cat, updated_at = datetime(''now'') ' +
      'WHERE id = :id';

    Query.ParamByName('date').AsString :=
      FormatDateTime('yyyy-mm-dd', T.Date);
    Query.ParamByName('amount').AsCurrency := T.Amount;
    Query.ParamByName('desc').AsString := T.Description;
    Query.ParamByName('cat').AsString := T.Category;
    Query.ParamByName('id').AsInteger := T.ID;

    Query.ExecSQL;
    FTransaction.Commit;
  finally
    Query.Free;
  end;
end;

procedure TFinanceDatabase.DeleteTransaction(AID: Integer);
var
  Query: TSQLQuery;
begin
  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;
    Query.SQL.Text := 'DELETE FROM transactions WHERE id = :id';
    Query.ParamByName('id').AsInteger := AID;
    Query.ExecSQL;
    FTransaction.Commit;
  finally
    Query.Free;
  end;
end;

procedure TFinanceDatabase.LoadAllTransactions(
  Manager: TTransactionManager);
var
  Query: TSQLQuery;
  T: TTransaction;
  TransDate: TDateTime;
begin
  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;
    Query.SQL.Text := 'SELECT * FROM transactions ORDER BY date DESC';
    Query.Open;

    while not Query.EOF do
    begin
      TransDate := StrToDate(Query.FieldByName('date').AsString);

      if Query.FieldByName('trans_type').AsString = 'expense' then
      begin
        T := TExpense.Create(
          TransDate,
          Query.FieldByName('amount').AsCurrency,
          Query.FieldByName('description').AsString,
          Query.FieldByName('category').AsString,
          Query.FieldByName('is_recurring').AsInteger = 1,
          Query.FieldByName('recurrence_days').AsInteger
        );
      end
      else
      begin
        T := TIncome.Create(
          TransDate,
          Query.FieldByName('amount').AsCurrency,
          Query.FieldByName('description').AsString,
          Query.FieldByName('category').AsString,
          Query.FieldByName('source').AsString
        );
      end;

      T.ID := Query.FieldByName('id').AsInteger;
      Manager.AddTransaction(T);
      Query.Next;
    end;

    Query.Close;
    Manager.Modified := False; { fresh load, not modified }
  finally
    Query.Free;
  end;
end;

procedure TFinanceDatabase.SaveBudget(B: TBudget);
var
  Query: TSQLQuery;
begin
  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;

    Query.SQL.Text :=
      'INSERT OR REPLACE INTO budgets ' +
      '(category, monthly_limit, month, year) ' +
      'VALUES (:cat, :limit, :month, :year)';

    Query.ParamByName('cat').AsString := B.Category;
    Query.ParamByName('limit').AsCurrency := B.MonthlyLimit;
    Query.ParamByName('month').AsInteger := B.Month;
    Query.ParamByName('year').AsInteger := B.Year;

    Query.ExecSQL;
    FTransaction.Commit;
  finally
    Query.Free;
  end;
end;

procedure TFinanceDatabase.LoadAllBudgets(Manager: TTransactionManager);
var
  Query: TSQLQuery;
begin
  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;
    Query.SQL.Text := 'SELECT * FROM budgets';
    Query.Open;

    while not Query.EOF do
    begin
      Manager.SetBudget(
        Query.FieldByName('category').AsString,
        Query.FieldByName('monthly_limit').AsCurrency,
        Query.FieldByName('month').AsInteger,
        Query.FieldByName('year').AsInteger
      );
      Query.Next;
    end;

    Query.Close;
  finally
    Query.Free;
  end;
end;

function TFinanceDatabase.GetTransactionsByDateRange(
  StartDate, EndDate: TDateTime): TTransactionList;
var
  Query: TSQLQuery;
  T: TTransaction;
  TransDate: TDateTime;
begin
  Result := TTransactionList.Create(True);
  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;
    Query.SQL.Text :=
      'SELECT * FROM transactions ' +
      'WHERE date >= :start AND date <= :end ' +
      'ORDER BY date DESC';
    Query.ParamByName('start').AsString :=
      FormatDateTime('yyyy-mm-dd', StartDate);
    Query.ParamByName('end').AsString :=
      FormatDateTime('yyyy-mm-dd', EndDate);
    Query.Open;

    while not Query.EOF do
    begin
      TransDate := StrToDate(Query.FieldByName('date').AsString);
      if Query.FieldByName('trans_type').AsString = 'expense' then
        T := TExpense.Create(TransDate,
          Query.FieldByName('amount').AsCurrency,
          Query.FieldByName('description').AsString,
          Query.FieldByName('category').AsString)
      else
        T := TIncome.Create(TransDate,
          Query.FieldByName('amount').AsCurrency,
          Query.FieldByName('description').AsString,
          Query.FieldByName('category').AsString,
          Query.FieldByName('source').AsString);
      T.ID := Query.FieldByName('id').AsInteger;
      Result.Add(T);
      Query.Next;
    end;
    Query.Close;
  finally
    Query.Free;
  end;
end;

function TFinanceDatabase.GetCategoryTotals(
  AMonth, AYear: Integer): TStringList;
var
  Query: TSQLQuery;
  StartDate, EndDate: String;
begin
  Result := TStringList.Create;
  StartDate := Format('%.4d-%.2d-01', [AYear, AMonth]);
  EndDate := Format('%.4d-%.2d-31', [AYear, AMonth]);

  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;
    Query.SQL.Text :=
      'SELECT category, SUM(amount) as total ' +
      'FROM transactions ' +
      'WHERE trans_type = ''expense'' ' +
      '  AND date >= :start AND date <= :end ' +
      'GROUP BY category ' +
      'ORDER BY total DESC';
    Query.ParamByName('start').AsString := StartDate;
    Query.ParamByName('end').AsString := EndDate;
    Query.Open;

    while not Query.EOF do
    begin
      Result.Add(Format('%s=%.2f', [
        Query.FieldByName('category').AsString,
        Query.FieldByName('total').AsFloat]));
      Query.Next;
    end;
    Query.Close;
  finally
    Query.Free;
  end;
end;

procedure TFinanceDatabase.BackupDatabase(const ABackupPath: String);
begin
  { Close, copy, reopen }
  FConnection.Close;
  try
    if FileExists(ABackupPath) then
      DeleteFile(ABackupPath);
    CopyFile(FDatabasePath, ABackupPath);
  finally
    FConnection.Open;
  end;
end;

procedure TFinanceDatabase.Vacuum;
begin
  FConnection.ExecuteDirect('VACUUM');
  FTransaction.Commit;
end;

procedure TFinanceDatabase.SaveSetting(const AKey, AValue: String);
var
  Query: TSQLQuery;
begin
  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;
    Query.SQL.Text :=
      'INSERT OR REPLACE INTO app_settings (key, value) ' +
      'VALUES (:key, :value)';
    Query.ParamByName('key').AsString := AKey;
    Query.ParamByName('value').AsString := AValue;
    Query.ExecSQL;
    FTransaction.Commit;
  finally
    Query.Free;
  end;
end;

function TFinanceDatabase.LoadSetting(
  const AKey, ADefault: String): String;
var
  Query: TSQLQuery;
begin
  Result := ADefault;
  Query := TSQLQuery.Create(nil);
  try
    Query.Database := FConnection;
    Query.Transaction := FTransaction;
    Query.SQL.Text :=
      'SELECT value FROM app_settings WHERE key = :key';
    Query.ParamByName('key').AsString := AKey;
    Query.Open;
    if not Query.EOF then
      Result := Query.FieldByName('value').AsString;
    Query.Close;
  finally
    Query.Free;
  end;
end;

end.

Several things deserve attention here.

Parameterized queries. Every SQL query uses parameters (:type, :date, :amount) rather than string concatenation. This is not optional — it is a security requirement. If we built queries by concatenating user input directly into SQL strings, a malicious (or merely careless) user could inject arbitrary SQL code. The ParamByName approach prevents this entirely because the database engine treats parameter values as data, never as code. We covered SQL injection in Chapter 31; here we apply that knowledge to every single query.

The migration pattern. The RunMigrations procedure checks a schema_version setting and applies any necessary schema changes. Right now, version 1 is the only version, so the procedure does nothing. But in a real application, schema evolution is inevitable. When PennyWise 2.1 adds a tags column to the transactions table, the migration procedure will detect that the database is version 1, execute the ALTER TABLE statement, and update the version to 2. This pattern ensures that users who upgrade from any version to any later version get the correct schema automatically.

Resource management. Every TSQLQuery is created in a try..finally block and freed in the finally clause. Every database connection is closed in the destructor. This discipline — allocate, use, free — is the Pascal memory management pattern you learned in Chapter 14 and have been practicing ever since. In a garbage-collected language, you might not notice a leaked query object until the database runs out of connections. In Pascal, the compiler does not clean up after you, so you clean up after yourself. This is a feature, not a limitation.


38.5 The User Interface: FinanceUI

The user interface is built with Lazarus and the LCL, using the techniques from Chapters 27 through 32. PennyWise 2.0's main form has four primary areas:

  1. The transaction grid — a TDBGrid or TStringGrid showing recent transactions, sortable by date, amount, or category.
  2. The entry panel — fields for adding or editing a transaction: date picker, amount spin edit, category combo box, description memo, and expense/income radio buttons.
  3. The budget sidebar — a list of categories with progress bars showing spending against budget.
  4. The chart area — a TChart component (from TAChart, included with Lazarus) showing a pie chart of expenses by category and a bar chart of monthly spending trends.

The Main Form

The main form is designed in the Lazarus Form Designer, but its event handlers are where the real work happens. Here is the structure:

unit FinanceUI;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Forms, Controls, Graphics, Dialogs,
  StdCtrls, ExtCtrls, Grids, ComCtrls, Menus, Spin,
  TAGraph, TASeries, TAChartUtils,
  FinanceCore, FinanceDB;

type
  TMainForm = class(TForm)
    { Menu }
    MainMenu: TMainMenu;
    MenuFile: TMenuItem;
    MenuFileImport: TMenuItem;
    MenuFileExport: TMenuItem;
    MenuFileBackup: TMenuItem;
    MenuFileExit: TMenuItem;
    MenuEdit: TMenuItem;
    MenuView: TMenuItem;
    MenuHelp: TMenuItem;
    MenuHelpAbout: TMenuItem;

    { Transaction grid }
    GridTransactions: TStringGrid;

    { Entry panel }
    PanelEntry: TPanel;
    DatePickerTrans: TDateTimePicker;
    SpinAmount: TFloatSpinEdit;
    ComboCategory: TComboBox;
    MemoDescription: TMemo;
    RadioExpense: TRadioButton;
    RadioIncome: TRadioButton;
    BtnAdd: TButton;
    BtnUpdate: TButton;
    BtnDelete: TButton;
    BtnClear: TButton;

    { Budget sidebar }
    PanelBudget: TPanel;
    ListBudgets: TListBox;
    ProgressBudget: TProgressBar;
    LabelBudgetStatus: TLabel;
    BtnSetBudget: TButton;

    { Charts }
    ChartPie: TChart;
    PieSeries: TPieSeries;
    ChartBar: TChart;
    BarSeries: TBarSeries;

    { Status bar }
    StatusBar: TStatusBar;

    { Event handlers }
    procedure FormCreate(Sender: TObject);
    procedure FormClose(Sender: TObject; var CloseAction: TCloseAction);
    procedure BtnAddClick(Sender: TObject);
    procedure BtnUpdateClick(Sender: TObject);
    procedure BtnDeleteClick(Sender: TObject);
    procedure BtnClearClick(Sender: TObject);
    procedure BtnSetBudgetClick(Sender: TObject);
    procedure GridTransactionsSelection(Sender: TObject;
      aCol, aRow: Integer);
    procedure MenuFileImportClick(Sender: TObject);
    procedure MenuFileExportClick(Sender: TObject);
    procedure MenuFileBackupClick(Sender: TObject);
    procedure MenuHelpAboutClick(Sender: TObject);
  private
    FManager: TTransactionManager;
    FDatabase: TFinanceDatabase;
    FCurrentMonth: Integer;
    FCurrentYear: Integer;
    procedure RefreshGrid;
    procedure RefreshBudgetPanel;
    procedure RefreshCharts;
    procedure LoadEntryFromGrid(ARow: Integer);
    procedure ClearEntryFields;
    procedure CheckBudgetAlerts;
    procedure UpdateStatusBar;
    function GetDatabasePath: String;
  public
    { nothing public beyond inherited TForm interface }
  end;

var
  MainForm: TMainForm;

implementation

{$R *.lfm}

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FCurrentMonth := MonthOf(Now);
  FCurrentYear := YearOf(Now);

  FManager := TTransactionManager.Create;
  FDatabase := TFinanceDatabase.Create(GetDatabasePath);
  FDatabase.LoadAllTransactions(FManager);
  FDatabase.LoadAllBudgets(FManager);

  { Set up grid columns }
  GridTransactions.ColCount := 6;
  GridTransactions.RowCount := 1;
  GridTransactions.Cells[0, 0] := 'ID';
  GridTransactions.Cells[1, 0] := 'Date';
  GridTransactions.Cells[2, 0] := 'Type';
  GridTransactions.Cells[3, 0] := 'Category';
  GridTransactions.Cells[4, 0] := 'Amount';
  GridTransactions.Cells[5, 0] := 'Description';

  RefreshGrid;
  RefreshBudgetPanel;
  RefreshCharts;
  UpdateStatusBar;
  CheckBudgetAlerts;
end;

procedure TMainForm.FormClose(Sender: TObject;
  var CloseAction: TCloseAction);
begin
  if FManager.Modified then
  begin
    { Save any pending changes }
    { In a full implementation, we would iterate modified
      transactions and save them }
  end;
  FDatabase.Free;
  FManager.Free;
end;

function TMainForm.GetDatabasePath: String;
begin
  Result := GetAppConfigDir(False) + 'pennywise.db';
  ForceDirectories(ExtractFileDir(Result));
end;

procedure TMainForm.RefreshGrid;
var
  I: Integer;
  T: TTransaction;
  TypeStr: String;
begin
  GridTransactions.RowCount := FManager.TransactionCount + 1;
  for I := 0 to FManager.Transactions.Count - 1 do
  begin
    T := FManager.Transactions[I];
    if T.IsExpense then TypeStr := 'Expense'
    else TypeStr := 'Income';

    GridTransactions.Cells[0, I + 1] := IntToStr(T.ID);
    GridTransactions.Cells[1, I + 1] :=
      FormatDateTime('yyyy-mm-dd', T.Date);
    GridTransactions.Cells[2, I + 1] := TypeStr;
    GridTransactions.Cells[3, I + 1] := T.Category;
    GridTransactions.Cells[4, I + 1] := Format('%.2f', [T.Amount]);
    GridTransactions.Cells[5, I + 1] := T.Description;
  end;
  UpdateStatusBar;
end;

procedure TMainForm.RefreshBudgetPanel;
var
  Summaries: TCategorySummaryArray;
  I: Integer;
begin
  ListBudgets.Clear;
  Summaries := FManager.GetCategorySummaries(FCurrentMonth, FCurrentYear);
  for I := 0 to Length(Summaries) - 1 do
  begin
    if Summaries[I].BudgetLimit > 0 then
      ListBudgets.Items.Add(Format('%-12s  %.2f / %.2f  (%.0f%%)',
        [Summaries[I].Category,
         Summaries[I].TotalSpent,
         Summaries[I].BudgetLimit,
         Summaries[I].PercentUsed]))
    else
      ListBudgets.Items.Add(Format('%-12s  %.2f  (no budget)',
        [Summaries[I].Category,
         Summaries[I].TotalSpent]));
  end;
end;

procedure TMainForm.RefreshCharts;
var
  Summaries: TCategorySummaryArray;
  I: Integer;
  Report: TMonthlyReport;
begin
  PieSeries.Clear;
  Summaries := FManager.GetCategorySummaries(FCurrentMonth, FCurrentYear);
  for I := 0 to Length(Summaries) - 1 do
    if Summaries[I].TotalSpent > 0 then
      PieSeries.AddPie(
        Double(Summaries[I].TotalSpent),
        Summaries[I].Category,
        clDefault);

  { Bar chart: last 6 months of total expenses }
  BarSeries.Clear;
  for I := 5 downto 0 do
  begin
    Report := FManager.GenerateMonthlyReport(
      MonthOf(IncMonth(Now, -I)),
      YearOf(IncMonth(Now, -I)));
    BarSeries.Add(
      Double(Report.TotalExpenses),
      Format('%d/%d', [Report.Month, Report.Year mod 100]),
      clDefault);
  end;
end;

procedure TMainForm.BtnAddClick(Sender: TObject);
var
  T: TTransaction;
begin
  if not TTransactionManager.ValidateAmount(
    CurrencyOf(SpinAmount.Value)) then
  begin
    ShowMessage('Please enter a positive amount.');
    SpinAmount.SetFocus;
    Exit;
  end;

  if not TTransactionManager.ValidateCategory(
    ComboCategory.Text) then
  begin
    ShowMessage('Please select or enter a category.');
    ComboCategory.SetFocus;
    Exit;
  end;

  if RadioExpense.Checked then
    T := TExpense.Create(
      DatePickerTrans.Date,
      CurrencyOf(SpinAmount.Value),
      MemoDescription.Text,
      ComboCategory.Text)
  else
    T := TIncome.Create(
      DatePickerTrans.Date,
      CurrencyOf(SpinAmount.Value),
      MemoDescription.Text,
      ComboCategory.Text,
      '' { source can be added later });

  FManager.AddTransaction(T);
  FDatabase.SaveTransaction(T);

  RefreshGrid;
  RefreshBudgetPanel;
  RefreshCharts;
  ClearEntryFields;
  CheckBudgetAlerts;
end;

procedure TMainForm.BtnDeleteClick(Sender: TObject);
var
  Row: Integer;
  ID: Integer;
begin
  Row := GridTransactions.Row;
  if Row < 1 then Exit;

  ID := StrToInt(GridTransactions.Cells[0, Row]);
  if MessageDlg('Delete this transaction?', mtConfirmation,
    [mbYes, mbNo], 0) = mrYes then
  begin
    FManager.RemoveTransaction(ID);
    FDatabase.DeleteTransaction(ID);
    RefreshGrid;
    RefreshBudgetPanel;
    RefreshCharts;
  end;
end;

procedure TMainForm.CheckBudgetAlerts;
var
  OverBudget: TStringList;
  Msg: String;
  I: Integer;
begin
  OverBudget := FManager.GetOverBudgetCategories(
    FCurrentMonth, FCurrentYear);
  try
    if OverBudget.Count > 0 then
    begin
      Msg := 'Budget alert! Over budget in:' + LineEnding;
      for I := 0 to OverBudget.Count - 1 do
        Msg := Msg + '  - ' + OverBudget[I] + LineEnding;
      LabelBudgetStatus.Caption := Msg;
      LabelBudgetStatus.Font.Color := clRed;
    end
    else
    begin
      LabelBudgetStatus.Caption := 'All categories within budget.';
      LabelBudgetStatus.Font.Color := clGreen;
    end;
  finally
    OverBudget.Free;
  end;
end;

procedure TMainForm.UpdateStatusBar;
var
  Report: TMonthlyReport;
begin
  Report := FManager.GenerateMonthlyReport(FCurrentMonth, FCurrentYear);
  StatusBar.SimpleText := Format(
    'Month: %d/%d | Transactions: %d | ' +
    'Income: %.2f | Expenses: %.2f | Balance: %.2f',
    [FCurrentMonth, FCurrentYear,
     Report.TransactionCount,
     Report.TotalIncome,
     Report.TotalExpenses,
     Report.NetBalance]);
end;

{ ... remaining handler implementations omitted for brevity,
  but each follows the same pattern: validate input, update model,
  persist to database, refresh display ... }

end.

The pattern is consistent throughout: the user triggers an event, the handler validates input, updates the in-memory model via TTransactionManager, persists the change via TFinanceDatabase, and refreshes the display. This three-step pattern — validate, persist, display — appears in every event handler because it ensures that the UI, the model, and the database are always in sync.

Best Practice: The Validate-Persist-Display Pattern Every user action follows the same three steps: (1) validate the input, (2) persist the change to the database, (3) refresh the display. If validation fails, we stop and show an error. If persistence fails, we catch the exception and roll back. If display fails... well, display does not fail, because the model is the source of truth and the display simply reads it. This pattern is not unique to Pascal — you will encounter it in every GUI framework — but Pascal's explicit structure makes it easy to follow consistently.


38.6 Advanced Features

CSV Import and Export

The FinanceExport unit handles importing bank CSV files and exporting PennyWise data:

unit FinanceExport;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, FinanceCore;

type
  TCSVImporter = class
  private
    FDelimiter: Char;
    FDateColumn: Integer;
    FAmountColumn: Integer;
    FDescriptionColumn: Integer;
    FHasHeader: Boolean;
    function ParseCSVLine(const Line: String): TStringList;
    function ParseDate(const S: String): TDateTime;
    function ParseAmount(const S: String): Currency;
  public
    constructor Create;
    property Delimiter: Char read FDelimiter write FDelimiter;
    property DateColumn: Integer read FDateColumn write FDateColumn;
    property AmountColumn: Integer read FAmountColumn write FAmountColumn;
    property DescriptionColumn: Integer
      read FDescriptionColumn write FDescriptionColumn;
    property HasHeader: Boolean read FHasHeader write FHasHeader;
    function ImportFromFile(const APath: String;
      Manager: TTransactionManager): Integer;
  end;

  TCSVExporter = class
  public
    class procedure ExportTransactions(
      Manager: TTransactionManager;
      const APath: String);
    class procedure ExportMonthlyReport(
      const Report: TMonthlyReport;
      const APath: String);
  end;

  TJSONExporter = class
  public
    class procedure ExportTransactions(
      Manager: TTransactionManager;
      const APath: String);
  end;

implementation

{ ---- TCSVImporter ---- }

constructor TCSVImporter.Create;
begin
  inherited Create;
  FDelimiter := ',';
  FDateColumn := 0;
  FAmountColumn := 1;
  FDescriptionColumn := 2;
  FHasHeader := True;
end;

function TCSVImporter.ParseCSVLine(const Line: String): TStringList;
var
  InQuotes: Boolean;
  Current: String;
  Ch: Char;
begin
  Result := TStringList.Create;
  InQuotes := False;
  Current := '';

  for Ch in Line do
  begin
    if Ch = '"' then
      InQuotes := not InQuotes
    else if (Ch = FDelimiter) and (not InQuotes) then
    begin
      Result.Add(Trim(Current));
      Current := '';
    end
    else
      Current := Current + Ch;
  end;
  Result.Add(Trim(Current));
end;

function TCSVImporter.ParseDate(const S: String): TDateTime;
begin
  { Try common date formats }
  if not TryStrToDate(S, Result) then
    if not TryEncodeDate(
      StrToInt(Copy(S, 1, 4)),
      StrToInt(Copy(S, 6, 2)),
      StrToInt(Copy(S, 9, 2)), Result) then
      raise EConvertError.CreateFmt(
        'Cannot parse date: "%s"', [S]);
end;

function TCSVImporter.ParseAmount(const S: String): Currency;
var
  CleanStr: String;
begin
  CleanStr := StringReplace(S, '$', '', [rfReplaceAll]);
  CleanStr := StringReplace(CleanStr, ',', '', [rfReplaceAll]);
  CleanStr := Trim(CleanStr);
  Result := StrToCurr(CleanStr);
end;

function TCSVImporter.ImportFromFile(const APath: String;
  Manager: TTransactionManager): Integer;
var
  Lines: TStringList;
  Fields: TStringList;
  I, StartLine: Integer;
  Trans: TExpense;
  Amt: Currency;
begin
  Result := 0;
  Lines := TStringList.Create;
  try
    Lines.LoadFromFile(APath);
    if FHasHeader then StartLine := 1
    else StartLine := 0;

    for I := StartLine to Lines.Count - 1 do
    begin
      if Trim(Lines[I]) = '' then Continue;

      Fields := ParseCSVLine(Lines[I]);
      try
        if (FDateColumn < Fields.Count) and
           (FAmountColumn < Fields.Count) then
        begin
          Amt := ParseAmount(Fields[FAmountColumn]);
          Trans := TExpense.Create(
            ParseDate(Fields[FDateColumn]),
            Abs(Amt),
            Fields[FDescriptionColumn],
            'Imported'  { default category, user can recategorize }
          );
          Manager.AddTransaction(Trans);
          Inc(Result);
        end;
      finally
        Fields.Free;
      end;
    end;
  finally
    Lines.Free;
  end;
end;

{ ---- TCSVExporter ---- }

class procedure TCSVExporter.ExportTransactions(
  Manager: TTransactionManager; const APath: String);
var
  Output: TStringList;
  I: Integer;
  T: TTransaction;
  TypeStr: String;
begin
  Output := TStringList.Create;
  try
    Output.Add('Date,Type,Category,Amount,Description');
    for I := 0 to Manager.Transactions.Count - 1 do
    begin
      T := Manager.Transactions[I];
      if T.IsExpense then TypeStr := 'Expense'
      else TypeStr := 'Income';
      Output.Add(Format('"%s","%s","%s","%.2f","%s"',
        [FormatDateTime('yyyy-mm-dd', T.Date),
         TypeStr, T.Category, T.Amount, T.Description]));
    end;
    Output.SaveToFile(APath);
  finally
    Output.Free;
  end;
end;

class procedure TCSVExporter.ExportMonthlyReport(
  const Report: TMonthlyReport; const APath: String);
var
  Output: TStringList;
  I: Integer;
begin
  Output := TStringList.Create;
  try
    Output.Add(Format('Monthly Report: %d/%d', [Report.Month, Report.Year]));
    Output.Add(Format('Total Income,%.2f', [Report.TotalIncome]));
    Output.Add(Format('Total Expenses,%.2f', [Report.TotalExpenses]));
    Output.Add(Format('Net Balance,%.2f', [Report.NetBalance]));
    Output.Add('');
    Output.Add('Category,Spent,Budget,Percent Used');
    for I := 0 to Length(Report.Categories) - 1 do
      Output.Add(Format('"%s","%.2f","%.2f","%.0f%%"',
        [Report.Categories[I].Category,
         Report.Categories[I].TotalSpent,
         Report.Categories[I].BudgetLimit,
         Report.Categories[I].PercentUsed]));
    Output.SaveToFile(APath);
  finally
    Output.Free;
  end;
end;

{ ---- TJSONExporter ---- }

class procedure TJSONExporter.ExportTransactions(
  Manager: TTransactionManager; const APath: String);
var
  Output: TStringList;
  I: Integer;
  T: TTransaction;
  TypeStr, Last: String;
begin
  Output := TStringList.Create;
  try
    Output.Add('{');
    Output.Add('  "pennywise_export": {');
    Output.Add(Format('    "exported_at": "%s",',
      [FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', Now)]));
    Output.Add(Format('    "transaction_count": %d,',
      [Manager.TransactionCount]));
    Output.Add('    "transactions": [');

    for I := 0 to Manager.Transactions.Count - 1 do
    begin
      T := Manager.Transactions[I];
      if T.IsExpense then TypeStr := 'expense'
      else TypeStr := 'income';

      if I < Manager.Transactions.Count - 1 then Last := ','
      else Last := '';

      Output.Add(Format(
        '      {"id": %d, "type": "%s", "date": "%s", ' +
        '"amount": %.2f, "category": "%s", "description": "%s"}%s',
        [T.ID, TypeStr,
         FormatDateTime('yyyy-mm-dd', T.Date),
         T.Amount, T.Category, T.Description, Last]));
    end;

    Output.Add('    ]');
    Output.Add('  }');
    Output.Add('}');
    Output.SaveToFile(APath);
  finally
    Output.Free;
  end;
end;

end.

The CSV importer deserves special attention. It handles quoted fields (essential for descriptions that contain commas), configurable column mappings (because every bank exports CSV differently), and multiple date formats. Rosa discovered the hard way that her bank uses semicolons as delimiters and DD/MM/YYYY dates, while Tomas's bank uses commas and MM/DD/YYYY. The configurable Delimiter, DateColumn, and AmountColumn properties handle both cases without code changes.

Background Auto-Save Thread

PennyWise 2.0 saves data automatically every sixty seconds using a background thread:

type
  TAutoSaveThread = class(TThread)
  private
    FManager: TTransactionManager;
    FDatabase: TFinanceDatabase;
    FIntervalMs: Integer;
  protected
    procedure Execute; override;
  public
    constructor Create(AManager: TTransactionManager;
      ADatabase: TFinanceDatabase; AIntervalMs: Integer = 60000);
  end;

constructor TAutoSaveThread.Create(AManager: TTransactionManager;
  ADatabase: TFinanceDatabase; AIntervalMs: Integer);
begin
  inherited Create(False); { start immediately }
  FreeOnTerminate := True;
  FManager := AManager;
  FDatabase := ADatabase;
  FIntervalMs := AIntervalMs;
end;

procedure TAutoSaveThread.Execute;
begin
  while not Terminated do
  begin
    Sleep(FIntervalMs);
    if Terminated then Exit;

    if FManager.Modified then
    begin
      Synchronize(@DoAutoSave);
      FManager.Modified := False;
    end;
  end;
end;

The Synchronize call is critical. Database operations must execute on the main thread (because our SQLite connection is not thread-safe by default), so we use Synchronize to schedule the save on the main thread's message loop. The background thread's only job is to decide when to save; the actual save happens on the main thread, safely.

System Tray Integration

On Windows and Linux, PennyWise 2.0 can minimize to the system tray using the TTrayIcon component from the LCL:

procedure TMainForm.SetupTrayIcon;
begin
  TrayIcon := TTrayIcon.Create(Self);
  TrayIcon.Icon := Application.Icon;
  TrayIcon.Hint := 'PennyWise 2.0 — Personal Finance Manager';
  TrayIcon.Visible := True;

  TrayIcon.PopUpMenu := TPopupMenu.Create(Self);
  with TrayIcon.PopUpMenu do
  begin
    Items.Add(NewItem('Show PennyWise', 0, False, True,
      @TrayShowClick, 0, 'miShow'));
    Items.Add(NewItem('-', 0, False, True, nil, 0, 'miSep'));
    Items.Add(NewItem('Exit', 0, False, True,
      @TrayExitClick, 0, 'miExit'));
  end;

  TrayIcon.OnDblClick := @TrayShowClick;
end;

procedure TMainForm.FormCloseQuery(Sender: TObject;
  var CanClose: Boolean);
begin
  if CloseToTray then
  begin
    CanClose := False;
    Hide;
    TrayIcon.BalloonHint := 'PennyWise is still running.';
    TrayIcon.ShowBalloonHint;
  end
  else
    CanClose := True;
end;

This is cross-platform — the LCL's TTrayIcon abstracts the platform differences between Windows system tray, Linux notification area (via libappindicator or similar), and macOS menu bar. Our code does not change.


38.7 Testing and Quality Assurance

A capstone project without tests is a prototype, not a product. PennyWise 2.0 uses FPCUnit for automated testing.

Unit Tests for FinanceCore

unit TestFinanceCore;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, fpcunit, testregistry, FinanceCore;

type
  TTestTransactionManager = class(TTestCase)
  private
    FManager: TTransactionManager;
  protected
    procedure SetUp; override;
    procedure TearDown; override;
  published
    procedure TestAddExpense;
    procedure TestAddIncome;
    procedure TestGetTotalExpenses;
    procedure TestGetTotalIncome;
    procedure TestNetBalance;
    procedure TestBudgetOverSpend;
    procedure TestBudgetUnderSpend;
    procedure TestCategorySummaries;
    procedure TestSortByDate;
    procedure TestSortByAmount;
    procedure TestValidateAmount;
    procedure TestValidateCategory;
    procedure TestRemoveTransaction;
  end;

implementation

procedure TTestTransactionManager.SetUp;
begin
  FManager := TTransactionManager.Create;
end;

procedure TTestTransactionManager.TearDown;
begin
  FManager.Free;
end;

procedure TTestTransactionManager.TestAddExpense;
var
  E: TExpense;
begin
  E := TExpense.Create(EncodeDate(2026, 3, 15), 42.50,
    'Lunch', 'Food');
  FManager.AddTransaction(E);
  AssertEquals('Transaction count should be 1', 1,
    FManager.TransactionCount);
  AssertTrue('Transaction should be expense',
    FManager.Transactions[0].IsExpense);
end;

procedure TTestTransactionManager.TestAddIncome;
var
  Inc: TIncome;
begin
  Inc := TIncome.Create(EncodeDate(2026, 3, 1), 3500.00,
    'Monthly salary', 'Salary', 'Employer Inc.');
  FManager.AddTransaction(Inc);
  AssertEquals(1, FManager.TransactionCount);
  AssertTrue(FManager.Transactions[0].IsIncome);
end;

procedure TTestTransactionManager.TestGetTotalExpenses;
begin
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 1), 100.00, 'Groceries', 'Food'));
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 15), 50.00, 'Gas', 'Transport'));
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 4, 1), 200.00, 'Rent partial', 'Housing'));

  AssertEquals('March expenses', Currency(150.00),
    FManager.GetTotalExpenses(3, 2026));
  AssertEquals('April expenses', Currency(200.00),
    FManager.GetTotalExpenses(4, 2026));
end;

procedure TTestTransactionManager.TestGetTotalIncome;
begin
  FManager.AddTransaction(TIncome.Create(
    EncodeDate(2026, 3, 1), 3000.00, 'Salary', 'Salary', 'Work'));
  FManager.AddTransaction(TIncome.Create(
    EncodeDate(2026, 3, 15), 500.00, 'Freelance', 'Freelance', 'Client'));

  AssertEquals(Currency(3500.00),
    FManager.GetTotalIncome(3, 2026));
end;

procedure TTestTransactionManager.TestNetBalance;
var
  Report: TMonthlyReport;
begin
  FManager.AddTransaction(TIncome.Create(
    EncodeDate(2026, 3, 1), 3000.00, 'Salary', 'Salary', 'Work'));
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 5), 1200.00, 'Rent', 'Housing'));
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 10), 300.00, 'Groceries', 'Food'));

  Report := FManager.GenerateMonthlyReport(3, 2026);
  AssertEquals(Currency(1500.00), Report.NetBalance);
end;

procedure TTestTransactionManager.TestBudgetOverSpend;
var
  OverList: TStringList;
begin
  FManager.SetBudget('Food', 200.00, 3, 2026);
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 1), 150.00, 'Groceries', 'Food'));
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 15), 100.00, 'Restaurant', 'Food'));

  OverList := FManager.GetOverBudgetCategories(3, 2026);
  try
    AssertEquals('One category over budget', 1, OverList.Count);
  finally
    OverList.Free;
  end;
end;

procedure TTestTransactionManager.TestBudgetUnderSpend;
var
  OverList: TStringList;
begin
  FManager.SetBudget('Food', 500.00, 3, 2026);
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 1), 100.00, 'Groceries', 'Food'));

  OverList := FManager.GetOverBudgetCategories(3, 2026);
  try
    AssertEquals('No categories over budget', 0, OverList.Count);
  finally
    OverList.Free;
  end;
end;

procedure TTestTransactionManager.TestCategorySummaries;
var
  Summaries: TCategorySummaryArray;
begin
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 1), 100.00, 'Groceries', 'Food'));
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 5), 50.00, 'Snacks', 'Food'));
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 10), 30.00, 'Gas', 'Transport'));

  Summaries := FManager.GetCategorySummaries(3, 2026);
  AssertEquals('Two categories', 2, Length(Summaries));
end;

procedure TTestTransactionManager.TestSortByDate;
begin
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 15), 50.00, 'Later', 'Misc'));
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 1), 100.00, 'Earlier', 'Misc'));

  FManager.SortByDate(True);
  AssertEquals('Earlier should come first', 'Earlier',
    FManager.Transactions[0].Description);
end;

procedure TTestTransactionManager.TestSortByAmount;
begin
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 1), 500.00, 'Big', 'Misc'));
  FManager.AddTransaction(TExpense.Create(
    EncodeDate(2026, 3, 1), 10.00, 'Small', 'Misc'));

  FManager.SortByAmount(True);
  AssertEquals('Small should come first', 'Small',
    FManager.Transactions[0].Description);
end;

procedure TTestTransactionManager.TestValidateAmount;
begin
  AssertTrue(TTransactionManager.ValidateAmount(10.00));
  AssertFalse(TTransactionManager.ValidateAmount(0));
  AssertFalse(TTransactionManager.ValidateAmount(-5.00));
end;

procedure TTestTransactionManager.TestValidateCategory;
begin
  AssertTrue(TTransactionManager.ValidateCategory('Food'));
  AssertFalse(TTransactionManager.ValidateCategory(''));
  AssertFalse(TTransactionManager.ValidateCategory('   '));
end;

procedure TTestTransactionManager.TestRemoveTransaction;
var
  E: TExpense;
begin
  E := TExpense.Create(EncodeDate(2026, 3, 1), 100.00, 'Test', 'Misc');
  E.ID := 42;
  FManager.AddTransaction(E);
  AssertEquals(1, FManager.TransactionCount);

  FManager.RemoveTransaction(42);
  AssertEquals(0, FManager.TransactionCount);
end;

initialization
  RegisterTest(TTestTransactionManager);

end.

Run the tests from the command line:

$ ./pennywise_tests --all
TestAddExpense: OK
TestAddIncome: OK
TestGetTotalExpenses: OK
TestGetTotalIncome: OK
TestNetBalance: OK
TestBudgetOverSpend: OK
TestBudgetUnderSpend: OK
TestCategorySummaries: OK
TestSortByDate: OK
TestSortByAmount: OK
TestValidateAmount: OK
TestValidateCategory: OK
TestRemoveTransaction: OK

13 tests run, 13 passed, 0 failed, 0 errors.

Every test passed. And every test is independent — it creates its own TTransactionManager, adds its own data, and checks its own assertions. No test depends on any other test. No test touches the database or the UI. This is the power of the separation of concerns we designed into the architecture: because FinanceCore has no dependencies on FinanceDB or FinanceUI, we can test it in complete isolation.

📊 Testing Strategy for PennyWise 2.0 The full test suite includes three levels: 1. Unit tests (automated, FPCUnit): Test FinanceCore logic — transaction math, budget alerts, sorting, validation. ~30 tests, runs in under a second. 2. Integration tests (automated): Test FinanceDB — create a temporary database, insert records, query them, verify results. ~15 tests. 3. Manual tests (checklist): Test the GUI — add a transaction, verify it appears in the grid, check that the chart updates, test CSV import with a sample file. ~20 test scenarios.

The unit tests run on every build. The integration tests run before every release. The manual tests run before every public release. This is not overkill — this is the minimum for software that manages people's financial data.


38.8 Cross-Platform Build and Deployment

PennyWise 2.0 compiles without modification on Windows, Linux, and macOS. This is possible because we used only the LCL for the GUI (which abstracts platform differences), SQLDB for the database (which provides a common interface), and standard Free Pascal units for everything else.

The Build Script

A lazbuild script automates the process:

#!/bin/bash
# build-pennywise.sh — Build PennyWise 2.0 for all platforms

PROJECT="PennyWise2.lpi"

echo "Building PennyWise 2.0..."

# Build for current platform
lazbuild "$PROJECT" --build-mode=Release

if [ $? -eq 0 ]; then
    echo "Build succeeded."
    echo "Binary: $(find . -name 'PennyWise2' -o -name 'PennyWise2.exe' | head -1)"
else
    echo "Build FAILED."
    exit 1
fi

# Run unit tests
echo "Running unit tests..."
./pennywise_tests --all --format=plain

echo "Done."

For cross-compilation to other platforms from a single machine, Free Pascal's cross-compilation support allows targeting Linux from Windows or vice versa — provided the appropriate cross-compiler and libraries are installed. The fpcupdeluxe tool (which we mentioned in Chapter 2) automates this setup.

Deployment Packaging

For distribution, PennyWise 2.0 needs to include the application binary plus the SQLite shared library (on platforms where it is not bundled with the OS). On Linux, we build a .deb package or an AppImage. On Windows, we use Inno Setup for an installer or distribute a portable .zip. On macOS, we create an .app bundle.

The critical difference from applications built in interpreted or VM-based languages: PennyWise 2.0 has no runtime dependency. The user does not need to install Python, Java, .NET, or Node.js. They download the binary, run it, and it works. This is the "native compiled code matters" theme in its purest form.

⚠️ Deployment Checklist Before releasing PennyWise 2.0: - [ ] All unit tests pass - [ ] All integration tests pass - [ ] Manual test checklist completed on all target platforms - [ ] Database migration tested (upgrade from PennyWise 1.x database) - [ ] CSV import tested with at least three different bank formats - [ ] Application starts cleanly on a machine that has never had Free Pascal installed - [ ] About dialog shows correct version number - [ ] Installer creates start menu/desktop shortcuts (Windows) or .desktop entry (Linux)


38.9 Lessons Learned: Rosa and Tomas Reflect

Rosa is sitting at her desk in her apartment, her graphic design work spread across two monitors, PennyWise 2.0 running on a third. She has been using it for two months now. It tracks every freelance payment she receives and every business expense she incurs. At the end of each month, she clicks the "Export Monthly Report" button and emails the CSV to her accountant. The accountant, who is used to receiving shoeboxes of receipts from freelancers, calls Rosa's reports "the cleanest financial records I've ever seen from a sole proprietor."

Rosa remembers Chapter 1. She remembers staring at WriteLn('Hello, World!') and thinking that she would never be able to build real software. She was a graphic designer, not a programmer. Variables were confusing. Loops were terrifying. The first time she saw a pointer, she closed the book and did not open it again for three days.

But she came back. She always came back. And now she has built something that saves her time, saves her money, and impresses her accountant. Not with a framework she does not understand. Not with a library someone else wrote. With Pascal code that she wrote, line by line, understanding every line.

Tomas is in his dorm room, having just checked his food budget. The progress bar is at 72% with eight days left in the month — he is going to make it. Before PennyWise, he overdrafted his account every other month. Before PennyWise, he had no idea where his money went. Now he knows, because the pie chart tells him: 40% housing, 25% food, 15% transportation, 10% entertainment, 10% miscellaneous.

Tomas remembers something specific: the moment in Chapter 16 when object-oriented programming clicked. He had been struggling with the concept of classes — they seemed like unnecessary complexity layered on top of procedures and records that already worked fine. Then he wrote TExpense = class(TTransaction) and suddenly understood. An expense is a transaction. Inheritance was not complexity — it was clarity. It modeled the way things actually are.

💡 What Rosa and Tomas Learned (and What You Learned with Them) The technical skills are obvious: Pascal syntax, data structures, algorithms, OOP, GUI development, database programming, networking, threading. But the deeper lessons are subtler: 1. Design before you code. The thirty minutes Rosa spent drawing a class diagram saved her days of refactoring. 2. Test as you go. Tomas's habit of writing a test before implementing a feature caught bugs before they became problems. 3. Simplicity compounds. Each clean, well-named procedure made the next procedure easier to write. Each well-structured unit made the next unit easier to integrate. 4. The compiler is your friend. Every type error the compiler caught was a runtime bug it prevented. Every "incompatible types" message was a logical error found at compile time instead of at 2 AM. 5. Understanding beats memorization. Rosa does not memorize Pascal syntax. She understands Pascal's principles — strong typing, explicit control flow, structured decomposition — and the syntax follows naturally.


38.10 Where to Go from Here

PennyWise 2.0 is complete, but it is not finished. No software is ever finished. Here are directions you could take it:

Feature extensions: - Recurring transactions. Automatically generate expenses that repeat on a schedule (rent, subscriptions, loan payments). The IsRecurring and RecurrenceDays fields are already in the data model — they just need a scheduling mechanism. - Multi-currency support. Track expenses in different currencies with automatic conversion using exchange rate APIs. - Receipt scanning. Integrate with a Tesseract OCR library (available as a Free Pascal binding) to extract amounts and dates from photographed receipts. - Mobile companion. Build a mobile frontend using LAMW (Lazarus Android Module Wizard) or a web frontend that consumes the REST API.

Technical improvements: - Thread-safe database access. Use SQLite's WAL (Write-Ahead Logging) mode and proper connection pooling to allow concurrent reads and writes. - Encryption. Encrypt the database file with SQLCipher (a SQLite extension) so financial data is protected at rest. - Plugin architecture. Define an interface (using Pascal interfaces from Chapter 18) that allows third-party plugins to add new report types, import formats, or visualizations.

Learning extensions: - Compiler construction. Wirth wrote a textbook on compiler construction. Build a small expression parser that evaluates formulas in PennyWise's budget rules (e.g., "Food + Transport < 40% of Income"). - Networking. Replace the simple REST endpoint with a proper client-server architecture that syncs data between multiple devices. - Machine learning. Build a simple transaction categorizer that learns from the user's past categorizations to automatically categorize imported bank transactions. Free Pascal can interface with Python via pipes, or you can implement a naive Bayes classifier directly in Pascal.

Each of these extensions uses skills you already have. You know how to define classes, write units, query databases, build GUIs, create threads, and serve HTTP. The only new thing in each extension is the domain knowledge — the specifics of OCR, encryption, or machine learning. The programming skills are transferable. This is what we meant in Chapter 1 when we said that Pascal teaches you to program, not just to write Pascal.


38.11 Summary

This chapter assembled everything. Thirty-seven chapters of learning — variables, types, control flow, procedures, functions, arrays, strings, records, pointers, linked lists, classes, inheritance, polymorphism, interfaces, generics, operator overloading, recursion, sorting, searching, trees, graphs, dynamic programming, performance, GUI forms, dialogs, menus, charts, databases, cross-platform packaging, units, file formats, networking, threading, and OS integration — all of it came together in PennyWise 2.0.

The architecture followed the principle of separation of concerns: FinanceCore for the domain model and business logic, FinanceDB for database persistence, FinanceUI for the graphical interface, FinanceExport for import and export, and FinanceSync for background operations and network access. Each unit is independent, testable, and replaceable.

The code is clean, readable, and maintainable. A new developer could open FinanceCore.pas and understand the data model in minutes. A new developer could open FinanceDB.pas and understand the database operations in minutes. This is not because the code is simple — it is because the code is clear. Clarity is Pascal's greatest gift to the programmer.

Rosa and Tomas have finished their journey through this textbook. Rosa is a freelance graphic designer who can now build her own tools. Tomas is a college student who understands, at a deep level, what a program is and how to build one. Both of them will go on to learn other languages — Python for data analysis, JavaScript for web development, C for systems programming, maybe Rust for its safety guarantees. And when they do, they will find that the concepts transfer. Types, control flow, abstraction, modularity, testing — these are not Pascal concepts. They are programming concepts. Pascal just taught them right.

PennyWise 2.0 is not the end. It is the beginning of everything you will build next.


"The tools we use have a profound (and devious!) influence on our thinking habits, and, therefore, on our thinking abilities." — Edsger W. Dijkstra

You chose good tools. Now go build something extraordinary.