diff --git a/libdrcr/src/austax/mod.rs b/libdrcr/src/austax/mod.rs
index f839e2e..4391e41 100644
--- a/libdrcr/src/austax/mod.rs
+++ b/libdrcr/src/austax/mod.rs
@@ -30,8 +30,7 @@ use crate::account_config::kinds_for_account;
 use crate::model::transaction::{Posting, Transaction, TransactionWithPostings};
 use crate::reporting::calculator::ReportingGraphDependencies;
 use crate::reporting::dynamic_report::{
-	entries_for_kind, CalculatableDynamicReport, CalculatableDynamicReportEntry,
-	CalculatableSection, CalculatedRow, DynamicReport, LiteralRow,
+	entries_for_kind, DynamicReport, DynamicReportEntry, Row, Section,
 };
 use crate::reporting::executor::ReportingExecutionError;
 use crate::reporting::steps::AllTransactionsExceptEarningsToEquityBalances;
@@ -44,6 +43,23 @@ use crate::util::sofy_from_eofy;
 use crate::{QuantityInt, INCOME_TAX, INCOME_TAX_CONTROL};
 
 // Constants and tax calculations
+#[rustfmt::skip]
+const INCOME_TYPES: &[(&str, &str, &str)] = &[
+	("income1", "Salary or wages", "1"),
+	("income5", "Australian Government allowances and payments", "5"),
+	("income10", "Gross interest", "10"),
+	("income13", "Partnerships and trusts", "13"),
+	("income24", "Other income", "24"),
+];
+
+#[rustfmt::skip]
+const DEDUCTION_TYPES: &[(&str, &str, &str)] = &[
+	("d2", "Work-related travel expenses", "D2"),
+	("d4", "Work-related self-education expenses", "D4"),
+	("d5", "Other work-related expenses", "D5"),
+	("d9", "Gifts or donations", "D9"),
+];
+
 fn get_grossedup_rfb(taxable_value: QuantityInt) -> QuantityInt {
 	// FIXME: May vary from year to year
 	((taxable_value as f64) * 2.0802) as QuantityInt
@@ -191,7 +207,156 @@ impl ReportingStep for CalculateIncomeTax {
 		let kinds_for_account =
 			kinds_for_account(context.db_connection.get_account_configurations().await);
 
-		// Pre-compute taxable value of reportable fringe benefits (required for MLS)
+		// Generate tax summary report
+		let mut report = DynamicReport {
+			title: "Tax summary".to_string(),
+			columns: vec!["$".to_string()],
+			entries: Vec::new(),
+		};
+
+		// Add income entries
+		let mut total_income: QuantityInt = 0;
+
+		for (code, label, number) in INCOME_TYPES {
+			let entries;
+			if *code == "income1" {
+				// Special case for salary or wages - round each separately
+				entries = entries_for_kind_floor(
+					&format!("austax.{}", code),
+					true,
+					&vec![balances],
+					&kinds_for_account,
+					100,
+				);
+			} else {
+				entries = entries_for_kind(
+					&format!("austax.{}", code),
+					true,
+					&vec![balances],
+					&kinds_for_account,
+				);
+			}
+
+			if entries.is_empty() {
+				continue;
+			}
+
+			let mut section = Section {
+				text: Some(format!("{} ({})", label, number)),
+				id: None,
+				visible: true,
+				entries,
+			};
+
+			// Add subtotal row
+			let subtotal = floor_quantity(section.subtotal(&report), 100);
+			total_income += subtotal[0];
+
+			section.entries.push(
+				Row {
+					text: format!("Total item {}", number),
+					quantity: subtotal,
+					id: Some(format!("total_{}", code)),
+					visible: true,
+					link: None,
+					heading: true,
+					bordered: true,
+				}
+				.into(),
+			);
+			report.entries.push(section.into());
+			report.entries.push(DynamicReportEntry::Spacer);
+		}
+
+		// Total assessable income
+		report.entries.push(
+			Row {
+				text: "Total assessable income".to_string(),
+				quantity: vec![total_income],
+				id: Some("total_income".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
+		report.entries.push(DynamicReportEntry::Spacer);
+
+		// Add deduction entries
+		let mut total_deductions: QuantityInt = 0;
+
+		for (code, label, number) in DEDUCTION_TYPES {
+			let entries = entries_for_kind(
+				&format!("austax.{}", code),
+				false,
+				&vec![balances],
+				&kinds_for_account,
+			);
+
+			if entries.is_empty() {
+				continue;
+			}
+
+			let mut section = Section {
+				text: Some(format!("{} ({})", label, number)),
+				id: None,
+				visible: true,
+				entries,
+			};
+
+			// Add subtotal row
+			let subtotal = floor_quantity(section.subtotal(&report), 100);
+			total_deductions += subtotal[0];
+
+			section.entries.push(
+				Row {
+					text: format!("Total item {}", number),
+					quantity: subtotal,
+					id: Some(format!("total_{}", code)),
+					visible: true,
+					link: None,
+					heading: true,
+					bordered: true,
+				}
+				.into(),
+			);
+			report.entries.push(section.into());
+			report.entries.push(DynamicReportEntry::Spacer);
+		}
+
+		// Total deductions
+		report.entries.push(
+			Row {
+				text: "Total deductions".to_string(),
+				quantity: vec![total_deductions],
+				id: Some("total_deductions".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
+		report.entries.push(DynamicReportEntry::Spacer);
+
+		// Net taxable income
+		let net_taxable = total_income - total_deductions;
+		report.entries.push(
+			Row {
+				text: "Net taxable income".to_string(),
+				quantity: vec![net_taxable],
+				id: Some("net_taxable".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
+		report.entries.push(DynamicReportEntry::Spacer);
+
+		// Precompute RFB amount as this is required for MLS
 		let rfb_taxable = balances
 			.iter()
 			.filter(|(acc, _)| {
@@ -202,543 +367,37 @@ impl ReportingStep for CalculateIncomeTax {
 			})
 			.map(|(_, bal)| *bal)
 			.sum();
+		let _rfb_grossedup = get_grossedup_rfb(rfb_taxable);
 
-		// Generate tax summary report
-		let report = CalculatableDynamicReport::new(
-			"Tax summary".to_string(),
-			vec!["$".to_string()],
-			vec![
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Salary or wages (1)".to_string(),
-					Some("income1".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind_floor(
-							"austax.income1",
-							true,
-							&vec![balances],
-							&kinds_for_account,
-							100,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item 1".to_string(),
-									quantity: report.subtotal_for_id("income1").unwrap(),
-									id: Some("total_income1".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						// Add spacer as child of the Section so it is hidden if the Section is hidden
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Australian Government allowances and payments (5)".to_string(),
-					Some("income5".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.income5",
-							true,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item 5".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("income5").unwrap(),
-										100,
-									),
-									id: Some("total_income5".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Gross interest (10)".to_string(),
-					Some("income10".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.income10",
-							true,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item 10".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("income10").unwrap(),
-										100,
-									),
-									id: Some("total_income10".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Partnerships and trusts (13)".to_string(),
-					Some("income13".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.income13",
-							true,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item 13".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("income13").unwrap(),
-										100,
-									),
-									id: Some("total_income13".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Foreign source income and foreign assets or property (20)".to_string(),
-					Some("income20".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.income20",
-							true,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item 20".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("income20").unwrap(),
-										100,
-									),
-									id: Some("total_income20".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Other income (24)".to_string(),
-					Some("income24".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.income24",
-							true,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item 24".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("income24").unwrap(),
-										100,
-									),
-									id: Some("total_income24".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
-					calculate_fn: |report| LiteralRow {
-						text: "Total assessable income".to_string(),
-						quantity: vec![
-							report
-								.quantity_for_id("total_income1")
-								.map(|v| v[0])
-								.unwrap_or(0) + report
-								.quantity_for_id("total_income5")
-								.map(|v| v[0])
-								.unwrap_or(0) + report
-								.quantity_for_id("total_income10")
-								.map(|v| v[0])
-								.unwrap_or(0) + report
-								.quantity_for_id("total_income13")
-								.map(|v| v[0])
-								.unwrap_or(0) + report
-								.quantity_for_id("total_income20")
-								.map(|v| v[0])
-								.unwrap_or(0) + report
-								.quantity_for_id("total_income24")
-								.map(|v| v[0])
-								.unwrap_or(0),
-						],
-						id: Some("total_income".to_string()),
-						visible: true,
-						auto_hide: false,
-						link: None,
-						heading: true,
-						bordered: true,
-					},
-				}),
-				CalculatableDynamicReportEntry::Spacer,
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Work-related travel expenses (D2)".to_string(),
-					Some("d2".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.d2",
-							false,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item D2".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("d2").unwrap(),
-										100,
-									),
-									id: Some("total_d2".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Work-related self-education expenses (D4)".to_string(),
-					Some("d4".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.d4",
-							false,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item D4".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("d4").unwrap(),
-										100,
-									),
-									id: Some("total_d4".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Other work-related expenses (D5)".to_string(),
-					Some("d5".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.d5",
-							false,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item D5".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("d5").unwrap(),
-										100,
-									),
-									id: Some("total_d5".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Gifts or donations (D9)".to_string(),
-					Some("d9".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.d9",
-							false,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item D9".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("d9").unwrap(),
-										100,
-									),
-									id: Some("total_d9".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Other deductions (D15)".to_string(),
-					Some("d15".to_string()),
-					true,
-					true,
-					{
-						let mut entries = entries_for_kind(
-							"austax.d15",
-							false,
-							&vec![balances],
-							&kinds_for_account,
-						);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total item D15".to_string(),
-									quantity: floor_quantity(
-										report.subtotal_for_id("d15").unwrap(),
-										100,
-									),
-									id: Some("total_d15".to_string()),
-									visible: true,
-									auto_hide: true,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries.push(CalculatableDynamicReportEntry::Spacer);
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
-					calculate_fn: |report| LiteralRow {
-						text: "Total deductions".to_string(),
-						quantity: vec![
-							report
-								.quantity_for_id("total_d2")
-								.map(|v| v[0])
-								.unwrap_or(0) + report
-								.quantity_for_id("total_d4")
-								.map(|v| v[0])
-								.unwrap_or(0) + report
-								.quantity_for_id("total_d5")
-								.map(|v| v[0])
-								.unwrap_or(0) + report
-								.quantity_for_id("total_d9")
-								.map(|v| v[0])
-								.unwrap_or(0) + report
-								.quantity_for_id("total_d15")
-								.map(|v| v[0])
-								.unwrap_or(0),
-						],
-						id: Some("total_deductions".to_string()),
-						visible: true,
-						auto_hide: false,
-						link: None,
-						heading: true,
-						bordered: true,
-					},
-				}),
-				CalculatableDynamicReportEntry::Spacer,
-				CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
-					calculate_fn: |report| LiteralRow {
-						text: "Net taxable income".to_string(),
-						quantity: vec![
-							report.quantity_for_id("total_income").unwrap()[0]
-								- report.quantity_for_id("total_deductions").unwrap()[0],
-						],
-						id: Some("net_taxable".to_string()),
-						visible: true,
-						auto_hide: false,
-						link: None,
-						heading: true,
-						bordered: true,
-					},
-				}),
-				// Precompute RFB amount as this is required for MLS
-				CalculatableDynamicReportEntry::LiteralRow(LiteralRow {
-					text: "Taxable value of reportable fringe benefits".to_string(),
-					quantity: vec![rfb_taxable],
-					id: Some("rfb_taxable".to_string()),
-					visible: false,
-					auto_hide: false,
-					link: None,
-					heading: false,
-					bordered: false,
-				}),
-				CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
-					calculate_fn: |report| LiteralRow {
-						text: "Grossed-up value".to_string(),
-						quantity: vec![get_grossedup_rfb(
-							report.quantity_for_id("rfb_taxable").unwrap()[0],
-						)],
-						id: Some("rfb_grossedup".to_string()),
-						visible: false,
-						auto_hide: false,
-						link: None,
-						heading: false,
-						bordered: false,
-					},
-				}),
-				CalculatableDynamicReportEntry::Spacer,
-				CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
-					calculate_fn: |report| LiteralRow {
-						text: "Base income tax".to_string(),
-						quantity: vec![get_base_income_tax(
-							report.quantity_for_id("net_taxable").unwrap()[0],
-						)],
-						id: Some("tax_base".to_string()),
-						visible: true,
-						auto_hide: false,
-						link: None,
-						heading: false,
-						bordered: false,
-					},
-				}),
-				// CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
-				// 	calculate_fn: |report| LiteralRow {
-				// 		text: "Medicare levy".to_string(),
-				// 		quantity: vec![get_medicare_levy(
-				// 			report.quantity_for_id("net_taxable").unwrap()[0],
-				// 		)],
-				// 		id: Some("tax_ml".to_string()),
-				// 		visible: true,
-				// 		auto_hide: true,
-				// 		link: None,
-				// 		heading: false,
-				// 		bordered: false,
-				// 	},
-				// }),
-				// CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
-				// 	calculate_fn: |report| LiteralRow {
-				// 		text: "Medicare levy".to_string(),
-				// 		quantity: vec![get_medicare_levy_surcharge(
-				// 			report.quantity_for_id("net_taxable").unwrap()[0],
-				// 			report.quantity_for_id("rfb_grossedup").unwrap()[0],
-				// 		)],
-				// 		id: Some("tax_mls".to_string()),
-				// 		visible: true,
-				// 		auto_hide: true,
-				// 		link: None,
-				// 		heading: false,
-				// 		bordered: false,
-				// 	},
-				// }),
-				CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
-					calculate_fn: |report| LiteralRow {
-						text: "Total income tax".to_string(),
-						quantity: vec![
-							report.quantity_for_id("tax_base").unwrap()[0], // + report.quantity_for_id("tax_ml").map(|v| v[0]).unwrap_or(0)
-							                                                // + report.quantity_for_id("tax_mls").map(|v| v[0]).unwrap_or(0),
-						],
-						id: Some("total_tax".to_string()),
-						visible: true,
-						auto_hide: false,
-						link: None,
-						heading: true,
-						bordered: true,
-					},
-				}),
-			],
+		// Base income tax row
+		let tax_base = get_base_income_tax(net_taxable);
+		report.entries.push(
+			Row {
+				text: "Base income tax".to_string(),
+				quantity: vec![tax_base],
+				id: Some("tax_base".to_string()),
+				visible: true,
+				link: None,
+				heading: false,
+				bordered: false,
+			}
+			.into(),
 		);
 
-		let mut report: DynamicReport = report.calculate();
-		report.auto_hide();
-
-		let total_tax = report.quantity_for_id("total_tax").unwrap()[0];
+		// Total income tax row
+		let tax_total = tax_base;
+		report.entries.push(
+			Row {
+				text: "Total income tax".to_string(),
+				quantity: vec![tax_total],
+				id: Some("tax_total".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
 
 		// Generate income tax transaction
 		let transactions = Transactions {
@@ -759,18 +418,18 @@ impl ReportingStep for CalculateIncomeTax {
 						transaction_id: None,
 						description: None,
 						account: INCOME_TAX.to_string(),
-						quantity: total_tax,
+						quantity: tax_total,
 						commodity: context.db_connection.metadata().reporting_commodity.clone(),
-						quantity_ascost: Some(total_tax),
+						quantity_ascost: Some(tax_total),
 					},
 					Posting {
 						id: None,
 						transaction_id: None,
 						description: None,
 						account: INCOME_TAX_CONTROL.to_string(),
-						quantity: -total_tax,
+						quantity: -tax_total,
 						commodity: context.db_connection.metadata().reporting_commodity.clone(),
-						quantity_ascost: Some(total_tax),
+						quantity_ascost: Some(tax_total),
 					},
 				],
 			}],
@@ -805,10 +464,10 @@ fn entries_for_kind_floor(
 	balances: &Vec<&HashMap<String, QuantityInt>>,
 	kinds_for_account: &HashMap<String, Vec<String>>,
 	floor: QuantityInt,
-) -> Vec<CalculatableDynamicReportEntry> {
+) -> Vec<DynamicReportEntry> {
 	let mut entries_for_kind = entries_for_kind(kind, invert, balances, kinds_for_account);
 	entries_for_kind.iter_mut().for_each(|e| match e {
-		CalculatableDynamicReportEntry::LiteralRow(row) => row
+		DynamicReportEntry::Row(row) => row
 			.quantity
 			.iter_mut()
 			.for_each(|v| *v = (*v / floor) * floor),
diff --git a/libdrcr/src/reporting/dynamic_report.rs b/libdrcr/src/reporting/dynamic_report.rs
index fb955ff..689cc81 100644
--- a/libdrcr/src/reporting/dynamic_report.rs
+++ b/libdrcr/src/reporting/dynamic_report.rs
@@ -16,9 +16,6 @@
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
 
-// FIXME: Tidy up this file
-
-use std::cell::RefCell;
 use std::collections::HashMap;
 
 use serde::{Deserialize, Serialize};
@@ -27,161 +24,7 @@ use crate::QuantityInt;
 
 use super::types::ReportingProduct;
 
-/// Represents a dynamically generated report composed of [CalculatableDynamicReportEntry]
-#[derive(Clone, Debug)]
-pub struct CalculatableDynamicReport {
-	pub title: String,
-	pub columns: Vec<String>,
-	// This must use RefCell as, during calculation, we iterate while mutating the report
-	pub entries: Vec<RefCell<CalculatableDynamicReportEntry>>,
-}
-
-impl CalculatableDynamicReport {
-	pub fn new(
-		title: String,
-		columns: Vec<String>,
-		entries: Vec<CalculatableDynamicReportEntry>,
-	) -> Self {
-		Self {
-			title,
-			columns,
-			entries: entries.into_iter().map(|e| RefCell::new(e)).collect(),
-		}
-	}
-
-	/// Recursively calculate all [CalculatedRow] entries
-	pub fn calculate(self) -> DynamicReport {
-		let mut calculated_entries = Vec::new();
-
-		for (entry_idx, entry) in self.entries.iter().enumerate() {
-			let entry_ref = entry.borrow();
-
-			match &*entry_ref {
-				CalculatableDynamicReportEntry::CalculatableSection(section) => {
-					// Clone first, in case calculation needs to take reference to the section
-					let updated_section = section.clone().calculate(&self);
-
-					drop(entry_ref); // Drop entry_ref so we can borrow mutably
-					let mut entry_mut = self.entries[entry_idx].borrow_mut();
-					*entry_mut = CalculatableDynamicReportEntry::Section(updated_section.clone());
-
-					calculated_entries.push(DynamicReportEntry::Section(updated_section));
-				}
-				CalculatableDynamicReportEntry::Section(section) => {
-					calculated_entries.push(DynamicReportEntry::Section(section.clone()));
-				}
-				CalculatableDynamicReportEntry::LiteralRow(row) => {
-					calculated_entries.push(DynamicReportEntry::LiteralRow(row.clone()));
-				}
-				CalculatableDynamicReportEntry::CalculatedRow(row) => {
-					let updated_row = row.calculate(&self);
-
-					drop(entry_ref); // Drop entry_ref so we can borrow mutably
-					let mut entry_mut = self.entries[entry_idx].borrow_mut();
-					*entry_mut = CalculatableDynamicReportEntry::LiteralRow(updated_row.clone());
-
-					calculated_entries.push(DynamicReportEntry::LiteralRow(updated_row));
-				}
-				CalculatableDynamicReportEntry::Spacer => {
-					calculated_entries.push(DynamicReportEntry::Spacer);
-				}
-			}
-		}
-
-		DynamicReport {
-			title: self.title,
-			columns: self.columns,
-			entries: calculated_entries,
-		}
-	}
-
-	/// Look up [CalculatableDynamicReportEntry] by id
-	///
-	/// Returns a cloned copy of the [CalculatableDynamicReportEntry]. This is necessary because the entry may be within a [Section], and [RefCell] semantics cannot express this type of nested borrow.
-	pub fn by_id(&self, id: &str) -> Option<CalculatableDynamicReportEntry> {
-		// Manually iterate over self.entries rather than self.entries()
-		// To catch the situation where entry is already mutably borrowed
-		for entry in self.entries.iter() {
-			match entry.try_borrow() {
-				Ok(entry) => match &*entry {
-					CalculatableDynamicReportEntry::CalculatableSection(section) => {
-						if let Some(i) = &section.id {
-							if i == id {
-								return Some(entry.clone());
-							}
-						}
-						if let Some(e) = section.by_id(id) {
-							return Some(e);
-						}
-					}
-					CalculatableDynamicReportEntry::Section(section) => {
-						if let Some(i) = &section.id {
-							if i == id {
-								return Some(entry.clone());
-							}
-						}
-						if let Some(e) = section.by_id(id) {
-							return Some(match e {
-								DynamicReportEntry::Section(section) => {
-									CalculatableDynamicReportEntry::Section(section.clone())
-								}
-								DynamicReportEntry::LiteralRow(row) => {
-									CalculatableDynamicReportEntry::LiteralRow(row.clone())
-								}
-								DynamicReportEntry::Spacer => {
-									CalculatableDynamicReportEntry::Spacer
-								}
-							});
-						}
-					}
-					CalculatableDynamicReportEntry::LiteralRow(row) => {
-						if let Some(i) = &row.id {
-							if i == id {
-								return Some(entry.clone());
-							}
-						}
-					}
-					CalculatableDynamicReportEntry::CalculatedRow(_) => (),
-					CalculatableDynamicReportEntry::Spacer => (),
-				},
-				Err(err) => panic!(
-					"Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}",
-					err
-				),
-			}
-		}
-
-		None
-	}
-
-	/// Calculate the subtotals for the [Section] with the given id
-	pub fn subtotal_for_id(&self, id: &str) -> Option<Vec<QuantityInt>> {
-		if let Some(entry) = self.by_id(id) {
-			if let CalculatableDynamicReportEntry::CalculatableSection(section) = entry {
-				Some(section.subtotal(&self))
-			} else {
-				panic!("Called subtotal_for_id on non-Section");
-			}
-		} else {
-			None
-		}
-	}
-
-	// Return the quantities for the [LiteralRow] with the given id
-	pub fn quantity_for_id(&self, id: &str) -> Option<Vec<QuantityInt>> {
-		if let Some(entry) = self.by_id(id) {
-			if let CalculatableDynamicReportEntry::LiteralRow(row) = entry {
-				Some(row.quantity)
-			} else {
-				panic!("Called quantity_for_id on non-LiteralRow");
-			}
-		} else {
-			None
-		}
-	}
-}
-
-/// Represents a dynamically generated report composed of [DynamicReportEntry], with no [CalculatedRow]s
+/// Represents a dynamically generated report composed of [DynamicReportEntry]
 #[derive(Clone, Debug, Deserialize, Serialize)]
 pub struct DynamicReport {
 	pub title: String,
@@ -198,37 +41,13 @@ impl DynamicReport {
 		}
 	}
 
-	/// Remove all entries from the report where auto_hide is enabled and quantity is zero
-	pub fn auto_hide(&mut self) {
-		self.entries.retain_mut(|e| match e {
-			DynamicReportEntry::Section(section) => {
-				section.auto_hide_children();
-				if section.can_auto_hide_self() {
-					false
-				} else {
-					true
-				}
-			}
-			DynamicReportEntry::LiteralRow(row) => {
-				if row.can_auto_hide() {
-					false
-				} else {
-					true
-				}
-			}
-			DynamicReportEntry::Spacer => true,
-		});
-	}
-
 	/// Serialise the report (as JSON) using serde
 	pub fn to_json(&self) -> String {
 		serde_json::to_string(self).unwrap()
 	}
 
 	/// Look up [DynamicReportEntry] by id
-	///
-	/// Returns a cloned copy of the [DynamicReportEntry]. This is necessary because the entry may be within a [Section], and [RefCell] semantics cannot express this type of nested borrow.
-	pub fn by_id(&self, id: &str) -> Option<DynamicReportEntry> {
+	pub fn by_id(&self, id: &str) -> Option<&DynamicReportEntry> {
 		// Manually iterate over self.entries rather than self.entries()
 		// To catch the situation where entry is already mutably borrowed
 		for entry in self.entries.iter() {
@@ -236,25 +55,17 @@ impl DynamicReport {
 				DynamicReportEntry::Section(section) => {
 					if let Some(i) = &section.id {
 						if i == id {
-							return Some(entry.clone());
+							return Some(entry);
 						}
 					}
 					if let Some(e) = section.by_id(id) {
-						return Some(match e {
-							DynamicReportEntry::Section(section) => {
-								DynamicReportEntry::Section(section.clone())
-							}
-							DynamicReportEntry::LiteralRow(row) => {
-								DynamicReportEntry::LiteralRow(row.clone())
-							}
-							DynamicReportEntry::Spacer => DynamicReportEntry::Spacer,
-						});
+						return Some(e);
 					}
 				}
-				DynamicReportEntry::LiteralRow(row) => {
+				DynamicReportEntry::Row(row) => {
 					if let Some(i) = &row.id {
 						if i == id {
-							return Some(entry.clone());
+							return Some(entry);
 						}
 					}
 				}
@@ -266,10 +77,10 @@ impl DynamicReport {
 	}
 
 	// Return the quantities for the [LiteralRow] with the given id
-	pub fn quantity_for_id(&self, id: &str) -> Option<Vec<QuantityInt>> {
+	pub fn quantity_for_id(&self, id: &str) -> Option<&Vec<QuantityInt>> {
 		if let Some(entry) = self.by_id(id) {
-			if let DynamicReportEntry::LiteralRow(row) = entry {
-				Some(row.quantity)
+			if let DynamicReportEntry::Row(row) = entry {
+				Some(&row.quantity)
 			} else {
 				panic!("Called quantity_for_id on non-LiteralRow");
 			}
@@ -281,207 +92,36 @@ impl DynamicReport {
 
 impl ReportingProduct for DynamicReport {}
 
-#[derive(Clone, Debug)]
-pub enum CalculatableDynamicReportEntry {
-	CalculatableSection(CalculatableSection),
-	Section(Section),
-	LiteralRow(LiteralRow),
-	CalculatedRow(CalculatedRow),
-	Spacer,
-}
-
 #[derive(Clone, Debug, Deserialize, Serialize)]
 pub enum DynamicReportEntry {
 	Section(Section),
-	LiteralRow(LiteralRow),
+	Row(Row),
 	Spacer,
 }
 
-#[derive(Clone, Debug)]
-pub struct CalculatableSection {
-	pub text: String,
-	pub id: Option<String>,
-	pub visible: bool,
-	pub auto_hide: bool,
-	pub entries: Vec<RefCell<CalculatableDynamicReportEntry>>,
+impl From<Section> for DynamicReportEntry {
+	fn from(value: Section) -> Self {
+		DynamicReportEntry::Section(value)
+	}
 }
 
-impl CalculatableSection {
-	pub fn new(
-		text: String,
-		id: Option<String>,
-		visible: bool,
-		auto_hide: bool,
-		entries: Vec<CalculatableDynamicReportEntry>,
-	) -> Self {
-		Self {
-			text,
-			id,
-			visible,
-			auto_hide,
-			entries: entries.into_iter().map(|e| RefCell::new(e)).collect(),
-		}
-	}
-
-	/// Recursively calculate all [CalculatedRow] entries
-	pub fn calculate(&mut self, report: &CalculatableDynamicReport) -> Section {
-		let mut calculated_entries = Vec::new();
-
-		for (entry_idx, entry) in self.entries.iter().enumerate() {
-			let entry_ref = entry.borrow();
-
-			match &*entry_ref {
-				CalculatableDynamicReportEntry::CalculatableSection(section) => {
-					let updated_section = section.clone().calculate(&report);
-
-					drop(entry_ref); // Drop entry_ref so we can borrow mutably
-					let mut entry_mut = self.entries[entry_idx].borrow_mut();
-					*entry_mut = CalculatableDynamicReportEntry::Section(updated_section.clone());
-
-					calculated_entries.push(DynamicReportEntry::Section(updated_section));
-				}
-				CalculatableDynamicReportEntry::Section(section) => {
-					calculated_entries.push(DynamicReportEntry::Section(section.clone()));
-				}
-				CalculatableDynamicReportEntry::LiteralRow(row) => {
-					calculated_entries.push(DynamicReportEntry::LiteralRow(row.clone()));
-				}
-				CalculatableDynamicReportEntry::CalculatedRow(row) => {
-					let updated_row = row.calculate(&report);
-
-					drop(entry_ref); // Drop entry_ref so we can borrow mutably
-					let mut entry_mut = self.entries[entry_idx].borrow_mut();
-					*entry_mut = CalculatableDynamicReportEntry::LiteralRow(updated_row.clone());
-
-					calculated_entries.push(DynamicReportEntry::LiteralRow(updated_row));
-				}
-				CalculatableDynamicReportEntry::Spacer => {
-					calculated_entries.push(DynamicReportEntry::Spacer);
-				}
-			}
-		}
-
-		Section {
-			text: self.text.clone(),
-			id: self.id.clone(),
-			visible: self.visible,
-			auto_hide: self.auto_hide,
-			entries: calculated_entries,
-		}
-	}
-
-	/// Look up [CalculatableDynamicReportEntry] by id
-	///
-	/// Returns a cloned copy of the [CalculatableDynamicReportEntry].
-	pub fn by_id(&self, id: &str) -> Option<CalculatableDynamicReportEntry> {
-		// Manually iterate over self.entries rather than self.entries()
-		// To catch the situation where entry is already mutably borrowed
-		for entry in self.entries.iter() {
-			match entry.try_borrow() {
-				Ok(entry) => match &*entry {
-					CalculatableDynamicReportEntry::CalculatableSection(section) => {
-						if let Some(i) = &section.id {
-							if i == id {
-								return Some(entry.clone());
-							}
-						}
-						if let Some(e) = section.by_id(id) {
-							return Some(e);
-						}
-					}
-					CalculatableDynamicReportEntry::Section(_) => todo!(),
-					CalculatableDynamicReportEntry::LiteralRow(row) => {
-						if let Some(i) = &row.id {
-							if i == id {
-								return Some(entry.clone());
-							}
-						}
-					}
-					CalculatableDynamicReportEntry::CalculatedRow(_) => (),
-					CalculatableDynamicReportEntry::Spacer => (),
-				},
-				Err(err) => panic!(
-					"Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}",
-					err
-				),
-			}
-		}
-
-		None
-	}
-
-	/// Calculate the subtotals for this [CalculatableSection]
-	pub fn subtotal(&self, report: &CalculatableDynamicReport) -> Vec<QuantityInt> {
-		let mut subtotals = vec![0; report.columns.len()];
-		for entry in self.entries.iter() {
-			match &*entry.borrow() {
-				CalculatableDynamicReportEntry::CalculatableSection(section) => {
-					for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() {
-						subtotals[col_idx] += subtotal;
-					}
-				}
-				CalculatableDynamicReportEntry::Section(section) => {
-					for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() {
-						subtotals[col_idx] += subtotal;
-					}
-				}
-				CalculatableDynamicReportEntry::LiteralRow(row) => {
-					for (col_idx, subtotal) in row.quantity.iter().enumerate() {
-						subtotals[col_idx] += subtotal;
-					}
-				}
-				CalculatableDynamicReportEntry::CalculatedRow(_) => (),
-				CalculatableDynamicReportEntry::Spacer => (),
-			}
-		}
-		subtotals
+impl From<Row> for DynamicReportEntry {
+	fn from(value: Row) -> Self {
+		DynamicReportEntry::Row(value)
 	}
 }
 
 #[derive(Clone, Debug, Deserialize, Serialize)]
 pub struct Section {
-	pub text: String,
+	pub text: Option<String>,
 	pub id: Option<String>,
 	pub visible: bool,
-	pub auto_hide: bool,
 	pub entries: Vec<DynamicReportEntry>,
 }
 
 impl Section {
-	fn auto_hide_children(&mut self) {
-		self.entries.retain_mut(|e| match e {
-			DynamicReportEntry::Section(section) => {
-				section.auto_hide_children();
-				if section.can_auto_hide_self() {
-					false
-				} else {
-					true
-				}
-			}
-			DynamicReportEntry::LiteralRow(row) => {
-				if row.can_auto_hide() {
-					false
-				} else {
-					true
-				}
-			}
-			DynamicReportEntry::Spacer => true,
-		});
-	}
-
-	fn can_auto_hide_self(&self) -> bool {
-		self.auto_hide
-			&& self.entries.iter().all(|e| match e {
-				DynamicReportEntry::Section(section) => section.can_auto_hide_self(),
-				DynamicReportEntry::LiteralRow(row) => row.can_auto_hide(),
-				DynamicReportEntry::Spacer => true,
-			})
-	}
-
 	/// Look up [DynamicReportEntry] by id
-	///
-	/// Returns a cloned copy of the [DynamicReportEntry].
-	pub fn by_id(&self, id: &str) -> Option<DynamicReportEntry> {
+	pub fn by_id(&self, id: &str) -> Option<&DynamicReportEntry> {
 		// Manually iterate over self.entries rather than self.entries()
 		// To catch the situation where entry is already mutably borrowed
 		for entry in self.entries.iter() {
@@ -489,17 +129,17 @@ impl Section {
 				DynamicReportEntry::Section(section) => {
 					if let Some(i) = &section.id {
 						if i == id {
-							return Some(entry.clone());
+							return Some(entry);
 						}
 					}
 					if let Some(e) = section.by_id(id) {
 						return Some(e);
 					}
 				}
-				DynamicReportEntry::LiteralRow(row) => {
+				DynamicReportEntry::Row(row) => {
 					if let Some(i) = &row.id {
 						if i == id {
-							return Some(entry.clone());
+							return Some(entry);
 						}
 					}
 				}
@@ -511,7 +151,7 @@ impl Section {
 	}
 
 	/// Calculate the subtotals for this [Section]
-	pub fn subtotal(&self, report: &CalculatableDynamicReport) -> Vec<QuantityInt> {
+	pub fn subtotal(&self, report: &DynamicReport) -> Vec<QuantityInt> {
 		let mut subtotals = vec![0; report.columns.len()];
 		for entry in self.entries.iter() {
 			match entry {
@@ -520,7 +160,7 @@ impl Section {
 						subtotals[col_idx] += subtotal;
 					}
 				}
-				DynamicReportEntry::LiteralRow(row) => {
+				DynamicReportEntry::Row(row) => {
 					for (col_idx, subtotal) in row.quantity.iter().enumerate() {
 						subtotals[col_idx] += subtotal;
 					}
@@ -533,48 +173,22 @@ impl Section {
 }
 
 #[derive(Clone, Debug, Deserialize, Serialize)]
-pub struct LiteralRow {
+pub struct Row {
 	pub text: String,
 	pub quantity: Vec<QuantityInt>,
 	pub id: Option<String>,
 	pub visible: bool,
-	pub auto_hide: bool,
 	pub link: Option<String>,
 	pub heading: bool,
 	pub bordered: bool,
 }
 
-impl LiteralRow {
-	/// Returns whether the row has auto_hide enabled and all quantities are zero
-	fn can_auto_hide(&self) -> bool {
-		self.auto_hide && self.quantity.iter().all(|q| *q == 0)
-	}
-}
-
-#[derive(Clone, Debug)]
-pub struct CalculatedRow {
-	//pub text: String,
-	pub calculate_fn: fn(report: &CalculatableDynamicReport) -> LiteralRow,
-	//pub id: Option<String>,
-	//pub visible: bool,
-	//pub auto_hide: bool,
-	//pub link: Option<String>,
-	//pub heading: bool,
-	//pub bordered: bool,
-}
-
-impl CalculatedRow {
-	fn calculate(&self, report: &CalculatableDynamicReport) -> LiteralRow {
-		(self.calculate_fn)(report)
-	}
-}
-
 pub fn entries_for_kind(
 	kind: &str,
 	invert: bool,
 	balances: &Vec<&HashMap<String, QuantityInt>>,
 	kinds_for_account: &HashMap<String, Vec<String>>,
-) -> Vec<CalculatableDynamicReportEntry> {
+) -> Vec<DynamicReportEntry> {
 	// Get accounts of specified kind
 	let mut accounts = kinds_for_account
 		.iter()
@@ -596,6 +210,11 @@ pub fn entries_for_kind(
 			.map(|b| b.get(account).unwrap_or(&0) * if invert { -1 } else { 1 })
 			.collect::<Vec<_>>();
 
+		// Do not show if all quantities are zero
+		if quantities.iter().all(|q| *q == 0) {
+			continue;
+		}
+
 		// Some exceptions for the link
 		let link;
 		if account == crate::CURRENT_YEAR_EARNINGS {
@@ -606,17 +225,16 @@ pub fn entries_for_kind(
 			link = Some(format!("/transactions/{}", account));
 		}
 
-		let entry = LiteralRow {
+		let entry = Row {
 			text: account.to_string(),
 			quantity: quantities,
 			id: None,
 			visible: true,
-			auto_hide: true,
 			link,
 			heading: false,
 			bordered: false,
 		};
-		entries.push(CalculatableDynamicReportEntry::LiteralRow(entry));
+		entries.push(entry.into());
 	}
 
 	entries
diff --git a/libdrcr/src/reporting/steps.rs b/libdrcr/src/reporting/steps.rs
index ec3c7bb..bfa69ec 100644
--- a/libdrcr/src/reporting/steps.rs
+++ b/libdrcr/src/reporting/steps.rs
@@ -35,8 +35,7 @@ use crate::QuantityInt;
 
 use super::calculator::ReportingGraphDependencies;
 use super::dynamic_report::{
-	entries_for_kind, CalculatableDynamicReport, CalculatableDynamicReportEntry,
-	CalculatableSection, CalculatedRow, DynamicReport, DynamicReportEntry, LiteralRow,
+	entries_for_kind, DynamicReport, DynamicReportEntry, Row, Section,
 };
 use super::executor::ReportingExecutionError;
 use super::types::{
@@ -457,92 +456,79 @@ impl ReportingStep for BalanceSheet {
 			kinds_for_account(context.db_connection.get_account_configurations().await);
 
 		// Init report
-		let report = CalculatableDynamicReport::new(
+		let mut report = DynamicReport::new(
 			"Balance sheet".to_string(),
 			self.args.dates.iter().map(|d| d.date.to_string()).collect(),
-			vec![
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Assets".to_string(),
-					Some("assets".to_string()),
-					true,
-					false,
-					{
-						let mut entries =
-							entries_for_kind("drcr.asset", false, &balances, &kinds_for_account);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total assets".to_string(),
-									quantity: report.subtotal_for_id("assets").unwrap(),
-									id: Some("total_assets".to_string()),
-									visible: true,
-									auto_hide: false,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::Spacer,
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Liabilities".to_string(),
-					Some("liabilities".to_string()),
-					true,
-					false,
-					{
-						let mut entries =
-							entries_for_kind("drcr.liability", true, &balances, &kinds_for_account);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total liabilities".to_string(),
-									quantity: report.subtotal_for_id("liabilities").unwrap(),
-									id: Some("total_liabilities".to_string()),
-									visible: true,
-									auto_hide: false,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::Spacer,
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Equity".to_string(),
-					Some("equity".to_string()),
-					true,
-					false,
-					{
-						let mut entries =
-							entries_for_kind("drcr.equity", true, &balances, &kinds_for_account);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total equity".to_string(),
-									quantity: report.subtotal_for_id("equity").unwrap(),
-									id: Some("total_equity".to_string()),
-									visible: true,
-									auto_hide: false,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries
-					},
-				)),
-			],
+			Vec::new(),
 		);
 
-		let mut report = report.calculate();
-		report.auto_hide();
+		// Add assets section
+		let mut assets = Section {
+			text: Some("Assets".to_string()),
+			id: None,
+			visible: true,
+			entries: entries_for_kind("drcr.asset", false, &balances, &kinds_for_account),
+		};
+		let total_assets = assets.subtotal(&report);
+		assets.entries.push(
+			Row {
+				text: "Total assets".to_string(),
+				quantity: total_assets,
+				id: Some("total_assets".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
+		report.entries.push(assets.into());
+		report.entries.push(DynamicReportEntry::Spacer);
+
+		// Add liabilities section
+		let mut liabilities = Section {
+			text: Some("Liabilities".to_string()),
+			id: None,
+			visible: true,
+			entries: entries_for_kind("drcr.liability", true, &balances, &kinds_for_account),
+		};
+		let total_liabilities = liabilities.subtotal(&report);
+		liabilities.entries.push(
+			Row {
+				text: "Total liabilities".to_string(),
+				quantity: total_liabilities,
+				id: Some("total_liabilities".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
+		report.entries.push(liabilities.into());
+		report.entries.push(DynamicReportEntry::Spacer);
+
+		// Add equity section
+		let mut equity = Section {
+			text: Some("Equity".to_string()),
+			id: None,
+			visible: true,
+			entries: entries_for_kind("drcr.equity", true, &balances, &kinds_for_account),
+		};
+		let total_equity = equity.subtotal(&report);
+		equity.entries.push(
+			Row {
+				text: "Total equity".to_string(),
+				quantity: total_equity,
+				id: Some("total_equity".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
+		report.entries.push(equity.into());
 
 		// Store the result
 		let mut result = ReportingProducts::new();
@@ -1089,89 +1075,80 @@ impl ReportingStep for IncomeStatement {
 			kinds_for_account(context.db_connection.get_account_configurations().await);
 
 		// Init report
-		let report = CalculatableDynamicReport::new(
+		let mut report = DynamicReport::new(
 			"Income statement".to_string(),
 			self.args
 				.dates
 				.iter()
 				.map(|d| d.date_end.to_string())
 				.collect(),
-			vec![
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Income".to_string(),
-					Some("income".to_string()),
-					true,
-					false,
-					{
-						let mut entries =
-							entries_for_kind("drcr.income", true, &balances, &kinds_for_account);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total income".to_string(),
-									quantity: report.subtotal_for_id("income").unwrap(),
-									id: Some("total_income".to_string()),
-									visible: true,
-									auto_hide: false,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::Spacer,
-				CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new(
-					"Expenses".to_string(),
-					Some("expenses".to_string()),
-					true,
-					false,
-					{
-						let mut entries =
-							entries_for_kind("drcr.expense", false, &balances, &kinds_for_account);
-						entries.push(CalculatableDynamicReportEntry::CalculatedRow(
-							CalculatedRow {
-								calculate_fn: |report| LiteralRow {
-									text: "Total expenses".to_string(),
-									quantity: report.subtotal_for_id("expenses").unwrap(),
-									id: Some("total_expenses".to_string()),
-									visible: true,
-									auto_hide: false,
-									link: None,
-									heading: true,
-									bordered: true,
-								},
-							},
-						));
-						entries
-					},
-				)),
-				CalculatableDynamicReportEntry::Spacer,
-				CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
-					calculate_fn: |report| LiteralRow {
-						text: "Net surplus (deficit)".to_string(),
-						quantity: report
-							.quantity_for_id("total_income")
-							.unwrap() // Get total income row
-							.iter()
-							.zip(report.quantity_for_id("total_expenses").unwrap().iter()) // Zip with total expenses row
-							.map(|(i, e)| i - e) // Compute net surplus
-							.collect(),
-						id: Some("net_surplus".to_string()),
-						visible: true,
-						auto_hide: false,
-						link: None,
-						heading: true,
-						bordered: true,
-					},
-				}),
-			],
+			Vec::new(),
 		);
 
-		let mut report = report.calculate();
-		report.auto_hide();
+		// Add income section
+		let mut income = Section {
+			text: Some("Income".to_string()),
+			id: None,
+			visible: true,
+			entries: entries_for_kind("drcr.income", true, &balances, &kinds_for_account),
+		};
+		let total_income = income.subtotal(&report);
+		income.entries.push(
+			Row {
+				text: "Total income".to_string(),
+				quantity: total_income.clone(),
+				id: Some("total_income".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
+		report.entries.push(income.into());
+		report.entries.push(DynamicReportEntry::Spacer);
+
+		// Add expenses section
+		let mut expenses = Section {
+			text: Some("Expenses".to_string()),
+			id: None,
+			visible: true,
+			entries: entries_for_kind("drcr.expense", false, &balances, &kinds_for_account),
+		};
+		let total_expenses = expenses.subtotal(&report);
+		expenses.entries.push(
+			Row {
+				text: "Total expenses".to_string(),
+				quantity: total_expenses.clone(),
+				id: Some("total_expenses".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
+		report.entries.push(expenses.into());
+		report.entries.push(DynamicReportEntry::Spacer);
+
+		// Add net surplus (deficit) row
+		let net_surplus = total_income
+			.into_iter()
+			.zip(total_expenses.into_iter())
+			.map(|(i, e)| i - e)
+			.collect();
+		report.entries.push(
+			Row {
+				text: "Net surplus (deficit)".to_string(),
+				quantity: net_surplus,
+				id: Some("net_surplus".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
+		);
 
 		// Store the result
 		let mut result = ReportingProducts::new();
@@ -1512,62 +1489,64 @@ impl ReportingStep for TrialBalance {
 		let mut accounts = balances.keys().collect::<Vec<_>>();
 		accounts.sort();
 
-		// Get total debits and credits
-		let total_dr = balances.values().filter(|b| **b >= 0).sum::<i64>();
-		let total_cr = -balances.values().filter(|b| **b < 0).sum::<i64>();
-
 		// Init report
-		let mut report = DynamicReport::new(
-			"Trial balance".to_string(),
-			vec!["Dr".to_string(), "Cr".to_string()],
-			{
-				let mut entries = Vec::new();
+		let mut report = DynamicReport {
+			title: "Trial balance".to_string(),
+			columns: vec!["Dr".to_string(), "Cr".to_string()],
+			entries: Vec::new(),
+		};
 
-				// Entry for each account
-				for account in accounts {
-					entries.push(DynamicReportEntry::LiteralRow(LiteralRow {
-						text: account.clone(),
-						quantity: vec![
-							// Dr cell
-							if balances[account] >= 0 {
-								balances[account]
-							} else {
-								0
-							},
-							// Cr cell
-							if balances[account] < 0 {
-								-balances[account]
-							} else {
-								0
-							},
-						],
-						id: None,
-						visible: true,
-						auto_hide: true,
-						link: Some(format!("/transactions/{}", account)),
-						heading: false,
-						bordered: false,
-					}));
-				}
-
-				// Total row
-				entries.push(DynamicReportEntry::LiteralRow(LiteralRow {
-					text: "Totals".to_string(),
-					quantity: vec![total_dr, total_cr],
-					id: Some("totals".to_string()),
+		// Add entry for each account
+		let mut section = Section {
+			text: None,
+			id: None,
+			visible: true,
+			entries: Vec::new(),
+		};
+		for account in accounts {
+			section.entries.push(
+				Row {
+					text: account.clone(),
+					quantity: vec![
+						// Dr cell
+						if balances[account] >= 0 {
+							balances[account]
+						} else {
+							0
+						},
+						// Cr cell
+						if balances[account] < 0 {
+							-balances[account]
+						} else {
+							0
+						},
+					],
+					id: None,
 					visible: true,
-					auto_hide: false,
-					link: None,
-					heading: true,
-					bordered: true,
-				}));
+					link: Some(format!("/transactions/{}", account)),
+					heading: false,
+					bordered: false,
+				}
+				.into(),
+			);
+		}
+		let totals_row = section.subtotal(&report);
+		report.entries.push(section.into());
 
-				entries
-			},
+		// Add total row
+		report.entries.push(
+			Row {
+				text: "Totals".to_string(),
+				quantity: totals_row,
+				id: Some("totals".to_string()),
+				visible: true,
+				link: None,
+				heading: true,
+				bordered: true,
+			}
+			.into(),
 		);
 
-		report.auto_hide();
-
 		// Store result
 		let mut result = ReportingProducts::new();
 		result.insert(
diff --git a/src/components/DynamicReportEntryComponent.vue b/src/components/DynamicReportEntryComponent.vue
index 3d5d4a6..321ea4f 100644
--- a/src/components/DynamicReportEntryComponent.vue
+++ b/src/components/DynamicReportEntryComponent.vue
@@ -18,14 +18,14 @@
 -->
 
 <template>
-	<template v-if="literalRow">
-		<template v-if="literalRow.visible">
-			<tr :class="literalRow.bordered ? 'border-y border-gray-300' : null">
-				<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': literalRow.heading }">
-					<a :href="literalRow.link as string" class="hover:text-blue-700 hover:underline" v-if="literalRow.link !== null">{{ literalRow.text }}</a>
-					<template v-if="literalRow.link === null">{{ literalRow.text }}</template>
+	<template v-if="row">
+		<template v-if="row.visible">
+			<tr :class="row.bordered ? 'border-y border-gray-300' : null">
+				<component :is="row.heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': row.heading }">
+					<a :href="row.link as string" class="hover:text-blue-700 hover:underline" v-if="row.link !== null">{{ row.text }}</a>
+					<template v-if="row.link === null">{{ row.text }}</template>
 				</component>
-				<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': literalRow.heading }" v-html="(cell !== 0 || literalRow.heading) ? ppBracketed(cell, literalRow.link ?? undefined) : ''" v-for="cell of literalRow.quantity">
+				<component :is="row.heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': row.heading }" v-html="(cell !== 0 || row.heading) ? ppBracketed(cell, row.link ?? undefined) : ''" v-for="cell of row.quantity">
 				</component>
 			</tr>
 		</template>
@@ -48,12 +48,12 @@
 	import { computed } from 'vue';
 
 	import { ppBracketed } from '../display.ts';
-	import { DynamicReportEntry, LiteralRow, Section } from '../reports/base.ts';
+	import { DynamicReportEntry, Row, Section } from '../reports/base.ts';
 	
 	const { entry } = defineProps<{ entry: DynamicReportEntry }>();
 	
-	const literalRow = computed(function() {
-		return (entry as { LiteralRow: LiteralRow }).LiteralRow;
+	const row = computed(function() {
+		return (entry as { Row: Row }).Row;
 	});
 	const section = computed(function() {
 		return (entry as { Section: Section }).Section;
diff --git a/src/reports/BalanceSheetReport.vue b/src/reports/BalanceSheetReport.vue
index 68c1dfa..daa6768 100644
--- a/src/reports/BalanceSheetReport.vue
+++ b/src/reports/BalanceSheetReport.vue
@@ -54,7 +54,7 @@
 	
 	import { ExclamationCircleIcon } from '@heroicons/vue/20/solid';
 	
-	import { DynamicReport, LiteralRow, reportEntryById } from './base.ts';
+	import { DynamicReport, Row, reportEntryById } from './base.ts';
 	import { db } from '../db.ts';
 	import DynamicReportComponent from '../components/DynamicReportComponent.vue';
 	
@@ -118,9 +118,9 @@
 			return true;
 		}
 		
-		const totalAssets = (reportEntryById(report.value, 'total_assets') as { LiteralRow: LiteralRow }).LiteralRow.quantity;
-		const totalLiabilities = (reportEntryById(report.value, 'total_liabilities') as { LiteralRow: LiteralRow }).LiteralRow.quantity;
-		const totalEquity = (reportEntryById(report.value, 'total_equity') as { LiteralRow: LiteralRow }).LiteralRow.quantity;
+		const totalAssets = (reportEntryById(report.value, 'total_assets') as { Row: Row }).Row.quantity;
+		const totalLiabilities = (reportEntryById(report.value, 'total_liabilities') as { Row: Row }).Row.quantity;
+		const totalEquity = (reportEntryById(report.value, 'total_equity') as { Row: Row }).Row.quantity;
 		
 		let doesBalance = true;
 		for (let column = 0; column < report.value.columns.length; column++) {
diff --git a/src/reports/base.ts b/src/reports/base.ts
index c9715fb..fa5925f 100644
--- a/src/reports/base.ts
+++ b/src/reports/base.ts
@@ -24,7 +24,7 @@ export interface DynamicReport {
 }
 
 // serde_json serialises an enum like this
-export type DynamicReportEntry = { Section: Section } | { LiteralRow: LiteralRow } | 'Spacer';
+export type DynamicReportEntry = { Section: Section } | { Row: Row } | 'Spacer';
 
 export interface Section {
 	text: string;
@@ -34,7 +34,7 @@ export interface Section {
 	entries: DynamicReportEntry[];
 }
 
-export interface LiteralRow {
+export interface Row {
 	text: string;
 	quantity: number[];
 	id: string;
@@ -55,8 +55,8 @@ export function reportEntryById(report: DynamicReport | Section, id: string): Dy
 			if (result !== null) {
 				return result;
 			}
-		} else if ((entry as { LiteralRow: LiteralRow }).LiteralRow) {
-			if ((entry as { LiteralRow: LiteralRow }).LiteralRow.id === id) {
+		} else if ((entry as { Row: Row }).Row) {
+			if ((entry as { Row: Row }).Row.id === id) {
 				return entry;
 			}
 		}