From 807316a0901c8234ffbeb85d692b8dd985db0170 Mon Sep 17 00:00:00 2001
From: RunasSudo <runassudo@yingtongli.me>
Date: Tue, 27 May 2025 00:22:52 +1000
Subject: [PATCH] Implement income statement report using libdrcr

---
 src-tauri/src/lib.rs                  | 40 ++++++++-----
 src-tauri/src/libdrcr_bridge.rs       | 68 +++++++++++++++++++++-
 src/reports/IncomeStatementReport.vue | 81 ++++-----------------------
 3 files changed, 104 insertions(+), 85 deletions(-)

diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index e120918..4c57e3e 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,17 +1,17 @@
 /*
 	DrCr: Web-based double-entry bookkeeping framework
-	Copyright (C) 2022–2024  Lee Yingtong Li (RunasSudo)
-	
+	Copyright (C) 2022-2025  Lee Yingtong Li (RunasSudo)
+
 	This program is free software: you can redistribute it and/or modify
 	it under the terms of the GNU Affero General Public License as published by
 	the Free Software Foundation, either version 3 of the License, or
 	(at your option) any later version.
-	
+
 	This program is distributed in the hope that it will be useful,
 	but WITHOUT ANY WARRANTY; without even the implied warranty of
 	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 	GNU Affero General Public License for more details.
-	
+
 	You should have received a copy of the GNU Affero General Public License
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
@@ -33,20 +33,26 @@ struct AppState {
 // Filename state
 
 #[tauri::command]
-async fn get_open_filename(state: State<'_, Mutex<AppState>>) -> Result<Option<String>, tauri_plugin_sql::Error> {
+async fn get_open_filename(
+	state: State<'_, Mutex<AppState>>,
+) -> Result<Option<String>, tauri_plugin_sql::Error> {
 	let state = state.lock().await;
 	Ok(state.db_filename.clone())
 }
 
 #[tauri::command]
-async fn set_open_filename(state: State<'_, Mutex<AppState>>, app: AppHandle, filename: Option<String>) -> Result<(), tauri_plugin_sql::Error> {
+async fn set_open_filename(
+	state: State<'_, Mutex<AppState>>,
+	app: AppHandle,
+	filename: Option<String>,
+) -> Result<(), tauri_plugin_sql::Error> {
 	let mut state = state.lock().await;
 	state.db_filename = filename.clone();
-	
+
 	// Persist in store
 	let store = app.store("store.json").expect("Error opening store");
 	store.set("db_filename", filename);
-	
+
 	Ok(())
 }
 
@@ -66,15 +72,15 @@ pub fn run() {
 					} else {
 						None
 					}
-				},
-				_ => panic!("Unexpected db_filename in store")
+				}
+				_ => panic!("Unexpected db_filename in store"),
 			};
-			
+
 			app.manage(Mutex::new(AppState {
 				db_filename: db_filename,
 				sql_transactions: Vec::new(),
 			}));
-			
+
 			Ok(())
 		})
 		.plugin(tauri_plugin_dialog::init())
@@ -82,9 +88,15 @@ pub fn run() {
 		.plugin(tauri_plugin_sql::Builder::new().build())
 		.plugin(tauri_plugin_store::Builder::new().build())
 		.invoke_handler(tauri::generate_handler![
-			get_open_filename, set_open_filename,
+			get_open_filename,
+			set_open_filename,
 			libdrcr_bridge::get_balance_sheet,
-			sql::sql_transaction_begin, sql::sql_transaction_execute, sql::sql_transaction_select, sql::sql_transaction_rollback, sql::sql_transaction_commit
+			libdrcr_bridge::get_income_statement,
+			sql::sql_transaction_begin,
+			sql::sql_transaction_execute,
+			sql::sql_transaction_select,
+			sql::sql_transaction_rollback,
+			sql::sql_transaction_commit
 		])
 		.run(tauri::generate_context!())
 		.expect("Error while running tauri application");
diff --git a/src-tauri/src/libdrcr_bridge.rs b/src-tauri/src/libdrcr_bridge.rs
index 1541ae2..ead5a20 100644
--- a/src-tauri/src/libdrcr_bridge.rs
+++ b/src-tauri/src/libdrcr_bridge.rs
@@ -23,8 +23,8 @@ use libdrcr::reporting::dynamic_report::DynamicReport;
 use libdrcr::reporting::generate_report;
 use libdrcr::reporting::steps::register_lookup_fns;
 use libdrcr::reporting::types::{
-	DateArgs, MultipleDateArgs, ReportingContext, ReportingProductId, ReportingProductKind,
-	VoidArgs,
+	DateArgs, DateStartDateEndArgs, MultipleDateArgs, MultipleDateStartDateEndArgs,
+	ReportingContext, ReportingProductId, ReportingProductKind, VoidArgs,
 };
 use tauri::State;
 use tokio::sync::Mutex;
@@ -94,3 +94,67 @@ pub(crate) async fn get_balance_sheet(
 	.await
 	.unwrap()
 }
+
+#[tauri::command]
+pub(crate) async fn get_income_statement(
+	state: State<'_, Mutex<AppState>>,
+	eofy_date: String,
+	dates: Vec<(String, String)>,
+) -> Result<String, ()> {
+	let state = state.lock().await;
+	let db_filename = state.db_filename.clone().unwrap();
+
+	spawn_blocking(move || {
+		// Connect to database
+		let db_connection =
+			DbConnection::connect(format!("sqlite:{}", db_filename.as_str()).as_str());
+
+		// Initialise ReportingContext
+		let mut context = ReportingContext::new(
+			db_connection,
+			NaiveDate::parse_from_str(eofy_date.as_str(), "%Y-%m-%d").unwrap(),
+			"$".to_string(),
+		);
+		register_lookup_fns(&mut context);
+		register_dynamic_builders(&mut context);
+
+		// Get income statement
+		let mut date_args = Vec::new();
+		for (date_start, date_end) in dates.iter() {
+			date_args.push(DateStartDateEndArgs {
+				date_start: NaiveDate::parse_from_str(date_start, "%Y-%m-%d").unwrap(),
+				date_end: NaiveDate::parse_from_str(date_end, "%Y-%m-%d").unwrap(),
+			})
+		}
+		let targets = vec![
+			ReportingProductId {
+				name: "CalculateIncomeTax",
+				kind: ReportingProductKind::Transactions,
+				args: Box::new(VoidArgs {}),
+			},
+			ReportingProductId {
+				name: "IncomeStatement",
+				kind: ReportingProductKind::Generic,
+				args: Box::new(MultipleDateStartDateEndArgs {
+					dates: date_args.clone(),
+				}),
+			},
+		];
+
+		// Run report
+		let products = generate_report(targets, &context).unwrap();
+		let result = products
+			.get_or_err(&ReportingProductId {
+				name: "IncomeStatement",
+				kind: ReportingProductKind::Generic,
+				args: Box::new(MultipleDateStartDateEndArgs { dates: date_args }),
+			})
+			.unwrap();
+
+		let income_statement = result.downcast_ref::<DynamicReport>().unwrap().to_json();
+
+		Ok(income_statement)
+	})
+	.await
+	.unwrap()
+}
diff --git a/src/reports/IncomeStatementReport.vue b/src/reports/IncomeStatementReport.vue
index e363ff7..9482490 100644
--- a/src/reports/IncomeStatementReport.vue
+++ b/src/reports/IncomeStatementReport.vue
@@ -1,6 +1,6 @@
 <!--
 	DrCr: Web-based double-entry bookkeeping framework
-	Copyright (C) 2022–2025  Lee Yingtong Li (RunasSudo)
+	Copyright (C) 2022-2025  Lee Yingtong Li (RunasSudo)
 	
 	This program is free software: you can redistribute it and/or modify
 	it under the terms of the GNU Affero General Public License as published by
@@ -16,48 +16,8 @@
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 -->
 
-<script lang="ts">
-	export class IncomeStatementReport extends DynamicReport {
-		constructor() {
-			super('Income statement');
-		}
-		
-		async generate(balances: Map<string, number>) {
-			const report = this;
-			this.entries = [
-				new Section(
-					'Income',
-					[
-						...await DynamicReport.entriesForKind(balances, 'drcr.income', true),
-						new Subtotal('Total income', 'total_income', true /* visible */, true /* bordered */)
-					]
-				),
-				new Spacer(),
-				new Section(
-					'Expenses',
-					[
-						...await DynamicReport.entriesForKind(balances, 'drcr.expense'),
-						new Subtotal('Total expenses', 'total_expenses', true /* visible */, true /* bordered */)
-					]
-				),
-				new Spacer(),
-				new Computed(
-					'Net surplus (deficit)',
-					() => (report.byId('total_income') as Subtotal).quantity - (report.byId('total_expenses') as Subtotal).quantity,
-					'net_surplus',
-					true /* visible */, false /* autoHide */, null /* link */, true /* heading */, true /* bordered */
-				)
-			];
-			
-			this.calculate();
-		}
-	}
-</script>
-
-<!-- Report display -->
-
 <template>
