> "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
In This Chapter
- 38.1 The Capstone Challenge
- 38.2 Software Design: Architecture and Planning
- 38.3 Implementing the Core: FinanceCore Unit
- 38.4 The Database Layer: FinanceDB Unit
- 38.5 The User Interface: FinanceUI
- 38.6 Advanced Features
- 38.7 Testing and Quality Assurance
- 38.8 Cross-Platform Build and Deployment
- 38.9 Lessons Learned: Rosa and Tomas Reflect
- 38.10 Where to Go from Here
- 38.11 Summary
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:
- Expense tracking. Add, edit, and delete expenses with date, amount, category, and description.
- Income tracking. Record income entries with source, date, and amount.
- Budget management. Set monthly budgets per category. Receive alerts when spending approaches or exceeds the budget.
- Reporting. Generate monthly and yearly summaries. View totals by category. Export reports to CSV.
- Persistent storage. All data stored in a local SQLite database. Automatic backup on exit.
- Import/Export. Import bank transactions from CSV files. Export all data to CSV or JSON.
- REST sync (optional). A local HTTP endpoint that serves expense data as JSON, enabling integration with a companion web dashboard or mobile app.
- Background operations. Auto-save runs on a background thread. Network sync (if enabled) runs asynchronously.
- System tray integration. PennyWise minimizes to the system tray with a budget-status icon.
- 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:
- The transaction grid — a
TDBGridorTStringGridshowing recent transactions, sortable by date, amount, or category. - 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.
- The budget sidebar — a list of categories with progress bars showing spending against budget.
- The chart area — a
TChartcomponent (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.