-	<ComparativeDynamicReportComponent :reports="reports" :labels="reportLabels">
+	<DynamicReportComponent :report="report">
 		<div class="my-2 py-2 flex">
 			<div class="grow flex gap-x-2 items-baseline">
 				<input type="date" class="bordered-field" v-model.lazy="dtStart">
@@ -75,21 +35,20 @@
 				</div>
 			</div>
 		</div>
-	</ComparativeDynamicReportComponent>
+	</DynamicReportComponent>
 </template>
 
 <script setup lang="ts">
-	import { ref, watch } from 'vue';
 	import dayjs from 'dayjs';
+	import { invoke } from '@tauri-apps/api/core';
+	import { ref, watch } from 'vue';
 	
-	import { Computed, DynamicReport, Section, Spacer, Subtotal } from './base.ts';
+	import { DynamicReport } from './base.ts';
 	import { db } from '../db.ts';
 	import { ExtendedDatabase } from '../dbutil.ts';
-	import ComparativeDynamicReportComponent from '../components/ComparativeDynamicReportComponent.vue';
-	import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
+	import DynamicReportComponent from '../components/DynamicReportComponent.vue';
 	
-	const reports = ref([] as IncomeStatementReport[]);
-	const reportLabels = ref([] as string[]);
+	const report = ref(null as DynamicReport | null);
 	
 	const dt = ref(null as string | null);
 	const dtStart = ref(null as string | null);
@@ -114,16 +73,14 @@
 	}
 	
 	async function updateReport(session: ExtendedDatabase) {
-		const newReportPromises = [];
-		const newReportLabels = [];
+		const reportDates = [];
 		for (let i = 0; i < comparePeriods.value; i++) {
-			let thisReportDt, thisReportDtStart, thisReportLabel;
+			let thisReportDt, thisReportDtStart;
 			
 			// Get period start and end dates
 			if (compareUnit.value === 'years') {
 				thisReportDt = dayjs(dt.value!).subtract(i, 'year');
 				thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'year');
-				thisReportLabel = dayjs(dt.value!).subtract(i, 'year').format('YYYY');
 			} else if (compareUnit.value === 'months') {
 				if (dayjs(dt.value!).add(1, 'day').isSame(dayjs(dt.value!).set('date', 1).add(1, 'month'))) {
 					// If dt is the end of a calendar month, then fix each prior dt to be the end of the calendar month
@@ -133,28 +90,14 @@
 					thisReportDt = dayjs(dt.value!).subtract(i, 'month');
 					thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'month');
 				}
-				thisReportLabel = dayjs(dt.value!).subtract(i, 'month').format('YYYY-MM');
 			} else {
 				throw new Error('Unexpected compareUnit');
 			}
 			
-			// Generate reports asynchronously
-			newReportPromises.push((async () => {
-				const reportingWorkflow = new ReportingWorkflow();
-				await reportingWorkflow.generate(session, thisReportDt.format('YYYY-MM-DD'), thisReportDtStart.format('YYYY-MM-DD'));
-				return reportingWorkflow.getReportAtStage(ReportingStage.InterimIncomeStatement, IncomeStatementReport) as IncomeStatementReport;
-			})());
-			
-			if (comparePeriods.value === 1) {
-				// If only 1 report, the heading is simply "$"
-				newReportLabels.push(db.metadata.reporting_commodity);
-			} else {
-				newReportLabels.push(thisReportLabel);
-			}
+			reportDates.push([thisReportDtStart.format('YYYY-MM-DD'), thisReportDt.format('YYYY-MM-DD')]);
 		}
 		
-		reports.value = await Promise.all(newReportPromises);
-		reportLabels.value = newReportLabels;
+		report.value = JSON.parse(await invoke('get_income_statement', { eofyDate: db.metadata.eofy_date, dates: reportDates }));
 	}
 	
 	load();